# 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** ```kotlin 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 bouncy(): FiniteAnimationSpec = spring( dampingRatio = 0.65f, stiffness = 400f ) // Fast snap-back — press feedback, button taps. Settles in ~150ms. fun bouncyQuick(): FiniteAnimationSpec = spring( dampingRatio = 0.7f, stiffness = 1000f ) // Controlled spring — expand/collapse, dialogs. fun snappy(): FiniteAnimationSpec = spring( dampingRatio = 0.7f, stiffness = 800f ) // Soft entrance — cards, content reveal. fun gentle(): FiniteAnimationSpec = spring( dampingRatio = 0.8f, stiffness = 300f ) // Quick tween — fade, color transitions. fun quick(): FiniteAnimationSpec = 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** ```bash 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** ```kotlin 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** ```bash 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** ```kotlin 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` ```bash 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: ```kotlin 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` ```bash 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: ```kotlin 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: ```kotlin // 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): ```kotlin 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` ```bash 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: ```kotlin // Staggered entrance tracking val animatedKeys = remember { mutableStateMapOf() } 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`): ```kotlin @Composable private fun StaggeredItem( key: String, index: Int, animatedKeys: MutableMap, 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): ```kotlin 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: ```kotlin 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` ```bash 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: ```kotlin 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: ```kotlin 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`: ```kotlin 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`: ```kotlin 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: ```kotlin 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` ```bash 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`: ```kotlin 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: ```kotlin 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: ```kotlin 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` ```bash 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** ```kotlin 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`: ```kotlin 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: ```kotlin AnimatedVisibility( visible = state.isLoadingLink, enter = fadeIn(SwooshMotion.quick()), exit = fadeOut(SwooshMotion.quick()) ) { PulsingPlaceholder(height = 80.dp) } ``` Wrap the link preview card (line 320) with: ```kotlin 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: ```kotlin 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: ```kotlin 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` ```bash 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`: ```kotlin 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: ```kotlin // 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`: ```kotlin 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`: ```kotlin 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` ```bash 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** ```kotlin 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): ```kotlin 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): ```kotlin 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): ```kotlin AnimatedVisibility( visible = sectionVisible[1].value, enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle()) ) { Text(text = post.textContent, ...) } ``` Section 2 — Tags (line 220): ```kotlin 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): ```kotlin 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): ```kotlin AnimatedVisibility( visible = sectionVisible[4].value && post.linkUrl != null, enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle()) ) { OutlinedCard(...) { ... } } ``` Section 5 — PostStatsSection (line 307): ```kotlin 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: ```kotlin 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` ```bash 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** ```kotlin 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: ```kotlin 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: ```kotlin 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: ```kotlin 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` ```bash 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** ```kotlin 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): ```kotlin // 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 `Row`s (lines 68-98): ```kotlin 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`: ```kotlin AnimatedVisibility( visible = cardVisible[0].value, modifier = Modifier.weight(1f), ... ) ``` - [ ] **Step 4: Wrap writing stats card (ST2)** Wrap the `OutlinedCard` (line 109): ```kotlin 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` ```bash 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** ```bash git add -A git commit -m "chore: clean up imports after animation additions" ```