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
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()
}
}

View file

@ -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,

View file

@ -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?,

View file

@ -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,

View file

@ -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,

View file

@ -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
}

View file

@ -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"

View file

@ -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 = {

View file

@ -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,

View file

@ -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)
}
}
}

View file

@ -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()
}
)
}
}
}

View file

@ -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"
)