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:
Paweł Orzech 2026-03-19 14:43:21 +01:00
parent 2470f9a049
commit c3fb3c7c98
No known key found for this signature in database
12 changed files with 604 additions and 380 deletions

View file

@ -1,12 +1,33 @@
package com.swoosh.microblog package com.swoosh.microblog
import android.app.Application import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.swoosh.microblog.worker.PostUploadWorker import com.swoosh.microblog.worker.PostUploadWorker
class SwooshApp : Application() { class SwooshApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Schedule periodic connectivity check for queued posts // Schedule periodic connectivity check for queued posts
PostUploadWorker.enqueuePeriodicCheck(this) 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()
}
} }

View file

@ -1,5 +1,8 @@
package com.swoosh.microblog.data.model package com.swoosh.microblog.data.model
import androidx.compose.runtime.Immutable
@Immutable
data class GhostAccount( data class GhostAccount(
val id: String, val id: String,
val name: String, val name: String,

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.compose.runtime.Immutable
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
// --- API Response/Request Models --- // --- API Response/Request Models ---
@ -106,6 +107,7 @@ enum class QueueStatus {
// --- UI Display Model --- // --- UI Display Model ---
@Immutable
data class FeedPost( data class FeedPost(
val localId: Long? = null, val localId: Long? = null,
val ghostId: String? = null, val ghostId: String? = null,
@ -131,6 +133,7 @@ data class FeedPost(
val queueStatus: QueueStatus = QueueStatus.NONE val queueStatus: QueueStatus = QueueStatus.NONE
) )
@Immutable
data class LinkPreview( data class LinkPreview(
val url: String, val url: String,
val title: String?, val title: String?,

View file

@ -1,8 +1,11 @@
package com.swoosh.microblog.data.model package com.swoosh.microblog.data.model
import androidx.compose.runtime.Immutable
/** /**
* Aggregate statistics across all posts. * Aggregate statistics across all posts.
*/ */
@Immutable
data class OverallStats( data class OverallStats(
val totalPosts: Int = 0, val totalPosts: Int = 0,
val publishedCount: Int = 0, val publishedCount: Int = 0,

View file

@ -1,8 +1,11 @@
package com.swoosh.microblog.data.model package com.swoosh.microblog.data.model
import androidx.compose.runtime.Immutable
/** /**
* Statistics calculated for a single post. * Statistics calculated for a single post.
*/ */
@Immutable
data class PostStats( data class PostStats(
val wordCount: Int, val wordCount: Int,
val charCount: Int, val charCount: Int,

View file

@ -2,42 +2,45 @@ package com.swoosh.microblog.ui.animation
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
object SwooshMotion { 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( fun <T> bouncy(): FiniteAnimationSpec<T> = spring(
dampingRatio = 0.65f, dampingRatio = 0.7f,
stiffness = 400f 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( fun <T> bouncyQuick(): FiniteAnimationSpec<T> = spring(
dampingRatio = 0.7f, dampingRatio = 0.75f,
stiffness = 1000f 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( 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, 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( fun <T> quick(): FiniteAnimationSpec<T> = tween(
durationMillis = 200, durationMillis = 150,
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
) )
// Stagger delays // Stagger delays — reduced from 50ms to 35ms for snappier cascades.
const val StaggerDelayMs = 50L const val StaggerDelayMs = 35L
const val RevealDelayMs = 80L // Reveal delay — reduced from 80ms to 50ms for faster sequential reveals.
const val RevealDelayMs = 50L
} }

View file

@ -28,7 +28,7 @@ fun PulsingPlaceholder(
initialValue = 0.12f, initialValue = 0.12f,
targetValue = 0.28f, targetValue = 0.28f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(800), animation = tween(650),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
), ),
label = "pulseAlpha" label = "pulseAlpha"

View file

@ -8,6 +8,7 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@ -23,6 +24,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription 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.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -62,6 +67,12 @@ fun ComposerScreen(
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
var showAltTextDialog by remember { mutableStateOf(false) } var showAltTextDialog by remember { mutableStateOf(false) }
var linkInput by remember { mutableStateOf("") } 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 // Load post for editing
LaunchedEffect(editPost) { 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) { LaunchedEffect(state.isSuccess) {
if (state.isSuccess) { if (state.isSuccess) {
viewModel.reset() viewModel.reset()
@ -86,14 +103,7 @@ fun ComposerScreen(
} }
} }
// Single image picker (legacy) val canSubmit = !state.isSubmitting && (state.text.isNotBlank() || state.imageUris.isNotEmpty())
val imagePickerLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
if (uri != null) {
viewModel.addImages(listOf(uri))
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -116,6 +126,76 @@ fun ComposerScreen(
Icon(Icons.Default.Fullscreen, "Full screen preview") 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 { } else {
// Edit mode: original composer UI // Edit mode: composer UI
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -208,13 +288,14 @@ fun ComposerScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// Text field with character counter and hashtag highlighting // Text field — primary focus, takes most of the screen
OutlinedTextField( OutlinedTextField(
value = state.text, value = state.text,
onValueChange = viewModel::updateText, onValueChange = viewModel::updateText,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 150.dp), .heightIn(min = 200.dp)
.focusRequester(focusRequester),
placeholder = { Text("What's on your mind?") }, placeholder = { Text("What's on your mind?") },
visualTransformation = hashtagTransformation, visualTransformation = hashtagTransformation,
supportingText = { supportingText = {
@ -284,7 +365,7 @@ fun ComposerScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Attachment buttons row // Compact attachment buttons row
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@ -296,7 +377,7 @@ fun ComposerScreen(
} }
} }
// Image grid preview (multi-image) // Image grid preview (multi-image) — 120dp thumbnails
AnimatedVisibility( AnimatedVisibility(
visible = state.imageUris.isNotEmpty(), visible = state.imageUris.isNotEmpty(),
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()), enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
@ -307,7 +388,11 @@ fun ComposerScreen(
ImageGridPreview( ImageGridPreview(
imageUris = state.imageUris, imageUris = state.imageUris,
onRemoveImage = viewModel::removeImage, 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 // Alt text for the first/primary image
@ -424,33 +509,7 @@ fun ComposerScreen(
} }
} }
// Feature toggle // Error display
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() }
)
}
AnimatedVisibility( AnimatedVisibility(
visible = state.error != null, visible = state.error != null,
enter = slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = SwooshMotion.snappy()) + fadeIn(SwooshMotion.quick()), 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 } // 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. * Displays a 2-column grid of image thumbnails with remove buttons.
* Includes an "Add more" button at the end. * Includes an "Add more" button at the end.
* Images are 120dp tall and tappable for full-screen preview.
*/ */
@Composable @Composable
fun ImageGridPreview( fun ImageGridPreview(
imageUris: List<Uri>, imageUris: List<Uri>,
onRemoveImage: (Int) -> Unit, onRemoveImage: (Int) -> Unit,
onAddMore: () -> Unit onAddMore: () -> Unit,
onImageClick: ((Int, Uri) -> Unit)? = null
) { ) {
val itemCount = imageUris.size + 1 // +1 for "add more" button val itemCount = imageUris.size + 1 // +1 for "add more" button
val rows = (itemCount + 1) / 2 // ceiling division for 2 columns 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( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
@ -647,8 +717,9 @@ fun ImageGridPreview(
itemsIndexed(imageUris) { index, uri -> itemsIndexed(imageUris) { index, uri ->
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .height(120.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable { onImageClick?.invoke(index, uri) }
) { ) {
AsyncImage( AsyncImage(
model = uri, model = uri,
@ -675,7 +746,7 @@ fun ImageGridPreview(
item { item {
OutlinedCard( OutlinedCard(
onClick = onAddMore, onClick = onAddMore,
modifier = Modifier.aspectRatio(1f) modifier = Modifier.height(120.dp)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -707,8 +778,27 @@ fun ScheduleDateTimePicker(
onConfirm: (LocalDateTime) -> Unit, onConfirm: (LocalDateTime) -> Unit,
onDismiss: () -> 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 showTimePicker by remember { mutableStateOf(false) }
var pastTimeError by remember { mutableStateOf(false) }
if (!showTimePicker) { if (!showTimePicker) {
DatePickerDialog( DatePickerDialog(
@ -732,16 +822,34 @@ fun ScheduleDateTimePicker(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Select Time") }, title = { Text("Select Time") },
text = { text = {
Column {
TimePicker(state = timePickerState) 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
val millis = datePickerState.selectedDateMillis!! val millis = datePickerState.selectedDateMillis
if (millis == null) {
onDismiss()
return@TextButton
}
val date = java.time.Instant.ofEpochMilli(millis) val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
.toLocalDate() .toLocalDate()
val dateTime = date.atTime(timePickerState.hour, timePickerState.minute) val dateTime = date.atTime(timePickerState.hour, timePickerState.minute)
if (dateTime.isBefore(LocalDateTime.now())) {
pastTimeError = true
} else {
onConfirm(dateTime) onConfirm(dateTime)
}
}) { Text("Schedule") } }) { Text("Schedule") }
}, },
dismissButton = { dismissButton = {

View file

@ -3,7 +3,6 @@ package com.swoosh.microblog.ui.feed
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@ -93,7 +92,6 @@ import com.swoosh.microblog.ui.components.ConfirmationDialog
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
fun FeedScreen( fun FeedScreen(
onSettingsClick: () -> Unit,
onPostClick: (FeedPost) -> Unit, onPostClick: (FeedPost) -> Unit,
onCompose: () -> Unit, onCompose: () -> Unit,
onEditPost: (FeedPost) -> Unit, onEditPost: (FeedPost) -> Unit,
@ -252,9 +250,6 @@ fun FeedScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { viewModel.activateSearch() }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
SortButton( SortButton(
currentSort = sortOrder, currentSort = sortOrder,
onSortSelected = { viewModel.setSortOrder(it) } onSortSelected = { viewModel.setSortOrder(it) }
@ -262,9 +257,6 @@ fun FeedScreen(
IconButton(onClick = { viewModel.refresh() }) { IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "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()) { if (isSearchActive && searchQuery.isNotBlank()) {
// Search results: flat list with highlighting, no swipe actions // 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}" val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem( StaggeredItem(
key = itemKey, key = itemKey,
@ -555,19 +551,13 @@ fun FeedScreen(
} }
} else { } else {
// Normal feed: pinned section + swipe actions // 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()) { if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") { items(
Column { pinnedPosts,
AnimatedVisibility( key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" },
visible = true, contentType = { "pinned_post" }
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { -it / 2 }) ) { post ->
) {
PinnedSectionHeader()
}
}
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}" val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem( StaggeredItem(
key = itemKey, key = itemKey,
@ -579,17 +569,6 @@ fun FeedScreen(
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }, onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = { 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) val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) { if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@ -607,7 +586,11 @@ fun FeedScreen(
// No extra separator — thick dividers built into each post // 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}" val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem( StaggeredItem(
key = itemKey, key = itemKey,
@ -619,17 +602,6 @@ fun FeedScreen(
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) }, onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = { 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) val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) { if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@ -660,7 +632,7 @@ fun FeedScreen(
LaunchedEffect(state.posts) { LaunchedEffect(state.posts) {
if (state.posts.isNotEmpty() && !initialLoadComplete) { 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 initialLoadComplete = true
animatedKeys.clear() // Free memory — no longer needed animatedKeys.clear() // Free memory — no longer needed
} }
@ -843,6 +815,7 @@ fun FilterChipsBar(
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
else else
MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.surface,
animationSpec = SwooshMotion.quick(),
label = "chipColor" label = "chipColor"
) )
FilterChip( FilterChip(
@ -942,7 +915,6 @@ fun SwipeablePostCard(
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit, onCancelQueue: () -> Unit,
onShare: () -> Unit = {}, onShare: () -> Unit = {},
onCopyLink: () -> Unit = {},
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onTogglePin: () -> Unit = {}, onTogglePin: () -> Unit = {},
@ -996,7 +968,6 @@ fun SwipeablePostCard(
onClick = onClick, onClick = onClick,
onCancelQueue = onCancelQueue, onCancelQueue = onCancelQueue,
onShare = onShare, onShare = onShare,
onCopyLink = onCopyLink,
onEdit = onEdit, onEdit = onEdit,
onDelete = onDelete, onDelete = onDelete,
onTogglePin = onTogglePin, onTogglePin = onTogglePin,
@ -1018,6 +989,7 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
SwipeToDismissBoxValue.EndToStart -> Color(0xFFC62828) // Bold red SwipeToDismissBoxValue.EndToStart -> Color(0xFFC62828) // Bold red
SwipeToDismissBoxValue.Settled -> Color.Transparent SwipeToDismissBoxValue.Settled -> Color.Transparent
}, },
animationSpec = SwooshMotion.quick(),
label = "swipe_bg_color" label = "swipe_bg_color"
) )
@ -1389,7 +1361,7 @@ private fun PulsingAssistChip(
initialValue = 0.6f, initialValue = 0.6f,
targetValue = 1f, targetValue = 1f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(600), animation = tween(500),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
), ),
label = "uploadPulse" label = "uploadPulse"
@ -1415,7 +1387,6 @@ fun PostCardContent(
onClick: () -> Unit, onClick: () -> Unit,
onCancelQueue: () -> Unit, onCancelQueue: () -> Unit,
onShare: () -> Unit = {}, onShare: () -> Unit = {},
onCopyLink: () -> Unit = {},
onEdit: () -> Unit = {}, onEdit: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onTogglePin: () -> Unit = {}, onTogglePin: () -> Unit = {},
@ -1468,20 +1439,12 @@ fun PostCardContent(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (post.featured) { if (post.featured) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.Filled.PushPin, imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned", contentDescription = "Pinned",
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary 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
)
}
} else { } else {
Spacer(modifier = Modifier.width(1.dp)) 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) { if (isPublished && hasShareableUrl) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { modifier = Modifier.clickable {
onCopyLink() onShare()
snackbarHostState?.let { host -> snackbarHostState?.let { host ->
coroutineScope.launch { 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( Icon(
Icons.Default.Share, Icons.Default.Share,

View file

@ -122,10 +122,10 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val filter = _activeFilter.value val filter = _activeFilter.value
val sort = _sortOrder.value val sort = _sortOrder.value
repository.getLocalPosts(filter, sort).collect { localPosts -> repository.getLocalPosts(filter, sort).collect { localPosts ->
val queuedPosts = localPosts val localFeedPosts = localPosts
.filter { it.queueStatus != QueueStatus.NONE } .filter { it.queueStatus != QueueStatus.NONE || it.status == PostStatus.DRAFT }
.map { it.toFeedPost() } .map { it.toFeedPost() }
mergePosts(queuedPosts) mergePosts(localFeedPosts)
} }
} }
} }

View file

@ -1,10 +1,30 @@
package com.swoosh.microblog.ui.navigation 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.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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.ui.composer.ComposerScreen import com.swoosh.microblog.ui.composer.ComposerScreen
import com.swoosh.microblog.ui.composer.ComposerViewModel 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.setup.SetupScreen
import com.swoosh.microblog.ui.stats.StatsScreen import com.swoosh.microblog.ui.stats.StatsScreen
import com.swoosh.microblog.ui.theme.ThemeViewModel 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 { object Routes {
const val SETUP = "setup" const val SETUP = "setup"
const val FEED = "feed" const val FEED = "feed"
const val SEARCH = "search"
const val COMPOSER = "composer" const val COMPOSER = "composer"
const val DETAIL = "detail" const val DETAIL = "detail"
const val SETTINGS = "settings" const val SETTINGS = "settings"
@ -35,6 +49,21 @@ object Routes {
const val ADD_ACCOUNT = "add_account" 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 @Composable
fun SwooshNavGraph( fun SwooshNavGraph(
navController: NavHostController, navController: NavHostController,
@ -48,11 +77,81 @@ fun SwooshNavGraph(
val feedViewModel: FeedViewModel = viewModel() val feedViewModel: FeedViewModel = viewModel()
NavHost(navController = navController, startDestination = startDestination) { 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
)
)
}
}
}
}
) { scaffoldPadding ->
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.padding(scaffoldPadding)
) {
composable( composable(
Routes.SETUP, Routes.SETUP,
enterTransition = { fadeIn(tween(500)) }, enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(500)) } exitTransition = { fadeOut(tween(200)) }
) { ) {
SetupScreen( SetupScreen(
onSetupComplete = { onSetupComplete = {
@ -66,14 +165,44 @@ fun SwooshNavGraph(
composable( composable(
Routes.FEED, Routes.FEED,
enterTransition = { fadeIn(tween(300)) }, enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { fadeOut(tween(200)) } popExitTransition = { fadeOut(tween(150)) }
) { ) {
FeedScreen( FeedScreen(
viewModel = feedViewModel, 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.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 -> onPostClick = { post ->
selectedPost = post selectedPost = post
navController.navigate(Routes.DETAIL) navController.navigate(Routes.DETAIL)
@ -94,10 +223,10 @@ fun SwooshNavGraph(
composable( composable(
Routes.COMPOSER, Routes.COMPOSER,
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() } popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) { ) {
val composerViewModel: ComposerViewModel = viewModel() val composerViewModel: ComposerViewModel = viewModel()
ComposerScreen( ComposerScreen(
@ -116,10 +245,10 @@ fun SwooshNavGraph(
composable( composable(
Routes.DETAIL, Routes.DETAIL,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() }, enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() } popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) { ) {
val post = selectedPost val post = selectedPost
if (post != null) { if (post != null) {
@ -149,17 +278,22 @@ fun SwooshNavGraph(
composable( composable(
Routes.SETTINGS, Routes.SETTINGS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, enterTransition = { fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) } popExitTransition = { fadeOut(tween(150)) }
) { ) {
SettingsScreen( SettingsScreen(
themeViewModel = themeViewModel, themeViewModel = themeViewModel,
onBack = { onBack = {
feedViewModel.refreshAccountsList() feedViewModel.refreshAccountsList()
feedViewModel.refresh() feedViewModel.refresh()
navController.popBackStack() navController.navigate(Routes.FEED) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
}
}, },
onLogout = { onLogout = {
navController.navigate(Routes.SETUP) { navController.navigate(Routes.SETUP) {
@ -174,10 +308,10 @@ fun SwooshNavGraph(
composable( composable(
Routes.STATS, Routes.STATS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) } popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) }
) { ) {
StatsScreen( StatsScreen(
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
@ -186,10 +320,10 @@ fun SwooshNavGraph(
composable( composable(
Routes.PREVIEW, Routes.PREVIEW,
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() } popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) { ) {
PreviewScreen( PreviewScreen(
html = previewHtml, html = previewHtml,
@ -199,10 +333,10 @@ fun SwooshNavGraph(
composable( composable(
Routes.ADD_ACCOUNT, Routes.ADD_ACCOUNT,
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(200)) }, exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(300)) }, popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() } popExitTransition = { slideOutVertically(targetOffsetY = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) { ) {
SetupScreen( SetupScreen(
onSetupComplete = { onSetupComplete = {
@ -217,3 +351,4 @@ fun SwooshNavGraph(
} }
} }
} }
}

View file

@ -48,25 +48,25 @@ fun StatsScreen(
} }
} }
// Animated counters (ST3) // Animated counters (ST3) — snappier 400ms count-up
val animatedTotal by animateIntAsState( val animatedTotal by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.totalPosts else 0, targetValue = if (!state.isLoading) state.stats.totalPosts else 0,
animationSpec = tween(600), animationSpec = tween(400),
label = "totalPosts" label = "totalPosts"
) )
val animatedPublished by animateIntAsState( val animatedPublished by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.publishedCount else 0, targetValue = if (!state.isLoading) state.stats.publishedCount else 0,
animationSpec = tween(600), animationSpec = tween(400),
label = "published" label = "published"
) )
val animatedDrafts by animateIntAsState( val animatedDrafts by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.draftCount else 0, targetValue = if (!state.isLoading) state.stats.draftCount else 0,
animationSpec = tween(600), animationSpec = tween(400),
label = "drafts" label = "drafts"
) )
val animatedScheduled by animateIntAsState( val animatedScheduled by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.scheduledCount else 0, targetValue = if (!state.isLoading) state.stats.scheduledCount else 0,
animationSpec = tween(600), animationSpec = tween(400),
label = "scheduled" label = "scheduled"
) )