mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
1259 lines
40 KiB
Markdown
1259 lines
40 KiB
Markdown
# 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 <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**
|
|
|
|
```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<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`):
|
|
|
|
```kotlin
|
|
@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):
|
|
|
|
```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"
|
|
```
|