mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-04-01 04:15:42 +00:00
feat: composer redesign, bottom tab bar, bug fixes, animation optimization
- Composer: auto-focus with keyboard, send button in top-right with dropdown (Publish/Draft/Schedule), smaller 120dp image thumbnails with fullscreen preview - Navigation: bottom tab bar (Home/Search/Settings), hidden on detail screens - Share now copies link to clipboard instead of opening share sheet - Fix: pinned label no longer shows twice - Fix: drafts now appear in feed - Fix: schedule picker blocks past dates, no more NPE crash - Animations: snappier springs (1500f stiffness), shorter tweens (150-200ms), @Immutable on data classes, Coil crossfade 150ms with cache config, LazyColumn contentType for better reuse
This commit is contained in:
parent
2470f9a049
commit
c3fb3c7c98
12 changed files with 604 additions and 380 deletions
|
|
@ -1,12 +1,33 @@
|
||||||
package com.swoosh.microblog
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue