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

31 KiB

Micro-Animations Implementation Plan

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 19 micro-animations + 5 navigation transitions to make Swoosh feel alive with expressive, bouncy character.

Architecture: Centralized SwooshMotion object provides shared spring/tween specs. Each screen gets targeted animation modifications. A reusable AnimatedDialog and PulsingPlaceholder component are shared across screens. Navigation transitions are configured per-route in NavGraph.kt.

Tech Stack: Jetpack Compose Animation APIs (animateFloatAsState, AnimatedVisibility, AnimatedContent, rememberInfiniteTransition, Animatable), Spring physics (spring()), Navigation Compose transitions.

Spec: docs/superpowers/specs/2026-03-19-micro-animations-design.md


File Structure

New Files

File Responsibility
ui/animation/SwooshMotion.kt Shared animation specs (Bouncy, BouncyQuick, Snappy, Gentle, Quick) + reduced motion check
ui/components/AnimatedDialog.kt Reusable scale-in dialog wrapper with backdrop fade
ui/components/PulsingPlaceholder.kt Pulsing alpha placeholder for loading states

Modified Files

File Changes
ui/feed/FeedScreen.kt FAB animations, staggered cards, expand animation, empty state, queue chip, snackbar
ui/composer/ComposerScreen.kt Image preview, link preview, schedule chip, publish button, char counter, action buttons, error text
ui/detail/DetailScreen.kt Content reveal sequence, status badge bounce, animated delete dialog, metadata slide
ui/settings/SettingsScreen.kt "Settings saved" animation, disconnect confirmation dialog
ui/navigation/NavGraph.kt Per-route enter/exit/popEnter/popExit 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 android.provider.Settings
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext

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 delay per item in cascading animations.
    const val StaggerDelayMs = 50L

    // Content reveal stagger (Detail screen).
    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 — Reusable Dialog Wrapper

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 — Loading Placeholder

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 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/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 to NavGraph.kt

At the top of NavGraph.kt, add these imports (after existing imports around line 15):

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 Setup route

Modify the composable(Routes.SETUP) call (around line 37) to include transitions:

composable(
    Routes.SETUP,
    enterTransition = { fadeIn(tween(500)) },
    exitTransition = { fadeOut(tween(500)) }
) {
  • Step 3: Add transitions to Feed route

Modify the composable(Routes.FEED) call (around line 48) to include transitions:

composable(
    Routes.FEED,
    enterTransition = { fadeIn(tween(300)) },
    exitTransition = { fadeOut(tween(200)) },
    popEnterTransition = { fadeIn(tween(300)) },
    popExitTransition = { fadeOut(tween(200)) }
) {
  • Step 4: Add transitions to Composer route

Modify the composable(Routes.COMPOSER) call (around line 63) to include slide-up transitions:

composable(
    Routes.COMPOSER,
    enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
    exitTransition = { fadeOut(tween(200)) },
    popEnterTransition = { fadeIn(tween(300)) },
    popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
) {
  • Step 5: Add transitions to Detail route

Modify the composable(Routes.DETAIL) call (around line 75) to include slide-from-right transitions:

composable(
    Routes.DETAIL,
    enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
    exitTransition = { fadeOut(tween(200)) },
    popEnterTransition = { fadeIn(tween(300)) },
    popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() }
) {
  • Step 6: Add transitions to Settings route

Modify the composable(Routes.SETTINGS) call (around line 93) to include slide-from-right transitions:

composable(
    Routes.SETTINGS,
    enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
    exitTransition = { fadeOut(tween(200)) },
    popEnterTransition = { fadeIn(tween(300)) },
    popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
  • Step 7: Verify it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 8: Commit
git add app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
git commit -m "feat: add navigation transitions between screens"

Task 5: Feed Screen — FAB Animations

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt (lines 77-81 for FAB)

  • Step 1: Add animation imports to FeedScreen.kt

Add these imports at the top of the file:

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import com.swoosh.microblog.ui.animation.SwooshMotion
  • Step 2: Add FAB entrance + press animation state

Before the Scaffold call (around line 52), 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"
)
  • Step 3: Replace FAB with animated version

Replace the existing FAB (lines 77-81) with:

floatingActionButton = {
    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 it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 5: Commit
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 Screen — Staggered Card Entrance

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt (lines 145-168 for LazyColumn)

  • Step 1: Add stagger tracking state

Before the Scaffold call, alongside the FAB state, add:

// Staggered entrance tracking
val animatedKeys = remember { mutableStateSetOf<String>() }
var initialLoadComplete by remember { mutableStateOf(false) }
  • Step 2: Wrap each LazyColumn item with staggered AnimatedVisibility

Inside the items() block (around line 151), wrap the PostCard call. The item key is post.ghostId ?: "local_${post.localId}". Wrap the card:

items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
    val itemKey = post.ghostId ?: "local_${post.localId}"
    val shouldAnimate = !initialLoadComplete && itemKey !in animatedKeys
    var visible by remember { mutableStateOf(!shouldAnimate) }

    LaunchedEffect(itemKey) {
        if (shouldAnimate) {
            val index = animatedKeys.size
            if (index < 8) {
                delay(SwooshMotion.StaggerDelayMs * index)
            }
            animatedKeys.add(itemKey)
            visible = true
        }
    }

    AnimatedVisibility(
        visible = visible,
        enter = slideInVertically(
            initialOffsetY = { it / 3 },
            animationSpec = SwooshMotion.gentle()
        ) + fadeIn(animationSpec = SwooshMotion.quick())
    ) {
        PostCard(
            // ... existing PostCard parameters unchanged
        )
    }
}
  • Step 3: Mark initial load complete after first batch

After the LazyColumn, add:

LaunchedEffect(state.posts) {
    if (state.posts.isNotEmpty() && !initialLoadComplete) {
        delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 300)
        initialLoadComplete = true
    }
}
  • Step 4: Verify it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 5: Commit
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add staggered card entrance animation in feed"

Task 7: Feed Screen — Show More, Empty State, Queue Chip, Snackbar

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt

  • Step 1: Animate "Show more" expand (lines 225-260 in PostCard)

Replace the truncated text display 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.text else post.text.take(280) + "...",
        style = MaterialTheme.typography.bodyMedium
    )
}
  • Step 2: Animate empty states (lines 90-142)

Wrap both empty state blocks with AnimatedVisibility:

AnimatedVisibility(
    visible = /* existing condition */,
    enter = fadeIn(SwooshMotion.quick()) + scaleIn(
        initialScale = 0.9f,
        animationSpec = SwooshMotion.quick()
    ),
    exit = fadeOut(SwooshMotion.quick())
) {
    // existing empty state Column content
}
  • Step 3: Animate queue status chip (lines 302-322)

Add pulsing animation to the queue chip when uploading:

val isUploading = post.queueStatus == QueueStatus.UPLOADING
val infiniteTransition = rememberInfiniteTransition(label = "queuePulse")
val chipAlpha by if (isUploading) {
    infiniteTransition.animateFloat(
        initialValue = 0.6f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(600),
            repeatMode = RepeatMode.Reverse
        ),
        label = "uploadPulse"
    )
} else {
    remember { mutableFloatStateOf(1f) }
}
// Apply Modifier.graphicsLayer { alpha = chipAlpha } to the AssistChip
  • Step 4: Verify it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 5: Commit
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
git commit -m "feat: add expand, empty state, and queue chip animations"

Task 8: Composer Screen — All Animations

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt

  • Step 1: Add animation 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 preview (lines 118-140)

Wrap the image preview section with AnimatedVisibility:

AnimatedVisibility(
    visible = state.imageUri != null,
    enter = scaleIn(
        initialScale = 0f,
        animationSpec = SwooshMotion.bouncy()
    ) + fadeIn(SwooshMotion.quick()),
    exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
    // existing Box with AsyncImage + close button
}
  • Step 3: Animate link preview (lines 143-188)

Replace LinearProgressIndicator (lines 143-146) with PulsingPlaceholder when loading, and wrap the link preview card with AnimatedVisibility:

// Loading state
AnimatedVisibility(
    visible = state.isLoadingLink,
    enter = fadeIn(SwooshMotion.quick()),
    exit = fadeOut(SwooshMotion.quick())
) {
    PulsingPlaceholder(height = 80.dp)
}

// Loaded link preview
AnimatedVisibility(
    visible = state.linkPreview != null && !state.isLoadingLink,
    enter = slideInVertically(
        initialOffsetY = { it / 2 },
        animationSpec = SwooshMotion.gentle()
    ) + fadeIn(SwooshMotion.quick()),
    exit = fadeOut(SwooshMotion.quick())
) {
    // existing OutlinedCard with link preview
}
  • Step 4: Animate schedule chip (lines 191-206)

Wrap schedule chip with AnimatedVisibility:

AnimatedVisibility(
    visible = state.scheduledAt != null,
    enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
    exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
    // existing AssistChip
}
  • Step 5: Animate character counter color (lines 92-99)

Replace static color with animated color:

val counterColor by animateColorAsState(
    targetValue = if (state.text.length > 280)
        MaterialTheme.colorScheme.error
    else
        MaterialTheme.colorScheme.onSurfaceVariant,
    animationSpec = SwooshMotion.quick(),
    label = "counterColor"
)
// Use counterColor in the Text composable
  • Step 6: Animate action buttons row (lines 234-256)

Add staggered entrance to action buttons:

val buttonLabels = listOf("draft", "schedule", "publish")
buttonLabels.forEachIndexed { index, _ ->
    var buttonVisible by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        delay(SwooshMotion.StaggerDelayMs * index)
        buttonVisible = true
    }
    AnimatedVisibility(
        visible = buttonVisible,
        enter = scaleIn(animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick())
    ) {
        // Existing button for this index
    }
}
  • Step 7: Animate error text (lines 208-215)

Wrap error text with AnimatedVisibility:

AnimatedVisibility(
    visible = state.error != null,
    enter = slideInHorizontally(
        initialOffsetX = { -it / 4 },
        animationSpec = SwooshMotion.snappy()
    ) + fadeIn(SwooshMotion.quick()),
    exit = fadeOut(SwooshMotion.quick())
) {
    // existing error Text
}
  • Step 8: Verify it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 9: Commit
git add app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
git commit -m "feat: add all composer screen micro-animations"

Task 9: Detail Screen — Content Reveal & Delete Dialog

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt

  • Step 1: Add animation imports

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.AnimatedDialog
  • Step 2: Add sequential reveal states

At the top of the DetailScreen composable (inside the function body, around line 40), add:

// Sequential content reveal
val revealSections = 4 // status, text, image, metadata
val sectionVisible = remember {
    List(revealSections) { mutableStateOf(false) }
}
LaunchedEffect(Unit) {
    sectionVisible.forEachIndexed { index, state ->
        delay(SwooshMotion.RevealDelayMs * index)
        state.value = true
    }
}
  • Step 3: Wrap each content section with AnimatedVisibility

Wrap sections in the Column (lines 59-145):

Section 0 — Status + time row (lines 59-69):

AnimatedVisibility(
    visible = sectionVisible[0].value,
    enter = fadeIn(SwooshMotion.quick()) + scaleIn(
        initialScale = 0.8f,
        animationSpec = SwooshMotion.bouncy()
    )
) {
    Row(/* existing status + time */) { ... }
}

Section 1 — Text content (lines 74-77):

AnimatedVisibility(
    visible = sectionVisible[1].value,
    enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
        initialOffsetY = { 20 },
        animationSpec = SwooshMotion.gentle()
    )
) {
    Text(/* existing */)
}

Section 2 — Image (lines 80-90):

AnimatedVisibility(
    visible = sectionVisible[2].value && post.imageUrl != null,
    enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
        initialOffsetY = { 20 },
        animationSpec = SwooshMotion.gentle()
    )
) {
    AsyncImage(/* existing */)
}

Section 3 — Metadata (lines 134-145):

AnimatedVisibility(
    visible = sectionVisible[3].value,
    enter = slideInVertically(
        initialOffsetY = { it / 4 },
        animationSpec = SwooshMotion.gentle()
    ) + fadeIn(SwooshMotion.quick())
) {
    Column(/* existing metadata */) { ... }
}
  • Step 4: Replace delete AlertDialog with AnimatedDialog

Replace the AlertDialog (lines 148-168) with:

if (showDeleteDialog) {
    AnimatedDialog(onDismissRequest = { showDeleteDialog = false }) {
        // Same AlertDialog content but wrapped in a Card/Surface for the animated wrapper
        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 it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 6: Commit
git add app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
git commit -m "feat: add content reveal and animated delete dialog"

Task 10: Settings Screen — Saved Feedback & Disconnect Dialog

Files:

  • Modify: app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt

  • Step 1: Add animation imports

import androidx.compose.animation.*
import androidx.compose.animation.core.*
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.AnimatedDialog
  • Step 2: Animate "Settings saved" text (lines 84-91)

Replace the static conditional with AnimatedVisibility:

AnimatedVisibility(
    visible = saved,
    enter = scaleIn(
        initialScale = 0f,
        animationSpec = SwooshMotion.bouncy()
    ) + fadeIn(SwooshMotion.quick()),
    exit = fadeOut(SwooshMotion.quick())
) {
    Text(
        "Settings saved",
        color = MaterialTheme.colorScheme.primary,
        style = MaterialTheme.typography.bodyMedium,
        modifier = Modifier.padding(top = 8.dp)
    )
}

Add auto-hide after 2 seconds:

LaunchedEffect(saved) {
    if (saved) {
        delay(2000)
        saved = false
    }
}
  • Step 3: Add disconnect confirmation dialog (replacing direct disconnect, lines 97-109)

Add state for the dialog:

var showDisconnectDialog by remember { mutableStateOf(false) }

Change the disconnect button to show dialog instead of directly disconnecting:

OutlinedButton(
    onClick = { showDisconnectDialog = true },
    colors = ButtonDefaults.outlinedButtonColors(
        contentColor = MaterialTheme.colorScheme.error
    ),
    modifier = Modifier.fillMaxWidth()
) {
    Text("Disconnect & Reset")
}

if (showDisconnectDialog) {
    AnimatedDialog(onDismissRequest = { showDisconnectDialog = false }) {
        Card(modifier = Modifier.padding(horizontal = 24.dp)) {
            Column(modifier = Modifier.padding(24.dp)) {
                Text("Disconnect?", style = MaterialTheme.typography.headlineSmall)
                Spacer(modifier = Modifier.height(16.dp))
                Text("This will clear your Ghost credentials. 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
                            credentials.clear()
                            ApiClient.resetClient()
                            onLogout()
                        },
                        colors = ButtonDefaults.buttonColors(
                            containerColor = MaterialTheme.colorScheme.error
                        )
                    ) {
                        Text("Disconnect")
                    }
                }
            }
        }
    }
}
  • Step 4: Verify it compiles

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5 Expected: BUILD SUCCESSFUL

  • Step 5: Commit
git add app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
git commit -m "feat: add settings saved animation and disconnect dialog"

Task 11: Run All Tests & 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. Animations don't affect business logic.

  • Step 2: Build debug APK

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew assembleDebug 2>&1 | tail -10 Expected: BUILD SUCCESSFUL

  • Step 3: Verify no unused imports or lint issues

Run: cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew lintDebug 2>&1 | tail -20 Expected: No new errors introduced

  • Step 4: Final commit if any cleanup needed
git add -A
git commit -m "chore: clean up lint and unused imports after animation additions"