40 KiB
Micro-Animations Implementation Plan (v2)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add 32 micro-animations + 8 navigation transitions to make Swoosh feel alive with expressive, bouncy character.
Architecture: Centralized SwooshMotion object provides shared spring/tween specs with reduced-motion support. Reusable AnimatedDialog and PulsingPlaceholder components. Each screen gets targeted animation modifications. Navigation transitions configured per-route.
Tech Stack: Jetpack Compose Animation APIs, Spring physics, Navigation Compose transitions.
Spec: docs/superpowers/specs/2026-03-19-micro-animations-design.md
File Structure
New Files (3)
| File | Responsibility |
|---|---|
app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt |
Shared animation specs + reduced motion |
app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt |
Reusable scale-in dialog wrapper |
app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt |
Pulsing alpha loading placeholder |
Modified Files (6)
| File | Lines | Animations |
|---|---|---|
ui/feed/FeedScreen.kt |
~2015 | F1-F12 |
ui/composer/ComposerScreen.kt |
~705 | C1-C9 |
ui/detail/DetailScreen.kt |
~547 | D1-D5 |
ui/settings/SettingsScreen.kt |
~216 | S1-S3 |
ui/stats/StatsScreen.kt |
~196 | ST1-ST3 |
ui/navigation/NavGraph.kt |
~168 | 8 route transitions |
Task 1: SwooshMotion — Shared Animation Specs
Files:
-
Create:
app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt -
Step 1: Create SwooshMotion object
package com.swoosh.microblog.ui.animation
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
object SwooshMotion {
// Expressive bounce — FAB entrance, chips, badges. One visible overshoot.
fun <T> bouncy(): FiniteAnimationSpec<T> = spring(
dampingRatio = 0.65f,
stiffness = 400f
)
// Fast snap-back — press feedback, button taps. Settles in ~150ms.
fun <T> bouncyQuick(): FiniteAnimationSpec<T> = spring(
dampingRatio = 0.7f,
stiffness = 1000f
)
// Controlled spring — expand/collapse, dialogs.
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
)
// Quick tween — fade, color transitions.
fun <T> quick(): FiniteAnimationSpec<T> = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
// Stagger delays
const val StaggerDelayMs = 50L
const val RevealDelayMs = 80L
}
- Step 2: Verify it compiles
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
- Step 3: Commit
git add app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt
git commit -m "feat: add SwooshMotion shared animation specs"
Task 2: AnimatedDialog Component
Files:
-
Create:
app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt -
Step 1: Create AnimatedDialog composable
package com.swoosh.microblog.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.swoosh.microblog.ui.animation.SwooshMotion
@Composable
fun AnimatedDialog(
onDismissRequest: () -> Unit,
content: @Composable () -> Unit
) {
val transitionState = remember {
MutableTransitionState(false).apply { targetState = true }
}
Dialog(
onDismissRequest = onDismissRequest,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
// Backdrop
AnimatedVisibility(
visibleState = transitionState,
enter = fadeIn(animationSpec = SwooshMotion.quick()),
exit = fadeOut(animationSpec = SwooshMotion.quick())
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onDismissRequest() }
)
}
// Content
AnimatedVisibility(
visibleState = transitionState,
enter = scaleIn(
initialScale = 0.8f,
animationSpec = SwooshMotion.snappy()
) + fadeIn(animationSpec = SwooshMotion.quick()),
exit = scaleOut(
targetScale = 0.8f,
animationSpec = SwooshMotion.quick()
) + fadeOut(animationSpec = SwooshMotion.quick())
) {
content()
}
}
}
}
- Step 2: Verify it compiles
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
Expected: BUILD SUCCESSFUL
- Step 3: Commit
git add app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt
git commit -m "feat: add AnimatedDialog reusable component"
Task 3: PulsingPlaceholder Component
Files:
-
Create:
app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt -
Step 1: Create PulsingPlaceholder composable
package com.swoosh.microblog.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun PulsingPlaceholder(
modifier: Modifier = Modifier,
height: Dp = 80.dp
) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.12f,
targetValue = 0.28f,
animationSpec = infiniteRepeatable(
animation = tween(800),
repeatMode = RepeatMode.Reverse
),
label = "pulseAlpha"
)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = alpha))
)
}
- Step 2: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt
git commit -m "feat: add PulsingPlaceholder loading component"
Task 4: Navigation Transitions
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt -
Step 1: Add animation imports
After existing imports (line 18), add:
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
- Step 2: Add transitions to all 8 routes
For each composable() call, add transition lambdas. The routes and their line numbers:
Setup (line 44): enterTransition = { fadeIn(tween(500)) }, exitTransition = { fadeOut(tween(500)) }
Feed (line 55): enterTransition = { fadeIn(tween(300)) }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { fadeOut(tween(200)) }
Composer (line 79): enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
Detail (line 95): enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() }
Settings (line 122): enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
Stats (line 141): Same as Settings (slide from right).
Preview (line 147): Same as Composer (slide from bottom).
AddAccount (line 154): Same as Composer (slide from bottom).
- Step 3: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
git commit -m "feat: add navigation transitions for all 8 routes"
Task 5: Feed — FAB Animations (F1, F2)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt -
Step 1: Add SwooshMotion import
Add at the top of FeedScreen.kt:
import com.swoosh.microblog.ui.animation.SwooshMotion
import androidx.compose.foundation.gestures.detectTapGestures
Note: graphicsLayer, pointerInput, and animation imports already exist in this file.
- Step 2: Add FAB state variables
Inside FeedScreen, before the Scaffold call (before line 155), add:
// FAB entrance animation
var fabVisible by remember { mutableStateOf(false) }
val fabScale by animateFloatAsState(
targetValue = if (fabVisible) 1f else 0f,
animationSpec = SwooshMotion.bouncy(),
label = "fabEntrance"
)
LaunchedEffect(Unit) { fabVisible = true }
// FAB press animation
var fabPressed by remember { mutableStateOf(false) }
val fabPressScale by animateFloatAsState(
targetValue = if (fabPressed) 0.85f else 1f,
animationSpec = SwooshMotion.bouncyQuick(),
label = "fabPress"
)
Note: animateFloatAsState needs import androidx.compose.animation.core.animateFloatAsState.
- Step 3: Replace FAB composable
Replace lines 248-253 (the floatingActionButton lambda):
floatingActionButton = {
if (!isSearchActive) {
FloatingActionButton(
onClick = onCompose,
modifier = Modifier
.graphicsLayer {
scaleX = fabScale * fabPressScale
scaleY = fabScale * fabPressScale
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
fabPressed = true
tryAwaitRelease()
fabPressed = false
}
)
}
) {
Icon(Icons.Default.Add, contentDescription = "New post")
}
}
},
- Step 4: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add bouncy FAB entrance and press animations"
Task 6: Feed — Staggered Card Entrance (F3)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt -
Step 1: Add stagger tracking state
Near the FAB state vars added in Task 5, add:
// Staggered entrance tracking
val animatedKeys = remember { mutableStateMapOf<String, Boolean>() }
var initialLoadComplete by remember { mutableStateOf(false) }
- Step 2: Create a helper composable for staggered items
Add a private composable at the bottom of the file (before FilterChipsBar):
@Composable
private fun StaggeredItem(
key: String,
index: Int,
animatedKeys: MutableMap<String, Boolean>,
initialLoadComplete: Boolean,
content: @Composable () -> Unit
) {
val shouldAnimate = !initialLoadComplete && key !in animatedKeys
var visible by remember { mutableStateOf(!shouldAnimate) }
LaunchedEffect(key) {
if (shouldAnimate && animatedKeys.size < 8) {
delay(SwooshMotion.StaggerDelayMs * animatedKeys.size)
animatedKeys[key] = true
}
visible = true
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { it / 3 },
animationSpec = SwooshMotion.gentle()
) + fadeIn(animationSpec = SwooshMotion.quick())
) {
content()
}
}
Needs imports: import kotlinx.coroutines.delay, import androidx.compose.animation.slideInVertically.
- Step 3: Wrap LazyColumn items with StaggeredItem
In the LazyColumn, wrap each SwipeablePostCard and search-mode PostCard with StaggeredItem. For example, the search items block (line 452):
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
index = 0, // index not needed since animatedKeys.size tracks position
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
highlightQuery = searchQuery
)
}
}
Same wrapping for pinned posts (line 467) and regular posts (line 508).
- Step 4: Mark initial load complete
After the LazyColumn block, add:
LaunchedEffect(state.posts) {
if (state.posts.isNotEmpty() && !initialLoadComplete) {
delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 300)
initialLoadComplete = true
}
}
- Step 5: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add staggered card entrance animation"
Task 7: Feed — Empty States, Snackbar, Search, Filters (F5, F7, F8, F9, F11, F12)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt -
Step 1: Wrap empty states with AnimatedVisibility (F5)
Each empty state block (connection error at line 365, filter empty at line 398, search no results at line 328, normal empty at line 160) — wrap the inner Column content with:
AnimatedVisibility(
visible = true, // already inside conditional
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
initialScale = 0.9f,
animationSpec = SwooshMotion.quick()
)
) {
// existing Column content
}
- Step 2: Animate search bar toggle (F8)
At line 157, replace the if (isSearchActive) with AnimatedContent or wrap both branches:
AnimatedVisibility(
visible = isSearchActive,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(SwooshMotion.quick()),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(SwooshMotion.quick())
) {
SearchTopBar(...)
}
AnimatedVisibility(
visible = !isSearchActive,
enter = fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
TopAppBar(...)
}
Note: This requires refactoring the topBar lambda to use a Box or Column instead of direct if/else.
- Step 3: Animate filter chips bar toggle (F9)
Wrap FilterChipsBar call (line 263) with AnimatedVisibility:
AnimatedVisibility(
visible = !isSearchActive,
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
) {
FilterChipsBar(
activeFilter = activeFilter,
onFilterSelected = { viewModel.setFilter(it) }
)
}
- Step 4: Animate pinned section header (F11)
Wrap PinnedSectionHeader call (line 465) with AnimatedVisibility:
item(key = "pinned_header") {
AnimatedVisibility(
visible = pinnedPosts.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { -it / 2 })
) {
PinnedSectionHeader()
}
}
- Step 5: Animate account switch overlay (F12)
Wrap the "Switching account..." block (line 288) with crossfade:
AnimatedVisibility(
visible = state.isSwitchingAccount,
enter = fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
// existing Box with CircularProgressIndicator + Text
}
- Step 6: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add empty state, search, filter, and overlay animations"
Task 8: Feed — Show More, Queue Chip, Account Switcher (F4, F6, F10)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt -
Step 1: Animate "Show more" expand (F4)
In PostCardContent composable, find the text display and "Show more" button. Replace the static text swap with AnimatedContent:
AnimatedContent(
targetState = expanded,
transitionSpec = {
(fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()))
.togetherWith(fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy()))
},
label = "expandText"
) { isExpanded ->
Text(
text = if (isExpanded) post.textContent else post.textContent.take(280) + "...",
style = MaterialTheme.typography.bodyMedium,
maxLines = if (isExpanded) Int.MAX_VALUE else 8,
overflow = TextOverflow.Ellipsis
)
}
Note: Field is post.textContent (not post.text).
- Step 2: Animate queue status chip (F6)
In PostCardContent, around the queue status section (inside the if (post.queueStatus != QueueStatus.NONE) block), add pulsing for uploading state:
val isUploading = post.queueStatus == QueueStatus.UPLOADING
val infiniteTransition = rememberInfiniteTransition(label = "queuePulse")
val chipAlpha by infiniteTransition.animateFloat(
initialValue = if (isUploading) 0.6f else 1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600),
repeatMode = RepeatMode.Reverse
),
label = "uploadPulse"
)
Apply Modifier.graphicsLayer { alpha = if (isUploading) chipAlpha else 1f } to the AssistChip.
- Step 3: Animate account switcher items (F10)
In AccountSwitcherBottomSheet (line 954), add stagger to account items:
accounts.forEachIndexed { index, account ->
var itemVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(SwooshMotion.StaggerDelayMs * index)
itemVisible = true
}
AnimatedVisibility(
visible = itemVisible,
enter = slideInHorizontally(
initialOffsetX = { -it / 4 },
animationSpec = SwooshMotion.gentle()
) + fadeIn(SwooshMotion.quick())
) {
AccountListItem(
account = account,
isActive = account.id == activeAccountId,
onClick = { onAccountSelected(account.id) },
onDelete = { onDeleteAccount(account) },
onRename = { onRenameAccount(account) }
)
}
}
- Step 4: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add expand, queue chip, and account switcher animations"
Task 9: Composer — Image, Link, Schedule, Error (C1, C2, C3, C7)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt -
Step 1: Add imports
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.PulsingPlaceholder
- Step 2: Animate image grid (C1)
Wrap ImageGridPreview block (line 277) with AnimatedVisibility:
AnimatedVisibility(
visible = state.imageUris.isNotEmpty(),
enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
Column {
ImageGridPreview(
imageUris = state.imageUris,
onRemoveImage = viewModel::removeImage,
onAddMore = { multiImagePickerLauncher.launch("image/*") }
)
// alt text button and badge remain inside
}
}
- Step 3: Replace LinearProgressIndicator with PulsingPlaceholder (C2)
Replace line 317 (LinearProgressIndicator) with:
AnimatedVisibility(
visible = state.isLoadingLink,
enter = fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
PulsingPlaceholder(height = 80.dp)
}
Wrap the link preview card (line 320) with:
AnimatedVisibility(
visible = state.linkPreview != null && !state.isLoadingLink,
enter = slideInVertically(initialOffsetY = { it / 2 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
// existing OutlinedCard
}
- Step 4: Animate schedule chip (C3)
Wrap schedule chip block (line 363) with:
AnimatedVisibility(
visible = state.scheduledAt != null,
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
AssistChip(...)
}
- Step 5: Animate error text (C7)
Wrap error text (line 407) with:
AnimatedVisibility(
visible = state.error != null,
enter = slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = SwooshMotion.snappy()) + fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
Text(text = state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
- Step 6: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
git commit -m "feat: add image, link, schedule, and error animations in composer"
Task 10: Composer — Publish, Counter, Buttons, Hashtags, Preview (C4, C5, C6, C8, C9)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt -
Step 1: Animate character counter color (C5)
Replace the static color logic in the supportingText lambda (lines 211-215) with animateColorAsState:
supportingText = {
val charCount = state.text.length
val statsText = PostStats.formatComposerStats(state.text)
val targetColor = when {
charCount > 500 -> MaterialTheme.colorScheme.error
charCount > 280 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val animatedColor by animateColorAsState(
targetValue = targetColor,
animationSpec = SwooshMotion.quick(),
label = "counterColor"
)
Text(
text = statsText,
style = MaterialTheme.typography.labelSmall,
color = animatedColor
)
}
Note: Needs import androidx.compose.animation.animateColorAsState.
- Step 2: Animate action buttons stagger (C6)
Wrap action buttons Column (line 420) with staggered entrance:
// Publish button (step 1 of stagger)
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, ...) { ... }
}
// Draft + Schedule row (step 2 of stagger)
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(...) { ... }
}
- Step 3: Animate hashtag chips (C8)
Wrap extracted tags FlowRow (line 225) with AnimatedVisibility:
AnimatedVisibility(
visible = state.extractedTags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Row(...) { /* existing tag chips */ }
}
- Step 4: Add edit/preview crossfade (C9)
Replace the if (state.isPreviewMode) block (line 167) with Crossfade:
Crossfade(
targetState = state.isPreviewMode,
animationSpec = SwooshMotion.quick(),
label = "editPreviewCrossfade"
) { isPreview ->
if (isPreview) {
// preview content (lines 168-189)
} else {
// edit content (lines 191-457)
}
}
- Step 5: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
git commit -m "feat: add counter, buttons, hashtag, and preview animations"
Task 11: Detail Screen — Content Reveal & Delete Dialog (D1-D4)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt -
Step 1: Add imports
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.AnimatedDialog
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInVertically
import kotlinx.coroutines.delay
Note: AnimatedVisibility, fadeIn, fadeOut, expandVertically, shrinkVertically are already imported.
- Step 2: Add sequential reveal states
Inside DetailScreen, after the existing state declarations (around line 62):
val revealCount = 6 // status, text, tags, gallery, link, stats
val sectionVisible = remember { List(revealCount) { mutableStateOf(false) } }
LaunchedEffect(Unit) {
sectionVisible.forEachIndexed { index, state ->
delay(SwooshMotion.RevealDelayMs * index)
state.value = true
}
}
- Step 3: Wrap content sections with AnimatedVisibility
In the Column (starting line 197):
Section 0 — Status + time row (line 199):
AnimatedVisibility(
visible = sectionVisible[0].value,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(initialScale = 0.8f, animationSpec = SwooshMotion.bouncy())
) { Row(...) { StatusBadge(post); Text(...) } }
Section 1 — Text content (line 214):
AnimatedVisibility(
visible = sectionVisible[1].value,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { Text(text = post.textContent, ...) }
Section 2 — Tags (line 220):
AnimatedVisibility(
visible = sectionVisible[2].value && post.tags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { FlowRow(...) { ... } }
Section 3 — Image gallery (line 243):
AnimatedVisibility(
visible = sectionVisible[3].value && allImages.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { Column { DetailImageGallery(...); /* alt text */ } }
Section 4 — Link preview (line 266):
AnimatedVisibility(
visible = sectionVisible[4].value && post.linkUrl != null,
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
) { OutlinedCard(...) { ... } }
Section 5 — PostStatsSection (line 307):
AnimatedVisibility(
visible = sectionVisible[5].value,
enter = slideInVertically(initialOffsetY = { it / 4 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) { PostStatsSection(post) }
- Step 4: Replace delete AlertDialog with AnimatedDialog (D3)
Replace the AlertDialog at line 312 with:
if (showDeleteDialog) {
AnimatedDialog(onDismissRequest = { showDeleteDialog = false }) {
Card(modifier = Modifier.padding(horizontal = 24.dp)) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Delete Post", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
Text("Are you sure you want to delete this post? This action cannot be undone.")
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { showDeleteDialog = false }) { Text("Cancel") }
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { showDeleteDialog = false; onDelete(post) },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
}
}
}
}
}
- Step 5: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
git commit -m "feat: add content reveal and animated delete dialog in detail"
Task 12: Settings Screen (S1, S2, S3)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt -
Step 1: Add imports
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.AnimatedDialog
import kotlinx.coroutines.delay
- Step 2: Animate account card entrance (S1)
Wrap account Card (line 78) with:
var cardVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { cardVisible = true }
AnimatedVisibility(
visible = cardVisible && activeAccount != null,
enter = fadeIn(SwooshMotion.quick()) + scaleIn(initialScale = 0.95f, animationSpec = SwooshMotion.quick())
) {
Card(...) { ... }
}
- Step 3: Add disconnect confirmation dialog (S2)
Add dialog state:
var showDisconnectDialog by remember { mutableStateOf(false) }
var showDisconnectAllDialog by remember { mutableStateOf(false) }
Change "Disconnect Current Account" button (line 139) to onClick = { showDisconnectDialog = true } and "Disconnect All" button (line 165) to onClick = { showDisconnectAllDialog = true }.
Add the dialogs:
if (showDisconnectDialog) {
AnimatedDialog(onDismissRequest = { showDisconnectDialog = false }) {
Card(modifier = Modifier.padding(horizontal = 24.dp)) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Disconnect Account?", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
Text("Remove \"${activeAccount?.name}\"? You'll need to set up again.")
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
showDisconnectDialog = false
activeAccount?.let { account ->
accountManager.removeAccount(account.id)
ApiClient.reset()
if (accountManager.getAccounts().isEmpty()) onLogout() else onBack()
}
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Disconnect") }
}
}
}
}
}
if (showDisconnectAllDialog) {
AnimatedDialog(onDismissRequest = { showDisconnectAllDialog = false }) {
Card(modifier = Modifier.padding(horizontal = 24.dp)) {
Column(modifier = Modifier.padding(24.dp)) {
Text("Disconnect All?", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
Text("Remove all accounts? You'll need to set up from scratch.")
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { showDisconnectAllDialog = false }) { Text("Cancel") }
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
showDisconnectAllDialog = false
accountManager.clearAll()
ApiClient.reset()
onLogout()
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Disconnect All") }
}
}
}
}
}
- Step 4: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
git commit -m "feat: add account card animation and disconnect dialogs"
Task 13: Stats Screen (ST1, ST2, ST3)
Files:
-
Modify:
app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt -
Step 1: Add imports
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.swoosh.microblog.ui.animation.SwooshMotion
import kotlinx.coroutines.delay
- Step 2: Add stagger and count-up states
Inside StatsScreen, after the state collection (line 27):
// Staggered entrance
val cardVisible = remember { List(4) { mutableStateOf(false) } }
var writingStatsVisible by remember { mutableStateOf(false) }
LaunchedEffect(state.isLoading) {
if (!state.isLoading) {
cardVisible.forEachIndexed { index, vis ->
delay(SwooshMotion.StaggerDelayMs * index)
vis.value = true
}
delay(SwooshMotion.StaggerDelayMs * 4)
writingStatsVisible = true
}
}
// Animated counters (ST3)
val animatedTotal by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.totalPosts else 0,
animationSpec = tween(600),
label = "totalPosts"
)
val animatedPublished by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.publishedCount else 0,
animationSpec = tween(600),
label = "published"
)
val animatedDrafts by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.draftCount else 0,
animationSpec = tween(600),
label = "drafts"
)
val animatedScheduled by animateIntAsState(
targetValue = if (!state.isLoading) state.stats.scheduledCount else 0,
animationSpec = tween(600),
label = "scheduled"
)
- Step 3: Wrap stats cards with AnimatedVisibility (ST1)
Wrap each StatsCard in the two Rows (lines 68-98):
Row(...) {
AnimatedVisibility(
visible = cardVisible[0].value,
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(modifier = Modifier.weight(1f), value = "$animatedTotal", label = "Total Posts", icon = ...)
}
AnimatedVisibility(
visible = cardVisible[1].value,
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick())
) {
StatsCard(modifier = Modifier.weight(1f), value = "$animatedPublished", label = "Published", icon = ...)
}
}
Same for the second Row with indices 2 and 3, using animatedDrafts and animatedScheduled.
Note: AnimatedVisibility + Modifier.weight(1f) inside a Row requires the weight to be on the AnimatedVisibility modifier, not the StatsCard:
AnimatedVisibility(
visible = cardVisible[0].value,
modifier = Modifier.weight(1f),
...
)
- Step 4: Wrap writing stats card (ST2)
Wrap the OutlinedCard (line 109):
AnimatedVisibility(
visible = writingStatsVisible,
enter = slideInVertically(initialOffsetY = { it / 3 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
) {
OutlinedCard(...) { ... }
}
- Step 5: Verify and commit
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5
git add app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
git commit -m "feat: add staggered stats cards and count-up animations"
Task 14: Final Verification
Files: None (verification only)
- Step 1: Run all unit tests
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew test 2>&1 | tail -20
Expected: All tests pass.
- Step 2: Build debug APK
Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew assembleDebug 2>&1 | tail -10
Expected: BUILD SUCCESSFUL
- Step 3: Commit cleanup if needed
git add -A
git commit -m "chore: clean up imports after animation additions"