Swoosh/docs/superpowers/plans/2026-03-19-micro-animations.md

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"

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"