diff --git a/app/src/main/java/com/swoosh/microblog/SwooshApp.kt b/app/src/main/java/com/swoosh/microblog/SwooshApp.kt index 49d39bb..8a45cc5 100644 --- a/app/src/main/java/com/swoosh/microblog/SwooshApp.kt +++ b/app/src/main/java/com/swoosh/microblog/SwooshApp.kt @@ -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() + } } diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt index 9b76069..11c8e12 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostAccount.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt index ba60c01..4771ded 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt @@ -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?, diff --git a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt index 570603d..f5c11da 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt index 3d8d95a..4f4f606 100644 --- a/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt +++ b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt b/app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt index ae050fc..6de34d3 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt @@ -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 bouncy(): FiniteAnimationSpec = 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 bouncyQuick(): FiniteAnimationSpec = 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 snappy(): FiniteAnimationSpec = spring( - dampingRatio = 0.7f, - stiffness = 800f - ) - - // Soft entrance — cards, content reveal. - fun gentle(): FiniteAnimationSpec = 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 gentle(): FiniteAnimationSpec = spring( + dampingRatio = 0.85f, + stiffness = Spring.StiffnessMediumLow // 400f → smooth, fast settle + ) + + // Quick tween — fade, color transitions. Reduced from 200ms to 150ms. fun quick(): FiniteAnimationSpec = 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 } diff --git a/app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt b/app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt index dd01f9f..4e3d13b 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt @@ -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" diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 5b409cc..3440718 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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(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, 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 = { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index ae2ef3d..a210cb2 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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, diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 37be5c6..f8c5fce 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index deb6642..8e3436d 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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() - } - ) - } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt index bfd0ebb..4ea2a15 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt @@ -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" )