mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: composer redesign, bottom tab bar, bug fixes, animation optimization
- Composer: auto-focus with keyboard, send button in top-right with dropdown (Publish/Draft/Schedule), smaller 120dp image thumbnails with fullscreen preview - Navigation: bottom tab bar (Home/Search/Settings), hidden on detail screens - Share now copies link to clipboard instead of opening share sheet - Fix: pinned label no longer shows twice - Fix: drafts now appear in feed - Fix: schedule picker blocks past dates, no more NPE crash - Animations: snappier springs (1500f stiffness), shorter tweens (150-200ms), @Immutable on data classes, Coil crossfade 150ms with cache config, LazyColumn contentType for better reuse
This commit is contained in:
parent
2470f9a049
commit
c3fb3c7c98
12 changed files with 604 additions and 380 deletions
|
|
@ -1,12 +1,33 @@
|
|||
package com.swoosh.microblog
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.swoosh.microblog.worker.PostUploadWorker
|
||||
|
||||
class SwooshApp : Application() {
|
||||
class SwooshApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Schedule periodic connectivity check for queued posts
|
||||
PostUploadWorker.enqueuePeriodicCheck(this)
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.crossfade(150) // Short crossfade for smooth image loading
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25) // Use 25% of available memory
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.05) // 5% of disk
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class GhostAccount(
|
||||
val id: String,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.swoosh.microblog.data.model
|
|||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// --- API Response/Request Models ---
|
||||
|
|
@ -106,6 +107,7 @@ enum class QueueStatus {
|
|||
|
||||
// --- UI Display Model ---
|
||||
|
||||
@Immutable
|
||||
data class FeedPost(
|
||||
val localId: Long? = null,
|
||||
val ghostId: String? = null,
|
||||
|
|
@ -131,6 +133,7 @@ data class FeedPost(
|
|||
val queueStatus: QueueStatus = QueueStatus.NONE
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class LinkPreview(
|
||||
val url: String,
|
||||
val title: String?,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Aggregate statistics across all posts.
|
||||
*/
|
||||
@Immutable
|
||||
data class OverallStats(
|
||||
val totalPosts: Int = 0,
|
||||
val publishedCount: Int = 0,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.swoosh.microblog.data.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* Statistics calculated for a single post.
|
||||
*/
|
||||
@Immutable
|
||||
data class PostStats(
|
||||
val wordCount: Int,
|
||||
val charCount: Int,
|
||||
|
|
|
|||
|
|
@ -2,42 +2,45 @@ package com.swoosh.microblog.ui.animation
|
|||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
|
||||
object SwooshMotion {
|
||||
|
||||
// Expressive bounce — FAB entrance, chips, badges. One visible overshoot.
|
||||
// Expressive bounce — FAB entrance, chips, badges.
|
||||
// Tighter damping + higher stiffness = settles faster with less wobble.
|
||||
fun <T> bouncy(): FiniteAnimationSpec<T> = spring(
|
||||
dampingRatio = 0.65f,
|
||||
stiffness = 400f
|
||||
dampingRatio = 0.7f,
|
||||
stiffness = Spring.StiffnessMedium // 1500f → settles in ~200ms
|
||||
)
|
||||
|
||||
// Fast snap-back — press feedback, button taps. Settles in ~150ms.
|
||||
// Fast snap-back — press feedback, button taps. Settles in ~100ms.
|
||||
fun <T> bouncyQuick(): FiniteAnimationSpec<T> = spring(
|
||||
dampingRatio = 0.7f,
|
||||
stiffness = 1000f
|
||||
dampingRatio = 0.75f,
|
||||
stiffness = Spring.StiffnessMediumLow * 3f // ~1200f → very fast settle
|
||||
)
|
||||
|
||||
// Controlled spring — expand/collapse, dialogs.
|
||||
// Controlled spring — expand/collapse, dialogs. Critically damped for snappiness.
|
||||
fun <T> snappy(): FiniteAnimationSpec<T> = spring(
|
||||
dampingRatio = 0.7f,
|
||||
stiffness = 800f
|
||||
)
|
||||
|
||||
// Soft entrance — cards, content reveal.
|
||||
fun <T> gentle(): FiniteAnimationSpec<T> = spring(
|
||||
dampingRatio = 0.8f,
|
||||
stiffness = 300f
|
||||
stiffness = Spring.StiffnessMedium // 1500f → no wobble, fast
|
||||
)
|
||||
|
||||
// Quick tween — fade, color transitions.
|
||||
// Soft entrance — cards, content reveal. Slightly underdamped for polish.
|
||||
fun <T> gentle(): FiniteAnimationSpec<T> = spring(
|
||||
dampingRatio = 0.85f,
|
||||
stiffness = Spring.StiffnessMediumLow // 400f → smooth, fast settle
|
||||
)
|
||||
|
||||
// Quick tween — fade, color transitions. Reduced from 200ms to 150ms.
|
||||
fun <T> quick(): FiniteAnimationSpec<T> = tween(
|
||||
durationMillis = 200,
|
||||
durationMillis = 150,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
|
||||
// Stagger delays
|
||||
const val StaggerDelayMs = 50L
|
||||
const val RevealDelayMs = 80L
|
||||
// Stagger delays — reduced from 50ms to 35ms for snappier cascades.
|
||||
const val StaggerDelayMs = 35L
|
||||
// Reveal delay — reduced from 80ms to 50ms for faster sequential reveals.
|
||||
const val RevealDelayMs = 50L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ fun PulsingPlaceholder(
|
|||
initialValue = 0.12f,
|
||||
targetValue = 0.28f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800),
|
||||
animation = tween(650),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseAlpha"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.animation.Crossfade
|
|||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
|
|
@ -23,6 +24,8 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
|
|
@ -35,6 +38,8 @@ import androidx.compose.ui.text.input.TransformedText
|
|||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
|
|
@ -62,6 +67,12 @@ fun ComposerScreen(
|
|||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showAltTextDialog by remember { mutableStateOf(false) }
|
||||
var linkInput by remember { mutableStateOf("") }
|
||||
var showSendMenu by remember { mutableStateOf(false) }
|
||||
var fullScreenImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var fullScreenImageIndex by remember { mutableIntStateOf(-1) }
|
||||
|
||||
// Focus requester for auto-focus on text field
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// Load post for editing
|
||||
LaunchedEffect(editPost) {
|
||||
|
|
@ -70,6 +81,12 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-focus on text input with a small delay
|
||||
LaunchedEffect(Unit) {
|
||||
delay(300)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.isSuccess) {
|
||||
if (state.isSuccess) {
|
||||
viewModel.reset()
|
||||
|
|
@ -86,14 +103,7 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Single image picker (legacy)
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
viewModel.addImages(listOf(uri))
|
||||
}
|
||||
}
|
||||
val canSubmit = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -116,6 +126,76 @@ fun ComposerScreen(
|
|||
Icon(Icons.Default.Fullscreen, "Full screen preview")
|
||||
}
|
||||
}
|
||||
|
||||
// Send button
|
||||
if (state.isSubmitting) {
|
||||
Box(
|
||||
modifier = Modifier.size(48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
FilledIconButton(
|
||||
onClick = viewModel::publish,
|
||||
enabled = canSubmit,
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Send, "Publish")
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown arrow for more publish options
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showSendMenu = true },
|
||||
enabled = !state.isSubmitting
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
"More publish options",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSendMenu,
|
||||
onDismissRequest = { showSendMenu = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (state.isEditing) "Update & Publish" else "Publish Now") },
|
||||
onClick = {
|
||||
showSendMenu = false
|
||||
viewModel.publish()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Send, null) },
|
||||
enabled = canSubmit
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save as Draft") },
|
||||
onClick = {
|
||||
showSendMenu = false
|
||||
viewModel.saveDraft()
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Save, null) },
|
||||
enabled = canSubmit
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Schedule...") },
|
||||
onClick = {
|
||||
showSendMenu = false
|
||||
showDatePicker = true
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Schedule, null) },
|
||||
enabled = canSubmit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -200,7 +280,7 @@ fun ComposerScreen(
|
|||
)
|
||||
}
|
||||
} else {
|
||||
// Edit mode: original composer UI
|
||||
// Edit mode: composer UI
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -208,13 +288,14 @@ fun ComposerScreen(
|
|||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Text field with character counter and hashtag highlighting
|
||||
// Text field — primary focus, takes most of the screen
|
||||
OutlinedTextField(
|
||||
value = state.text,
|
||||
onValueChange = viewModel::updateText,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 150.dp),
|
||||
.heightIn(min = 200.dp)
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = { Text("What's on your mind?") },
|
||||
visualTransformation = hashtagTransformation,
|
||||
supportingText = {
|
||||
|
|
@ -284,7 +365,7 @@ fun ComposerScreen(
|
|||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Attachment buttons row
|
||||
// Compact attachment buttons row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
|
@ -296,7 +377,7 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Image grid preview (multi-image)
|
||||
// Image grid preview (multi-image) — 120dp thumbnails
|
||||
AnimatedVisibility(
|
||||
visible = state.imageUris.isNotEmpty(),
|
||||
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
||||
|
|
@ -307,7 +388,11 @@ fun ComposerScreen(
|
|||
ImageGridPreview(
|
||||
imageUris = state.imageUris,
|
||||
onRemoveImage = viewModel::removeImage,
|
||||
onAddMore = { multiImagePickerLauncher.launch("image/*") }
|
||||
onAddMore = { multiImagePickerLauncher.launch("image/*") },
|
||||
onImageClick = { index, uri ->
|
||||
fullScreenImageUri = uri
|
||||
fullScreenImageIndex = index
|
||||
}
|
||||
)
|
||||
|
||||
// Alt text for the first/primary image
|
||||
|
|
@ -424,33 +509,7 @@ fun ComposerScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Feature toggle
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.PushPin,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (state.featured) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Feature this post",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.featured,
|
||||
onCheckedChange = { viewModel.toggleFeatured() }
|
||||
)
|
||||
}
|
||||
|
||||
// Error display
|
||||
AnimatedVisibility(
|
||||
visible = state.error != null,
|
||||
enter = slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = SwooshMotion.snappy()) + fadeIn(SwooshMotion.quick()),
|
||||
|
|
@ -465,66 +524,6 @@ fun ComposerScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Action buttons
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Publish button (step 1)
|
||||
var publishVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { publishVisible = true }
|
||||
AnimatedVisibility(
|
||||
visible = publishVisible,
|
||||
enter = scaleIn(animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
|
||||
) {
|
||||
Button(
|
||||
onClick = viewModel::publish,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
|
||||
) {
|
||||
if (state.isSubmitting) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (state.isEditing) "Update & Publish" else "Publish now")
|
||||
}
|
||||
}
|
||||
|
||||
// Draft + Schedule row (step 2)
|
||||
var rowVisible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(SwooshMotion.StaggerDelayMs)
|
||||
rowVisible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = rowVisible,
|
||||
enter = scaleIn(animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::saveDraft,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSubmitting
|
||||
) {
|
||||
Text("Save draft")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { showDatePicker = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSubmitting
|
||||
) {
|
||||
Icon(Icons.Default.Schedule, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Schedule")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Crossfade
|
||||
|
|
@ -619,21 +618,92 @@ fun ComposerScreen(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Full-screen image preview dialog
|
||||
if (fullScreenImageUri != null) {
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
fullScreenImageUri = null
|
||||
fullScreenImageIndex = -1
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.9f))
|
||||
) {
|
||||
AsyncImage(
|
||||
model = fullScreenImageUri,
|
||||
contentDescription = "Full screen image preview",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
// Close button at top-left
|
||||
IconButton(
|
||||
onClick = {
|
||||
fullScreenImageUri = null
|
||||
fullScreenImageIndex = -1
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(16.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.5f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Close, "Close preview")
|
||||
}
|
||||
|
||||
// Remove button at bottom-center
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
if (fullScreenImageIndex >= 0) {
|
||||
viewModel.removeImage(fullScreenImageIndex)
|
||||
}
|
||||
fullScreenImageUri = null
|
||||
fullScreenImageIndex = -1
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 48.dp),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a 2-column grid of image thumbnails with remove buttons.
|
||||
* Includes an "Add more" button at the end.
|
||||
* Images are 120dp tall and tappable for full-screen preview.
|
||||
*/
|
||||
@Composable
|
||||
fun ImageGridPreview(
|
||||
imageUris: List<Uri>,
|
||||
onRemoveImage: (Int) -> Unit,
|
||||
onAddMore: () -> Unit
|
||||
onAddMore: () -> Unit,
|
||||
onImageClick: ((Int, Uri) -> Unit)? = null
|
||||
) {
|
||||
val itemCount = imageUris.size + 1 // +1 for "add more" button
|
||||
val rows = (itemCount + 1) / 2 // ceiling division for 2 columns
|
||||
val gridHeight = (rows * 140).dp // approx height per row
|
||||
val gridHeight = (rows * 130).dp // 120dp + spacing
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
|
|
@ -647,8 +717,9 @@ fun ImageGridPreview(
|
|||
itemsIndexed(imageUris) { index, uri ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onImageClick?.invoke(index, uri) }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
|
|
@ -675,7 +746,7 @@ fun ImageGridPreview(
|
|||
item {
|
||||
OutlinedCard(
|
||||
onClick = onAddMore,
|
||||
modifier = Modifier.aspectRatio(1f)
|
||||
modifier = Modifier.height(120.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -707,8 +778,27 @@ fun ScheduleDateTimePicker(
|
|||
onConfirm: (LocalDateTime) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val datePickerState = rememberDatePickerState()
|
||||
val todayMillis = remember {
|
||||
java.time.LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}
|
||||
|
||||
val datePickerState = rememberDatePickerState(
|
||||
selectableDates = object : SelectableDates {
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
// Allow today and future dates only
|
||||
return utcTimeMillis >= todayMillis
|
||||
}
|
||||
|
||||
override fun isSelectableYear(year: Int): Boolean {
|
||||
return year >= java.time.LocalDate.now().year
|
||||
}
|
||||
}
|
||||
)
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
var pastTimeError by remember { mutableStateOf(false) }
|
||||
|
||||
if (!showTimePicker) {
|
||||
DatePickerDialog(
|
||||
|
|
@ -732,16 +822,34 @@ fun ScheduleDateTimePicker(
|
|||
onDismissRequest = onDismiss,
|
||||
title = { Text("Select Time") },
|
||||
text = {
|
||||
TimePicker(state = timePickerState)
|
||||
Column {
|
||||
TimePicker(state = timePickerState)
|
||||
if (pastTimeError) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Cannot schedule in the past",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val millis = datePickerState.selectedDateMillis!!
|
||||
val millis = datePickerState.selectedDateMillis
|
||||
if (millis == null) {
|
||||
onDismiss()
|
||||
return@TextButton
|
||||
}
|
||||
val date = java.time.Instant.ofEpochMilli(millis)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
val dateTime = date.atTime(timePickerState.hour, timePickerState.minute)
|
||||
onConfirm(dateTime)
|
||||
if (dateTime.isBefore(LocalDateTime.now())) {
|
||||
pastTimeError = true
|
||||
} else {
|
||||
onConfirm(dateTime)
|
||||
}
|
||||
}) { Text("Schedule") }
|
||||
},
|
||||
dismissButton = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.swoosh.microblog.ui.feed
|
|||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
|
|
@ -93,7 +92,6 @@ import com.swoosh.microblog.ui.components.ConfirmationDialog
|
|||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FeedScreen(
|
||||
onSettingsClick: () -> Unit,
|
||||
onPostClick: (FeedPost) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
onEditPost: (FeedPost) -> Unit,
|
||||
|
|
@ -252,9 +250,6 @@ fun FeedScreen(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.activateSearch() }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search")
|
||||
}
|
||||
SortButton(
|
||||
currentSort = sortOrder,
|
||||
onSortSelected = { viewModel.setSortOrder(it) }
|
||||
|
|
@ -262,9 +257,6 @@ fun FeedScreen(
|
|||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
IconButton(onClick = onSettingsClick) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -538,7 +530,11 @@ fun FeedScreen(
|
|||
|
||||
if (isSearchActive && searchQuery.isNotBlank()) {
|
||||
// Search results: flat list with highlighting, no swipe actions
|
||||
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
||||
items(
|
||||
displayPosts,
|
||||
key = { it.ghostId ?: "local_${it.localId}" },
|
||||
contentType = { "search_post" }
|
||||
) { post ->
|
||||
val itemKey = post.ghostId ?: "local_${post.localId}"
|
||||
StaggeredItem(
|
||||
key = itemKey,
|
||||
|
|
@ -555,19 +551,13 @@ fun FeedScreen(
|
|||
}
|
||||
} else {
|
||||
// Normal feed: pinned section + swipe actions
|
||||
// Pinned posts (no section header — pin icon on post)
|
||||
// Pinned posts (pin icon on each post card is sufficient)
|
||||
if (pinnedPosts.isNotEmpty()) {
|
||||
item(key = "pinned_header") {
|
||||
Column {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { -it / 2 })
|
||||
) {
|
||||
PinnedSectionHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
|
||||
items(
|
||||
pinnedPosts,
|
||||
key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" },
|
||||
contentType = { "pinned_post" }
|
||||
) { post ->
|
||||
val itemKey = post.ghostId ?: "local_${post.localId}"
|
||||
StaggeredItem(
|
||||
key = itemKey,
|
||||
|
|
@ -579,17 +569,6 @@ fun FeedScreen(
|
|||
onClick = { onPostClick(post) },
|
||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||
onShare = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val shareText = ShareUtils.formatShareContent(post, postUrl)
|
||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
|
||||
}
|
||||
},
|
||||
onCopyLink = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
|
@ -607,7 +586,11 @@ fun FeedScreen(
|
|||
// No extra separator — thick dividers built into each post
|
||||
}
|
||||
|
||||
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
||||
items(
|
||||
regularPosts,
|
||||
key = { it.ghostId ?: "local_${it.localId}" },
|
||||
contentType = { "regular_post" }
|
||||
) { post ->
|
||||
val itemKey = post.ghostId ?: "local_${post.localId}"
|
||||
StaggeredItem(
|
||||
key = itemKey,
|
||||
|
|
@ -619,17 +602,6 @@ fun FeedScreen(
|
|||
onClick = { onPostClick(post) },
|
||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||
onShare = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val shareText = ShareUtils.formatShareContent(post, postUrl)
|
||||
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
|
||||
}
|
||||
},
|
||||
onCopyLink = {
|
||||
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
|
||||
if (postUrl != null) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
|
|
@ -660,7 +632,7 @@ fun FeedScreen(
|
|||
|
||||
LaunchedEffect(state.posts) {
|
||||
if (state.posts.isNotEmpty() && !initialLoadComplete) {
|
||||
delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 300)
|
||||
delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 200)
|
||||
initialLoadComplete = true
|
||||
animatedKeys.clear() // Free memory — no longer needed
|
||||
}
|
||||
|
|
@ -843,6 +815,7 @@ fun FilterChipsBar(
|
|||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surface,
|
||||
animationSpec = SwooshMotion.quick(),
|
||||
label = "chipColor"
|
||||
)
|
||||
FilterChip(
|
||||
|
|
@ -942,7 +915,6 @@ fun SwipeablePostCard(
|
|||
onClick: () -> Unit,
|
||||
onCancelQueue: () -> Unit,
|
||||
onShare: () -> Unit = {},
|
||||
onCopyLink: () -> Unit = {},
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePin: () -> Unit = {},
|
||||
|
|
@ -996,7 +968,6 @@ fun SwipeablePostCard(
|
|||
onClick = onClick,
|
||||
onCancelQueue = onCancelQueue,
|
||||
onShare = onShare,
|
||||
onCopyLink = onCopyLink,
|
||||
onEdit = onEdit,
|
||||
onDelete = onDelete,
|
||||
onTogglePin = onTogglePin,
|
||||
|
|
@ -1018,6 +989,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
|
|||
SwipeToDismissBoxValue.EndToStart -> Color(0xFFC62828) // Bold red
|
||||
SwipeToDismissBoxValue.Settled -> Color.Transparent
|
||||
},
|
||||
animationSpec = SwooshMotion.quick(),
|
||||
label = "swipe_bg_color"
|
||||
)
|
||||
|
||||
|
|
@ -1389,7 +1361,7 @@ private fun PulsingAssistChip(
|
|||
initialValue = 0.6f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600),
|
||||
animation = tween(500),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "uploadPulse"
|
||||
|
|
@ -1415,7 +1387,6 @@ fun PostCardContent(
|
|||
onClick: () -> Unit,
|
||||
onCancelQueue: () -> Unit,
|
||||
onShare: () -> Unit = {},
|
||||
onCopyLink: () -> Unit = {},
|
||||
onEdit: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onTogglePin: () -> Unit = {},
|
||||
|
|
@ -1468,20 +1439,12 @@ fun PostCardContent(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (post.featured) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = "Pinned",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "Pinned",
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = "Pinned",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
|
|
@ -1717,36 +1680,18 @@ fun PostCardContent(
|
|||
)
|
||||
}
|
||||
|
||||
// Copy link action (only for published posts with URL)
|
||||
// Share action (copies link to clipboard)
|
||||
if (isPublished && hasShareableUrl) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable {
|
||||
onCopyLink()
|
||||
onShare()
|
||||
snackbarHostState?.let { host ->
|
||||
coroutineScope.launch {
|
||||
host.showSnackbar("Link copied")
|
||||
host.showSnackbar("Link copied to clipboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy link",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Copy",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Share action
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.clickable(onClick = onShare)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Share,
|
||||
|
|
|
|||
|
|
@ -122,10 +122,10 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val filter = _activeFilter.value
|
||||
val sort = _sortOrder.value
|
||||
repository.getLocalPosts(filter, sort).collect { localPosts ->
|
||||
val queuedPosts = localPosts
|
||||
.filter { it.queueStatus != QueueStatus.NONE }
|
||||
val localFeedPosts = localPosts
|
||||
.filter { it.queueStatus != QueueStatus.NONE || it.status == PostStatus.DRAFT }
|
||||
.map { it.toFeedPost() }
|
||||
mergePosts(queuedPosts)
|
||||
mergePosts(localFeedPosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
package com.swoosh.microblog.ui.navigation
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.swoosh.microblog.data.model.FeedPost
|
||||
import com.swoosh.microblog.ui.composer.ComposerScreen
|
||||
import com.swoosh.microblog.ui.composer.ComposerViewModel
|
||||
|
|
@ -16,17 +36,11 @@ import com.swoosh.microblog.ui.settings.SettingsScreen
|
|||
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||
import com.swoosh.microblog.ui.stats.StatsScreen
|
||||
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
|
||||
object Routes {
|
||||
const val SETUP = "setup"
|
||||
const val FEED = "feed"
|
||||
const val SEARCH = "search"
|
||||
const val COMPOSER = "composer"
|
||||
const val DETAIL = "detail"
|
||||
const val SETTINGS = "settings"
|
||||
|
|
@ -35,6 +49,21 @@ object Routes {
|
|||
const val ADD_ACCOUNT = "add_account"
|
||||
}
|
||||
|
||||
data class BottomNavItem(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
val bottomNavItems = listOf(
|
||||
BottomNavItem(Routes.FEED, "Home", Icons.Default.Home),
|
||||
BottomNavItem(Routes.SEARCH, "Search", Icons.Default.Search),
|
||||
BottomNavItem(Routes.SETTINGS, "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
/** Routes where the bottom navigation bar should be visible */
|
||||
private val bottomBarRoutes = setOf(Routes.FEED, Routes.SEARCH, Routes.SETTINGS)
|
||||
|
||||
@Composable
|
||||
fun SwooshNavGraph(
|
||||
navController: NavHostController,
|
||||
|
|
@ -48,172 +77,278 @@ fun SwooshNavGraph(
|
|||
|
||||
val feedViewModel: FeedViewModel = viewModel()
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable(
|
||||
Routes.SETUP,
|
||||
enterTransition = { fadeIn(tween(500)) },
|
||||
exitTransition = { fadeOut(tween(500)) }
|
||||
) {
|
||||
SetupScreen(
|
||||
onSetupComplete = {
|
||||
feedViewModel.refresh()
|
||||
navController.navigate(Routes.FEED) {
|
||||
popUpTo(Routes.SETUP) { inclusive = true }
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val showBottomBar = currentRoute in bottomBarRoutes
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
AnimatedVisibility(
|
||||
visible = showBottomBar,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(150)),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(100))
|
||||
) {
|
||||
NavigationBar {
|
||||
bottomNavItems.forEach { item ->
|
||||
val selected = currentRoute == item.route
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (item.route == Routes.SEARCH) {
|
||||
// Navigate to feed with search mode active
|
||||
feedViewModel.activateSearch()
|
||||
navController.navigate(Routes.SEARCH) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
} else {
|
||||
if (item.route == Routes.FEED) {
|
||||
feedViewModel.deactivateSearch()
|
||||
}
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
)
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.FEED,
|
||||
enterTransition = { fadeIn(tween(300)) },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { fadeOut(tween(200)) }
|
||||
) { scaffoldPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(scaffoldPadding)
|
||||
) {
|
||||
FeedScreen(
|
||||
viewModel = feedViewModel,
|
||||
onSettingsClick = { navController.navigate(Routes.SETTINGS) },
|
||||
onPostClick = { post ->
|
||||
selectedPost = post
|
||||
navController.navigate(Routes.DETAIL)
|
||||
},
|
||||
onCompose = {
|
||||
editPost = null
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onEditPost = { post ->
|
||||
editPost = post
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onAddAccount = {
|
||||
navController.navigate(Routes.ADD_ACCOUNT)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
Routes.SETUP,
|
||||
enterTransition = { fadeIn(tween(300)) },
|
||||
exitTransition = { fadeOut(tween(200)) }
|
||||
) {
|
||||
SetupScreen(
|
||||
onSetupComplete = {
|
||||
feedViewModel.refresh()
|
||||
navController.navigate(Routes.FEED) {
|
||||
popUpTo(Routes.SETUP) { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.COMPOSER,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
|
||||
) {
|
||||
val composerViewModel: ComposerViewModel = viewModel()
|
||||
ComposerScreen(
|
||||
editPost = editPost,
|
||||
viewModel = composerViewModel,
|
||||
onDismiss = {
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onFullScreenPreview = { html ->
|
||||
previewHtml = html
|
||||
navController.navigate(Routes.PREVIEW)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.DETAIL,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() }
|
||||
) {
|
||||
val post = selectedPost
|
||||
if (post != null) {
|
||||
DetailScreen(
|
||||
post = post,
|
||||
onBack = { navController.popBackStack() },
|
||||
onEdit = { p ->
|
||||
editPost = p
|
||||
composable(
|
||||
Routes.FEED,
|
||||
enterTransition = { fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { fadeOut(tween(150)) }
|
||||
) {
|
||||
FeedScreen(
|
||||
viewModel = feedViewModel,
|
||||
onPostClick = { post ->
|
||||
selectedPost = post
|
||||
navController.navigate(Routes.DETAIL)
|
||||
},
|
||||
onCompose = {
|
||||
editPost = null
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onDelete = { p ->
|
||||
feedViewModel.deletePost(p)
|
||||
onEditPost = { post ->
|
||||
editPost = post
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onAddAccount = {
|
||||
navController.navigate(Routes.ADD_ACCOUNT)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.SEARCH,
|
||||
enterTransition = { fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { fadeOut(tween(150)) }
|
||||
) {
|
||||
// Ensure search is active when this tab is shown
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.activateSearch()
|
||||
}
|
||||
FeedScreen(
|
||||
viewModel = feedViewModel,
|
||||
onPostClick = { post ->
|
||||
selectedPost = post
|
||||
navController.navigate(Routes.DETAIL)
|
||||
},
|
||||
onCompose = {
|
||||
editPost = null
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onEditPost = { post ->
|
||||
editPost = post
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onAddAccount = {
|
||||
navController.navigate(Routes.ADD_ACCOUNT)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.COMPOSER,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
val composerViewModel: ComposerViewModel = viewModel()
|
||||
ComposerScreen(
|
||||
editPost = editPost,
|
||||
viewModel = composerViewModel,
|
||||
onDismiss = {
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPreview = { html ->
|
||||
onFullScreenPreview = { html ->
|
||||
previewHtml = html
|
||||
navController.navigate(Routes.PREVIEW)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.DETAIL,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
val post = selectedPost
|
||||
if (post != null) {
|
||||
DetailScreen(
|
||||
post = post,
|
||||
onBack = { navController.popBackStack() },
|
||||
onEdit = { p ->
|
||||
editPost = p
|
||||
navController.navigate(Routes.COMPOSER)
|
||||
},
|
||||
onDelete = { p ->
|
||||
feedViewModel.deletePost(p)
|
||||
navController.popBackStack()
|
||||
},
|
||||
onPreview = { html ->
|
||||
previewHtml = html
|
||||
navController.navigate(Routes.PREVIEW)
|
||||
},
|
||||
onTogglePin = { p ->
|
||||
feedViewModel.toggleFeatured(p)
|
||||
// Update selected post so UI reflects the change
|
||||
selectedPost = p.copy(featured = !p.featured)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.SETTINGS,
|
||||
enterTransition = { fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { fadeOut(tween(150)) }
|
||||
) {
|
||||
SettingsScreen(
|
||||
themeViewModel = themeViewModel,
|
||||
onBack = {
|
||||
feedViewModel.refreshAccountsList()
|
||||
feedViewModel.refresh()
|
||||
navController.navigate(Routes.FEED) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onTogglePin = { p ->
|
||||
feedViewModel.toggleFeatured(p)
|
||||
// Update selected post so UI reflects the change
|
||||
selectedPost = p.copy(featured = !p.featured)
|
||||
onLogout = {
|
||||
navController.navigate(Routes.SETUP) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onStatsClick = {
|
||||
navController.navigate(Routes.STATS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.STATS,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) }
|
||||
) {
|
||||
StatsScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.PREVIEW,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
PreviewScreen(
|
||||
html = previewHtml,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.ADD_ACCOUNT,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
exitTransition = { fadeOut(tween(150)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
|
||||
) {
|
||||
SetupScreen(
|
||||
onSetupComplete = {
|
||||
feedViewModel.refreshAccountsList()
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.SETTINGS,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
SettingsScreen(
|
||||
themeViewModel = themeViewModel,
|
||||
onBack = {
|
||||
feedViewModel.refreshAccountsList()
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onLogout = {
|
||||
navController.navigate(Routes.SETUP) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onStatsClick = {
|
||||
navController.navigate(Routes.STATS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.STATS,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
StatsScreen(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.PREVIEW,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
|
||||
) {
|
||||
PreviewScreen(
|
||||
html = previewHtml,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
Routes.ADD_ACCOUNT,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(300)) },
|
||||
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
|
||||
) {
|
||||
SetupScreen(
|
||||
onSetupComplete = {
|
||||
feedViewModel.refreshAccountsList()
|
||||
feedViewModel.refresh()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,25 +48,25 @@ fun StatsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// Animated counters (ST3)
|
||||
// Animated counters (ST3) — snappier 400ms count-up
|
||||
val animatedTotal by animateIntAsState(
|
||||
targetValue = if (!state.isLoading) state.stats.totalPosts else 0,
|
||||
animationSpec = tween(600),
|
||||
animationSpec = tween(400),
|
||||
label = "totalPosts"
|
||||
)
|
||||
val animatedPublished by animateIntAsState(
|
||||
targetValue = if (!state.isLoading) state.stats.publishedCount else 0,
|
||||
animationSpec = tween(600),
|
||||
animationSpec = tween(400),
|
||||
label = "published"
|
||||
)
|
||||
val animatedDrafts by animateIntAsState(
|
||||
targetValue = if (!state.isLoading) state.stats.draftCount else 0,
|
||||
animationSpec = tween(600),
|
||||
animationSpec = tween(400),
|
||||
label = "drafts"
|
||||
)
|
||||
val animatedScheduled by animateIntAsState(
|
||||
targetValue = if (!state.isLoading) state.stats.scheduledCount else 0,
|
||||
animationSpec = tween(600),
|
||||
animationSpec = tween(400),
|
||||
label = "scheduled"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue