diff --git a/.superpowers/brainstorm/46859-1773916456/.server-stopped b/.superpowers/brainstorm/46859-1773916456/.server-stopped
new file mode 100644
index 0000000..7352693
--- /dev/null
+++ b/.superpowers/brainstorm/46859-1773916456/.server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1773918437073}
diff --git a/.superpowers/brainstorm/46859-1773916456/.server.log b/.superpowers/brainstorm/46859-1773916456/.server.log
new file mode 100644
index 0000000..e2e2ae6
--- /dev/null
+++ b/.superpowers/brainstorm/46859-1773916456/.server.log
@@ -0,0 +1,4 @@
+{"type":"server-started","port":54989,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:54989","screen_dir":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/46859-1773916456"}
+{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/46859-1773916456/animation-map-v3.html"}
+{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/46859-1773916456/waiting-2.html"}
+{"type":"server-stopped","reason":"idle timeout"}
diff --git a/.superpowers/brainstorm/46859-1773916456/.server.pid b/.superpowers/brainstorm/46859-1773916456/.server.pid
new file mode 100644
index 0000000..49313d1
--- /dev/null
+++ b/.superpowers/brainstorm/46859-1773916456/.server.pid
@@ -0,0 +1 @@
+46868
diff --git a/.superpowers/brainstorm/46859-1773916456/animation-map-v3.html b/.superpowers/brainstorm/46859-1773916456/animation-map-v3.html
new file mode 100644
index 0000000..076e7dd
--- /dev/null
+++ b/.superpowers/brainstorm/46859-1773916456/animation-map-v3.html
@@ -0,0 +1,249 @@
+
Zaktualizowana mapa mikro-animacji Swoosh
+Pełna mapa uwzględniająca nowe funkcje: multi-account, search, swipe-to-dismiss, galeria, stats, preview. Ekspresyjny styl.
+
+
+
+
+
+
📋 Feed Screen 12
+
+
+
FAB wejście SPRING
+
Scale 0→1 z overshoot przy otwarciu feedu. Znika gdy search aktywny.
+
+
+
FAB press SPRING
+
Kurczy się do 85% na tap, sprężyście wraca. BouncyQuick — kończy przed nawigacją.
+
+
+
Karty — staggered wejście SLIDE
+
Kaskadowe slide-in od dołu, 50ms delay. Tylko initial load, max 8 kart. Tracking via mutableStateMapOf.
+
+
+
"Show more" expand SPRING
+
AnimatedContent z expandVertically/shrinkVertically. Sprężysta zmiana wysokości.
+
+
+
Empty state FADE
+
Fade-in + scale 0.9→1.0. Dotyczy wszystkich empty state'ów (connection error, filter empty, search no results).
+
+
+
Queue status chip BOUNCE
+
Puls podczas uploadu. Bounce + color crossfade na zmianę statusu. Shake na FAILED.
+
+
+
Snackbar SLIDE
+
Slide-in od dołu z overshoot (error snackbar + pin/unpin confirmation).
+
+
+
Search bar SLIDE NEW
+
SearchTopBar wjeżdża od góry z fade. Zamknięcie — reverse. Focus z delay.
+
+
+
Filter chips FADE NEW
+
Chips bar fade-in/out przy przełączeniu search. Już ma animateColorAsState — rozszerzyć o wejście.
+
+
+
Account switcher SLIDE NEW
+
ModalBottomSheet ma domyślną animację — dodać staggered wejście account items.
+
+
+
Pinned section header FADE NEW
+
Header "📌 Pinned" — fade-in z slide gdy pojawiają się pinned posty.
+
+
+
Account switch overlay FADE NEW
+
Crossfade "Switching account..." — płynne przejście z fade + scale spinner.
+
+
+
+
+
+
+
✏️ Composer Screen 9
+
+
+
Image grid preview SCALE
+
Nowe zdjęcia scale-in z bounce. Usunięcie — scale-out z fade. Cały ImageGridPreview.
+
+
+
Link preview card SLIDE
+
Slide-up + fade po załadowaniu. Pulsing placeholder zamiast LinearProgressIndicator.
+
+
+
Schedule chip SPRING
+
Pop-in z bouncy spring po wybraniu daty.
+
+
+
Publish button BOUNCE
+
Bounce na aktywację. Loading pulse. Checkmark scale-in na success.
+
+
+
Character counter color FADE
+
Trzy-stopniowy color crossfade: neutral → tertiary (280) → error (500).
+
+
+
Action buttons SCALE
+
Kaskadowy scale-in: Publish → [Draft, Schedule]. Dopasowany do Column+Row layout.
+
+
+
Error text SLIDE
+
Slide-in z lewej + fade.
+
+
+
Hashtag chips SPRING NEW
+
Extracted tags FlowRow — staggered scale-in chipów gdy tagi się pojawiają.
+
+
+
Edit/Preview tabs FADE NEW
+
Crossfade między edit mode a preview mode. Płynne przejście contentu.
+
+
+
+
+
+
+
📖 Detail Screen 5
+
+
+
Content reveal FADE
+
Sekwencyjne: status → tekst → tagi → galeria → link → stats. 80ms delay each.
+
+
+
Status badge SPRING
+
Scale-in z bounce — pierwszy element w sekwencji.
+
+
+
Delete dialog SCALE
+
AnimatedDialog — scale z centrum + backdrop fade.
+
+
+
PostStatsSection SLIDE
+
Slide-up jako ostatni w sekwencji. Expand/collapse już animowane.
+
+
+
Pin toggle feedback BOUNCE NEW
+
Ikona pina bouncy rotate przy toggle. Zmiana filled↔outlined z crossfade.
+
+
+
+
+
+
+
⚙️ Settings Screen 3
+
+
+
Account card FADE NEW
+
Card z current account — fade-in + subtle scale przy wejściu na ekran.
+
+
+
Disconnect confirm SCALE
+
AnimatedDialog z scale-in. Nowe zachowanie: dialog przed disconnect.
+
+
+
Theme chip selection SPRING NEW
+
Bouncy scale pulse na wybranym theme chipie (System/Light/Dark).
+
+
+
+
+
+
+
📊 Stats Screen 3 NEW
+
+
+
Stats cards wejście SCALE NEW
+
4 StatsCards — staggered scale-in z bounce. Efekt "odliczania" statystyk.
+
+
+
Writing stats reveal SLIDE NEW
+
OutlinedCard slide-up z fade po cards. Kaskadowe pojawienie WritingStatRows.
+
+
+
Number count-up FADE NEW
+
Wartości liczbowe animowane od 0 do docelowej (animateIntAsState). Subtelne ale efektowne.
+
+
+
+
+
+
+
🎛️ SwooshMotion — wspólne parametry
+
+
+
Bouncy
+
damping=0.65, stiff=400
→ FAB, chipy, badges
+
+
+
BouncyQuick
+
damping=0.7, stiff=1000
→ press feedback
+
+
+
Snappy
+
damping=0.7, stiff=800
→ expand, dialogi
+
+
+
Gentle
+
damping=0.8, stiff=300
→ karty, reveal
+
+
+
Quick
+
200ms FastOutSlowIn
→ fade, color
+
+
+
ReducedMotion
+
Checks ANIMATOR_DURATION_SCALE
→ snap() fallback
+
+
+
+
+
+
+
🔀 Przejścia nawigacyjne — 8 tras
+
+ | Feed → Composer | Slide up + fade |
+ | Feed → Detail | Slide right + fade |
+ | Feed → Settings | Slide right |
+ | Settings → Stats | Slide right |
+ | Detail/Composer → Preview | Slide up + fade |
+ | Feed → AddAccount | Slide up + fade (jak Composer) |
+ | Setup → Feed | Crossfade 500ms |
+ | Back (wszystkie) | Reverse via popEnter/popExit |
+
+
+
+
+
+ Podsumowanie: 32 animacje + 8 przejść nawigacyjnych.
+ Nowe pliki: SwooshMotion.kt, AnimatedDialog.kt, PulsingPlaceholder.kt (3 pliki)
+ Modyfikowane: FeedScreen, ComposerScreen, DetailScreen, SettingsScreen, StatsScreen, NavGraph (6 plików)
+ 12 nowych animacji względem pierwotnego planu (zielone NEW), wynikające z nowych funkcji aplikacji.
+
diff --git a/.superpowers/brainstorm/46859-1773916456/waiting-2.html b/.superpowers/brainstorm/46859-1773916456/waiting-2.html
new file mode 100644
index 0000000..3d2a71c
--- /dev/null
+++ b/.superpowers/brainstorm/46859-1773916456/waiting-2.html
@@ -0,0 +1,3 @@
+
+
Writing spec & plan in terminal...
+
diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
index 478b29d..95854a3 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
@@ -869,8 +869,8 @@ fun SwipeBackground(dismissState: SwipeToDismissBoxState) {
val color by animateColorAsState(
when (direction) {
- SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.primary
- SwipeToDismissBoxValue.EndToStart -> MaterialTheme.colorScheme.error
+ SwipeToDismissBoxValue.StartToEnd -> Color(0xFF1565C0) // Bold blue
+ SwipeToDismissBoxValue.EndToStart -> Color(0xFFC62828) // Bold red
SwipeToDismissBoxValue.Settled -> Color.Transparent
},
label = "swipe_bg_color"
@@ -1262,7 +1262,7 @@ fun PostCardContent(
Card(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp)
+ .padding(horizontal = 16.dp, vertical = 6.dp)
.combinedClickable(
onClick = onClick,
onClickLabel = "View post details",
@@ -1270,9 +1270,10 @@ fun PostCardContent(
showContextMenu = true
}
),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = if (post.featured)
- MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else
MaterialTheme.colorScheme.surface
)
@@ -1978,21 +1979,21 @@ fun buildHighlightedString(
@Composable
fun StatusBadge(post: FeedPost) {
- val (label, color) = when {
- post.queueStatus != QueueStatus.NONE -> "Pending" to MaterialTheme.colorScheme.tertiary
- post.status == "published" -> "Published" to MaterialTheme.colorScheme.primary
- post.status == "scheduled" -> "Scheduled" to MaterialTheme.colorScheme.secondary
- else -> "Draft" to MaterialTheme.colorScheme.outline
+ val (label, containerColor, labelColor) = when {
+ post.queueStatus != QueueStatus.NONE -> Triple("Pending", Color(0xFFFFF3E0), Color(0xFFE65100))
+ post.status == "published" -> Triple("Published", Color(0xFFE8F5E9), Color(0xFF2E7D32))
+ post.status == "scheduled" -> Triple("Scheduled", Color(0xFFE3F2FD), Color(0xFF1565C0))
+ else -> Triple("Draft", Color(0xFFF3E5F5), Color(0xFF7B1FA2))
}
SuggestionChip(
onClick = {},
label = {
- Text(label, style = MaterialTheme.typography.labelSmall)
+ Text(label, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold))
},
colors = SuggestionChipDefaults.suggestionChipColors(
- containerColor = color.copy(alpha = 0.12f),
- labelColor = color
+ containerColor = containerColor,
+ labelColor = labelColor
),
border = null
)
diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/Color.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/Color.kt
new file mode 100644
index 0000000..79ea675
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/ui/theme/Color.kt
@@ -0,0 +1,59 @@
+package com.swoosh.microblog.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Bold, expressive palette inspired by the Swoosh icon (dark green + mint)
+val SwooshGreen = Color(0xFF1B4332)
+val SwooshMint = Color(0xFF52B788)
+val SwooshMintLight = Color(0xFF95D5B2)
+val SwooshMintDark = Color(0xFF2D6A4F)
+
+// Light theme - bold, high-contrast
+val LightPrimary = Color(0xFF1B6B45)
+val LightOnPrimary = Color(0xFFFFFFFF)
+val LightPrimaryContainer = Color(0xFFD0F0DC)
+val LightOnPrimaryContainer = Color(0xFF002111)
+val LightSecondary = Color(0xFF4A635A)
+val LightOnSecondary = Color(0xFFFFFFFF)
+val LightSecondaryContainer = Color(0xFFCCE8DC)
+val LightOnSecondaryContainer = Color(0xFF072018)
+val LightTertiary = Color(0xFF3D6374)
+val LightOnTertiary = Color(0xFFFFFFFF)
+val LightTertiaryContainer = Color(0xFFC1E8FC)
+val LightOnTertiaryContainer = Color(0xFF001F2A)
+val LightError = Color(0xFFBA1A1A)
+val LightOnError = Color(0xFFFFFFFF)
+val LightErrorContainer = Color(0xFFFFDAD6)
+val LightOnErrorContainer = Color(0xFF410002)
+val LightBackground = Color(0xFFF5FBF5)
+val LightOnBackground = Color(0xFF171D19)
+val LightSurface = Color(0xFFF5FBF5)
+val LightOnSurface = Color(0xFF171D19)
+val LightSurfaceVariant = Color(0xFFDBE5DD)
+val LightOnSurfaceVariant = Color(0xFF404943)
+val LightOutline = Color(0xFF707973)
+
+// Dark theme - rich, bold
+val DarkPrimary = Color(0xFF52B788)
+val DarkOnPrimary = Color(0xFF003822)
+val DarkPrimaryContainer = Color(0xFF005233)
+val DarkOnPrimaryContainer = Color(0xFFD0F0DC)
+val DarkSecondary = Color(0xFFB1CCC0)
+val DarkOnSecondary = Color(0xFF1C352C)
+val DarkSecondaryContainer = Color(0xFF334B42)
+val DarkOnSecondaryContainer = Color(0xFFCCE8DC)
+val DarkTertiary = Color(0xFFA5CCE0)
+val DarkOnTertiary = Color(0xFF073544)
+val DarkTertiaryContainer = Color(0xFF244B5C)
+val DarkOnTertiaryContainer = Color(0xFFC1E8FC)
+val DarkError = Color(0xFFFFB4AB)
+val DarkOnError = Color(0xFF690005)
+val DarkErrorContainer = Color(0xFF93000A)
+val DarkOnErrorContainer = Color(0xFFFFDAD6)
+val DarkBackground = Color(0xFF0F1512)
+val DarkOnBackground = Color(0xFFDFE4DE)
+val DarkSurface = Color(0xFF0F1512)
+val DarkOnSurface = Color(0xFFDFE4DE)
+val DarkSurfaceVariant = Color(0xFF404943)
+val DarkOnSurfaceVariant = Color(0xFFBFC9C1)
+val DarkOutline = Color(0xFF8A938C)
diff --git a/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt
index b2885fd..b378586 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/theme/Theme.kt
@@ -1,15 +1,133 @@
package com.swoosh.microblog.ui.theme
-import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+private val SwooshLightColorScheme = lightColorScheme(
+ primary = LightPrimary,
+ onPrimary = LightOnPrimary,
+ primaryContainer = LightPrimaryContainer,
+ onPrimaryContainer = LightOnPrimaryContainer,
+ secondary = LightSecondary,
+ onSecondary = LightOnSecondary,
+ secondaryContainer = LightSecondaryContainer,
+ onSecondaryContainer = LightOnSecondaryContainer,
+ tertiary = LightTertiary,
+ onTertiary = LightOnTertiary,
+ tertiaryContainer = LightTertiaryContainer,
+ onTertiaryContainer = LightOnTertiaryContainer,
+ error = LightError,
+ onError = LightOnError,
+ errorContainer = LightErrorContainer,
+ onErrorContainer = LightOnErrorContainer,
+ background = LightBackground,
+ onBackground = LightOnBackground,
+ surface = LightSurface,
+ onSurface = LightOnSurface,
+ surfaceVariant = LightSurfaceVariant,
+ onSurfaceVariant = LightOnSurfaceVariant,
+ outline = LightOutline
+)
+
+private val SwooshDarkColorScheme = darkColorScheme(
+ primary = DarkPrimary,
+ onPrimary = DarkOnPrimary,
+ primaryContainer = DarkPrimaryContainer,
+ onPrimaryContainer = DarkOnPrimaryContainer,
+ secondary = DarkSecondary,
+ onSecondary = DarkOnSecondary,
+ secondaryContainer = DarkSecondaryContainer,
+ onSecondaryContainer = DarkOnSecondaryContainer,
+ tertiary = DarkTertiary,
+ onTertiary = DarkOnTertiary,
+ tertiaryContainer = DarkTertiaryContainer,
+ onTertiaryContainer = DarkOnTertiaryContainer,
+ error = DarkError,
+ onError = DarkOnError,
+ errorContainer = DarkErrorContainer,
+ onErrorContainer = DarkOnErrorContainer,
+ background = DarkBackground,
+ onBackground = DarkOnBackground,
+ surface = DarkSurface,
+ onSurface = DarkOnSurface,
+ surfaceVariant = DarkSurfaceVariant,
+ onSurfaceVariant = DarkOnSurfaceVariant,
+ outline = DarkOutline
+)
+
+private val SwooshTypography = Typography(
+ headlineLarge = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = (-0.5).sp
+ ),
+ headlineMedium = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ lineHeight = 36.sp
+ ),
+ headlineSmall = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 24.sp,
+ lineHeight = 32.sp
+ ),
+ titleLarge = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 22.sp,
+ lineHeight = 28.sp
+ ),
+ titleMedium = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp
+ ),
+ titleSmall = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ bodyLarge = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ bodyMedium = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp
+ ),
+ labelLarge = TextStyle(
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
+ labelSmall = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+)
@Composable
fun SwooshTheme(
themeMode: ThemeMode = ThemeMode.SYSTEM,
- dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
@@ -18,19 +136,11 @@ fun SwooshTheme(
ThemeMode.DARK -> true
}
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context)
- else dynamicLightColorScheme(context)
- }
- darkTheme -> darkColorScheme()
- else -> lightColorScheme()
- }
+ val colorScheme = if (darkTheme) SwooshDarkColorScheme else SwooshLightColorScheme
MaterialTheme(
colorScheme = colorScheme,
- typography = Typography(),
+ typography = SwooshTypography,
content = content
)
}
diff --git a/docs/superpowers/plans/2026-03-19-micro-animations.md b/docs/superpowers/plans/2026-03-19-micro-animations.md
index d0624f8..6c22a1e 100644
--- a/docs/superpowers/plans/2026-03-19-micro-animations.md
+++ b/docs/superpowers/plans/2026-03-19-micro-animations.md
@@ -1,12 +1,12 @@
-# Micro-Animations Implementation Plan
+# 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 19 micro-animations + 5 navigation transitions to make Swoosh feel alive with expressive, bouncy character.
+**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. 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`.
+**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 (`animateFloatAsState`, `AnimatedVisibility`, `AnimatedContent`, `rememberInfiniteTransition`, `Animatable`), Spring physics (`spring()`), Navigation Compose transitions.
+**Tech Stack:** Jetpack Compose Animation APIs, Spring physics, Navigation Compose transitions.
**Spec:** `docs/superpowers/specs/2026-03-19-micro-animations-design.md`
@@ -14,21 +14,22 @@
## File Structure
-### New Files
+### New Files (3)
| 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 |
+| `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
-| 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 |
+### 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 |
---
@@ -42,16 +43,11 @@
```kotlin
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 {
@@ -85,10 +81,8 @@ object SwooshMotion {
easing = FastOutSlowInEasing
)
- // Stagger delay per item in cascading animations.
+ // Stagger delays
const val StaggerDelayMs = 50L
-
- // Content reveal stagger (Detail screen).
const val RevealDelayMs = 80L
}
```
@@ -107,7 +101,7 @@ git commit -m "feat: add SwooshMotion shared animation specs"
---
-## Task 2: AnimatedDialog — Reusable Dialog Wrapper
+## Task 2: AnimatedDialog Component
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt`
@@ -203,7 +197,7 @@ git commit -m "feat: add AnimatedDialog reusable component"
---
-## Task 3: PulsingPlaceholder — Loading Placeholder
+## Task 3: PulsingPlaceholder Component
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt`
@@ -257,12 +251,9 @@ fun PulsingPlaceholder(
}
```
-- [ ] **Step 2: Verify it compiles**
+- [ ] **Step 2: Verify and commit**
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/PulsingPlaceholder.kt
@@ -276,9 +267,9 @@ git commit -m "feat: add PulsingPlaceholder loading component"
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
-- [ ] **Step 1: Add animation imports to NavGraph.kt**
+- [ ] **Step 1: Add animation imports**
-At the top of `NavGraph.kt`, add these imports (after existing imports around line 15):
+After existing imports (line 18), add:
```kotlin
import androidx.compose.animation.fadeIn
@@ -290,108 +281,56 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
```
-- [ ] **Step 2: Add transitions to Setup route**
+- [ ] **Step 2: Add transitions to all 8 routes**
-Modify the `composable(Routes.SETUP)` call (around line 37) to include transitions:
+For each `composable()` call, add transition lambdas. The routes and their line numbers:
-```kotlin
-composable(
- Routes.SETUP,
- enterTransition = { fadeIn(tween(500)) },
- exitTransition = { fadeOut(tween(500)) }
-) {
-```
+**Setup** (line 44): `enterTransition = { fadeIn(tween(500)) }, exitTransition = { fadeOut(tween(500)) }`
-- [ ] **Step 3: Add transitions to Feed route**
+**Feed** (line 55): `enterTransition = { fadeIn(tween(300)) }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { fadeOut(tween(200)) }`
-Modify the `composable(Routes.FEED)` call (around line 48) to include transitions:
+**Composer** (line 79): `enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }`
-```kotlin
-composable(
- Routes.FEED,
- enterTransition = { fadeIn(tween(300)) },
- exitTransition = { fadeOut(tween(200)) },
- popEnterTransition = { fadeIn(tween(300)) },
- popExitTransition = { fadeOut(tween(200)) }
-) {
-```
+**Detail** (line 95): `enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() }`
-- [ ] **Step 4: Add transitions to Composer route**
+**Settings** (line 122): `enterTransition = { slideInHorizontally(initialOffsetX = { it }) }, exitTransition = { fadeOut(tween(200)) }, popEnterTransition = { fadeIn(tween(300)) }, popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }`
-Modify the `composable(Routes.COMPOSER)` call (around line 63) to include slide-up transitions:
+**Stats** (line 141): Same as Settings (slide from right).
-```kotlin
-composable(
- Routes.COMPOSER,
- enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
- exitTransition = { fadeOut(tween(200)) },
- popEnterTransition = { fadeIn(tween(300)) },
- popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
-) {
-```
+**Preview** (line 147): Same as Composer (slide from bottom).
-- [ ] **Step 5: Add transitions to Detail route**
+**AddAccount** (line 154): Same as Composer (slide from bottom).
-Modify the `composable(Routes.DETAIL)` call (around line 75) to include slide-from-right transitions:
-
-```kotlin
-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:
-
-```kotlin
-composable(
- Routes.SETTINGS,
- enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
- exitTransition = { fadeOut(tween(200)) },
- popEnterTransition = { fadeIn(tween(300)) },
- popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
-) {
-```
-
-- [ ] **Step 7: Verify it compiles**
+- [ ] **Step 3: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 8: Commit**
```bash
git add app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
-git commit -m "feat: add navigation transitions between screens"
+git commit -m "feat: add navigation transitions for all 8 routes"
```
---
-## Task 5: Feed Screen — FAB Animations
+## Task 5: Feed — FAB Animations (F1, F2)
**Files:**
-- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt` (lines 77-81 for FAB)
+- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
-- [ ] **Step 1: Add animation imports to FeedScreen.kt**
+- [ ] **Step 1: Add SwooshMotion import**
-Add these imports at the top of the file:
+Add at the top of FeedScreen.kt:
```kotlin
-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
+import androidx.compose.foundation.gestures.detectTapGestures
```
-- [ ] **Step 2: Add FAB entrance + press animation state**
+Note: `graphicsLayer`, `pointerInput`, and animation imports already exist in this file.
-Before the `Scaffold` call (around line 52), add:
+- [ ] **Step 2: Add FAB state variables**
+
+Inside `FeedScreen`, before the `Scaffold` call (before line 155), add:
```kotlin
// FAB entrance animation
@@ -412,40 +351,41 @@ val fabPressScale by animateFloatAsState(
)
```
-- [ ] **Step 3: Replace FAB with animated version**
+Note: `animateFloatAsState` needs `import androidx.compose.animation.core.animateFloatAsState`.
-Replace the existing FAB (lines 77-81) with:
+- [ ] **Step 3: Replace FAB composable**
+
+Replace lines 248-253 (the `floatingActionButton` lambda):
```kotlin
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")
+ 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 it compiles**
+- [ ] **Step 4: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 5: Commit**
```bash
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
@@ -454,40 +394,43 @@ git commit -m "feat: add bouncy FAB entrance and press animations"
---
-## Task 6: Feed Screen — Staggered Card Entrance
+## Task 6: Feed — Staggered Card Entrance (F3)
**Files:**
-- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt` (lines 145-168 for LazyColumn)
+- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- [ ] **Step 1: Add stagger tracking state**
-Before the `Scaffold` call, alongside the FAB state, add:
+Near the FAB state vars added in Task 5, add:
```kotlin
// Staggered entrance tracking
-val animatedKeys = remember { mutableStateSetOf() }
+val animatedKeys = remember { mutableStateMapOf() }
var initialLoadComplete by remember { mutableStateOf(false) }
```
-- [ ] **Step 2: Wrap each LazyColumn item with staggered AnimatedVisibility**
+- [ ] **Step 2: Create a helper composable for staggered items**
-Inside the `items()` block (around line 151), wrap the `PostCard` call. The item key is `post.ghostId ?: "local_${post.localId}"`. Wrap the card:
+Add a private composable at the bottom of the file (before `FilterChipsBar`):
```kotlin
-items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
- val itemKey = post.ghostId ?: "local_${post.localId}"
- val shouldAnimate = !initialLoadComplete && itemKey !in animatedKeys
+@Composable
+private fun StaggeredItem(
+ key: String,
+ index: Int,
+ animatedKeys: MutableMap,
+ initialLoadComplete: Boolean,
+ content: @Composable () -> Unit
+) {
+ val shouldAnimate = !initialLoadComplete && key !in animatedKeys
var visible by remember { mutableStateOf(!shouldAnimate) }
- LaunchedEffect(itemKey) {
- if (shouldAnimate) {
- val index = animatedKeys.size
- if (index < 8) {
- delay(SwooshMotion.StaggerDelayMs * index)
- }
- animatedKeys.add(itemKey)
- visible = true
+ LaunchedEffect(key) {
+ if (shouldAnimate && animatedKeys.size < 8) {
+ delay(SwooshMotion.StaggerDelayMs * animatedKeys.size)
+ animatedKeys[key] = true
}
+ visible = true
}
AnimatedVisibility(
@@ -496,17 +439,42 @@ items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
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(
- // ... existing PostCard parameters unchanged
+ post = post,
+ onClick = { onPostClick(post) },
+ onCancelQueue = { viewModel.cancelQueuedPost(post) },
+ highlightQuery = searchQuery
)
}
}
```
-- [ ] **Step 3: Mark initial load complete after first batch**
+Same wrapping for pinned posts (line 467) and regular posts (line 508).
-After the LazyColumn, add:
+- [ ] **Step 4: Mark initial load complete**
+
+After the LazyColumn block, add:
```kotlin
LaunchedEffect(state.posts) {
@@ -517,28 +485,126 @@ LaunchedEffect(state.posts) {
}
```
-- [ ] **Step 4: Verify it compiles**
+- [ ] **Step 5: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 5: Commit**
```bash
git add app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt
-git commit -m "feat: add staggered card entrance animation in feed"
+git commit -m "feat: add staggered card entrance animation"
```
---
-## Task 7: Feed Screen — Show More, Empty State, Queue Chip, Snackbar
+## 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: Animate "Show more" expand (lines 225-260 in PostCard)**
+- [ ] **Step 1: Wrap empty states with AnimatedVisibility (F5)**
-Replace the truncated text display with `AnimatedContent`:
+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(
@@ -550,72 +616,82 @@ AnimatedContent(
label = "expandText"
) { isExpanded ->
Text(
- text = if (isExpanded) post.text else post.text.take(280) + "...",
- style = MaterialTheme.typography.bodyMedium
+ 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
)
}
```
-- [ ] **Step 2: Animate empty states (lines 90-142)**
+Note: Field is `post.textContent` (not `post.text`).
-Wrap both empty state blocks with `AnimatedVisibility`:
+- [ ] **Step 2: Animate queue status chip (F6)**
-```kotlin
-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:
+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 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
+val chipAlpha by infiniteTransition.animateFloat(
+ initialValue = if (isUploading) 0.6f else 1f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(600),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "uploadPulse"
+)
```
-- [ ] **Step 4: Verify it compiles**
+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`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 5: Commit**
```bash
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"
+git commit -m "feat: add expand, queue chip, and account switcher animations"
```
---
-## Task 8: Composer Screen — All 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 animation imports**
+- [ ] **Step 1: Add imports**
```kotlin
import androidx.compose.animation.*
@@ -624,29 +700,32 @@ import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.PulsingPlaceholder
```
-- [ ] **Step 2: Animate image preview (lines 118-140)**
+- [ ] **Step 2: Animate image grid (C1)**
-Wrap the image preview section with `AnimatedVisibility`:
+Wrap `ImageGridPreview` block (line 277) with `AnimatedVisibility`:
```kotlin
AnimatedVisibility(
- visible = state.imageUri != null,
- enter = scaleIn(
- initialScale = 0f,
- animationSpec = SwooshMotion.bouncy()
- ) + fadeIn(SwooshMotion.quick()),
+ visible = state.imageUris.isNotEmpty(),
+ enter = scaleIn(initialScale = 0f, animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
- // existing Box with AsyncImage + close button
+ Column {
+ ImageGridPreview(
+ imageUris = state.imageUris,
+ onRemoveImage = viewModel::removeImage,
+ onAddMore = { multiImagePickerLauncher.launch("image/*") }
+ )
+ // alt text button and badge remain inside
+ }
}
```
-- [ ] **Step 3: Animate link preview (lines 143-188)**
+- [ ] **Step 3: Replace LinearProgressIndicator with PulsingPlaceholder (C2)**
-Replace `LinearProgressIndicator` (lines 143-146) with `PulsingPlaceholder` when loading, and wrap the link preview card with `AnimatedVisibility`:
+Replace line 317 (`LinearProgressIndicator`) with:
```kotlin
-// Loading state
AnimatedVisibility(
visible = state.isLoadingLink,
enter = fadeIn(SwooshMotion.quick()),
@@ -654,23 +733,23 @@ AnimatedVisibility(
) {
PulsingPlaceholder(height = 80.dp)
}
+```
-// Loaded link preview
+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()),
+ enter = slideInVertically(initialOffsetY = { it / 2 }, animationSpec = SwooshMotion.gentle()) + fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
- // existing OutlinedCard with link preview
+ // existing OutlinedCard
}
```
-- [ ] **Step 4: Animate schedule chip (lines 191-206)**
+- [ ] **Step 4: Animate schedule chip (C3)**
-Wrap schedule chip with `AnimatedVisibility`:
+Wrap schedule chip block (line 363) with:
```kotlin
AnimatedVisibility(
@@ -678,102 +757,165 @@ AnimatedVisibility(
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
) {
- // existing AssistChip
+ AssistChip(...)
}
```
-- [ ] **Step 5: Animate character counter color (lines 92-99)**
+- [ ] **Step 5: Animate error text (C7)**
-Replace static color with animated color:
-
-```kotlin
-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:
-
-```kotlin
-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`:
+Wrap error text (line 407) with:
```kotlin
AnimatedVisibility(
visible = state.error != null,
- enter = slideInHorizontally(
- initialOffsetX = { -it / 4 },
- animationSpec = SwooshMotion.snappy()
- ) + fadeIn(SwooshMotion.quick()),
+ enter = slideInHorizontally(initialOffsetX = { -it / 4 }, animationSpec = SwooshMotion.snappy()) + fadeIn(SwooshMotion.quick()),
exit = fadeOut(SwooshMotion.quick())
) {
- // existing error Text
+ Text(text = state.error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
```
-- [ ] **Step 8: Verify it compiles**
+- [ ] **Step 6: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 9: Commit**
```bash
git add app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
-git commit -m "feat: add all composer screen micro-animations"
+git commit -m "feat: add image, link, schedule, and error animations in composer"
```
---
-## Task 9: Detail Screen — Content Reveal & Delete Dialog
+## 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 animation imports**
+- [ ] **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 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**
-At the top of the `DetailScreen` composable (inside the function body, around line 40), add:
+Inside `DetailScreen`, after the existing state declarations (around line 62):
```kotlin
-// Sequential content reveal
-val revealSections = 4 // status, text, image, metadata
-val sectionVisible = remember {
- List(revealSections) { mutableStateOf(false) }
-}
+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)
@@ -782,97 +924,78 @@ LaunchedEffect(Unit) {
}
```
-- [ ] **Step 3: Wrap each content section with AnimatedVisibility**
+- [ ] **Step 3: Wrap content sections with AnimatedVisibility**
-Wrap sections in the Column (lines 59-145):
+In the Column (starting line 197):
-Section 0 — Status + time row (lines 59-69):
+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(/* existing status + time */) { ... }
-}
+ enter = fadeIn(SwooshMotion.quick()) + scaleIn(initialScale = 0.8f, animationSpec = SwooshMotion.bouncy())
+) { Row(...) { StatusBadge(post); Text(...) } }
```
-Section 1 — Text content (lines 74-77):
+Section 1 — Text content (line 214):
```kotlin
AnimatedVisibility(
visible = sectionVisible[1].value,
- enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
- initialOffsetY = { 20 },
- animationSpec = SwooshMotion.gentle()
- )
-) {
- Text(/* existing */)
-}
+ enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
+) { Text(text = post.textContent, ...) }
```
-Section 2 — Image (lines 80-90):
+Section 2 — Tags (line 220):
```kotlin
AnimatedVisibility(
- visible = sectionVisible[2].value && post.imageUrl != null,
- enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
- initialOffsetY = { 20 },
- animationSpec = SwooshMotion.gentle()
- )
-) {
- AsyncImage(/* existing */)
-}
+ visible = sectionVisible[2].value && post.tags.isNotEmpty(),
+ enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
+) { FlowRow(...) { ... } }
```
-Section 3 — Metadata (lines 134-145):
+Section 3 — Image gallery (line 243):
```kotlin
AnimatedVisibility(
- visible = sectionVisible[3].value,
- enter = slideInVertically(
- initialOffsetY = { it / 4 },
- animationSpec = SwooshMotion.gentle()
- ) + fadeIn(SwooshMotion.quick())
-) {
- Column(/* existing metadata */) { ... }
-}
+ visible = sectionVisible[3].value && allImages.isNotEmpty(),
+ enter = fadeIn(SwooshMotion.quick()) + slideInVertically(initialOffsetY = { 20 }, animationSpec = SwooshMotion.gentle())
+) { Column { DetailImageGallery(...); /* alt text */ } }
```
-- [ ] **Step 4: Replace delete AlertDialog with AnimatedDialog**
+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(...) { ... } }
+```
-Replace the `AlertDialog` (lines 148-168) with:
+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 }) {
- // Same AlertDialog content but wrapped in a Card/Surface for the animated wrapper
- Card(
- modifier = Modifier.padding(horizontal = 24.dp)
- ) {
+ 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")
- }
+ 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")
- }
+ onClick = { showDeleteDialog = false; onDelete(post) },
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) { Text("Delete") }
}
}
}
@@ -880,117 +1003,109 @@ if (showDeleteDialog) {
}
```
-- [ ] **Step 5: Verify it compiles**
+- [ ] **Step 5: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 6: Commit**
```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"
+git commit -m "feat: add content reveal and animated delete dialog in detail"
```
---
-## Task 10: Settings Screen — Saved Feedback & Disconnect Dialog
+## Task 12: Settings Screen (S1, S2, S3)
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
-- [ ] **Step 1: Add animation imports**
+- [ ] **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 "Settings saved" text (lines 84-91)**
+- [ ] **Step 2: Animate account card entrance (S1)**
-Replace the static conditional with `AnimatedVisibility`:
+Wrap account Card (line 78) with:
```kotlin
+var cardVisible by remember { mutableStateOf(false) }
+LaunchedEffect(Unit) { cardVisible = true }
AnimatedVisibility(
- visible = saved,
- enter = scaleIn(
- initialScale = 0f,
- animationSpec = SwooshMotion.bouncy()
- ) + fadeIn(SwooshMotion.quick()),
- exit = fadeOut(SwooshMotion.quick())
+ visible = cardVisible && activeAccount != null,
+ enter = fadeIn(SwooshMotion.quick()) + scaleIn(initialScale = 0.95f, animationSpec = SwooshMotion.quick())
) {
- Text(
- "Settings saved",
- color = MaterialTheme.colorScheme.primary,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(top = 8.dp)
- )
+ Card(...) { ... }
}
```
-Add auto-hide after 2 seconds:
+- [ ] **Step 3: Add disconnect confirmation dialog (S2)**
-```kotlin
-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:
+Add dialog state:
```kotlin
var showDisconnectDialog by remember { mutableStateOf(false) }
+var showDisconnectAllDialog by remember { mutableStateOf(false) }
```
-Change the disconnect button to show dialog instead of directly disconnecting:
+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
-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)
+ Text("Disconnect Account?", style = MaterialTheme.typography.headlineSmall)
Spacer(modifier = Modifier.height(16.dp))
- Text("This will clear your Ghost credentials. You'll need to set up again.")
+ 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")
- }
+ 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()
+ 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")
- }
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) { Text("Disconnect All") }
}
}
}
@@ -998,42 +1113,147 @@ if (showDisconnectDialog) {
}
```
-- [ ] **Step 4: Verify it compiles**
+- [ ] **Step 4: Verify and commit**
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
-Expected: BUILD SUCCESSFUL
-
-- [ ] **Step 5: Commit**
```bash
git add app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
-git commit -m "feat: add settings saved animation and disconnect dialog"
+git commit -m "feat: add account card animation and disconnect dialogs"
```
---
-## Task 11: Run All Tests & Final Verification
+## 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. Animations don't affect business logic.
+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: 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**
+- [ ] **Step 3: Commit cleanup if needed**
```bash
git add -A
-git commit -m "chore: clean up lint and unused imports after animation additions"
+git commit -m "chore: clean up imports after animation additions"
```
diff --git a/docs/superpowers/specs/2026-03-19-micro-animations-design.md b/docs/superpowers/specs/2026-03-19-micro-animations-design.md
index b8f6a63..53d0ef7 100644
--- a/docs/superpowers/specs/2026-03-19-micro-animations-design.md
+++ b/docs/superpowers/specs/2026-03-19-micro-animations-design.md
@@ -1,4 +1,4 @@
-# Micro-Animations Design — Swoosh
+# Micro-Animations Design — Swoosh (v2)
**Date:** 2026-03-19
**Status:** Approved
@@ -6,235 +6,219 @@
## Overview
-Add 19 micro-animations + 5 navigation transitions across all screens to make the app feel alive. Create a shared `SwooshMotion` object with predefined animation specs ensuring consistent character throughout the app.
+Add 32 micro-animations + 8 navigation transitions across all screens to make the app feel alive. Create a shared `SwooshMotion` object with predefined animation specs ensuring consistent character throughout the app.
+
+The app now includes: multi-account support, search with filters, swipe-to-dismiss, multi-image galleries, writing statistics dashboard, HTML preview, and hashtag support.
## Shared Animation Specs — SwooshMotion
-Central object in a new file `ui/animation/SwooshMotion.kt` providing reusable animation specifications:
+Central object in `ui/animation/SwooshMotion.kt`:
| Name | Type | Parameters | Use case |
|------|------|-----------|----------|
| `Bouncy` | Spring | dampingRatio=0.65, stiffness=400 | FAB entrance, chips, badges |
-| `BouncyQuick` | Spring | dampingRatio=0.7, stiffness=1000 | Press feedback (settles in ~150ms) |
+| `BouncyQuick` | Spring | dampingRatio=0.7, stiffness=1000 | Press feedback (~150ms settle) |
| `Snappy` | Spring | dampingRatio=0.7, stiffness=800 | Expand/collapse, dialogs |
| `Gentle` | Spring | dampingRatio=0.8, stiffness=300 | Cards, content reveal |
| `Quick` | Tween | 200ms, FastOutSlowInEasing | Fade, color transitions |
-| `StaggerDelay` | Offset | 50ms per item | List item entrances |
+| `StaggerDelayMs` | Long | 50 | List item entrances |
+| `RevealDelayMs` | Long | 80 | Content reveal sequences |
### Reduced Motion Support
-`SwooshMotion` exposes `val isReducedMotion: Boolean` that reads the system accessibility setting (`Settings.Global.ANIMATOR_DURATION_SCALE`). When `true`, all spring specs fall back to `snap()` (instant) and all tweens use 0ms duration. Every animation in this spec must route through `SwooshMotion` so the reduced-motion check is centralized.
+`SwooshMotion` checks `Settings.Global.ANIMATOR_DURATION_SCALE`. When reduced motion is enabled, all springs fall back to `snap()` and all tweens use 0ms duration.
### Stagger Pattern
-For staggered entrance animations (#3, #13, #14), each item uses a local `MutableState` controlled by a `LaunchedEffect`:
-
-```kotlin
-val visible = remember { mutableStateOf(false) }
-LaunchedEffect(Unit) {
- delay(SwooshMotion.StaggerDelay * index)
- visible.value = true
-}
-AnimatedVisibility(visible = visible.value, ...) { ... }
-```
-
-`AnimatedVisibility` has no built-in delay parameter — this pattern is the canonical approach.
+Each item uses a `MutableState` toggled via `LaunchedEffect(Unit) { delay(StaggerDelayMs * index); visible = true }`. `AnimatedVisibility` has no built-in delay.
### Already-Animated Tracking (LazyColumn)
-For staggered items inside `LazyColumn` (#3), items re-enter composition on scroll. To prevent replaying the entrance animation, hoist a `mutableStateSetOf()` of already-animated item keys in `FeedScreen`:
+Hoist a `mutableStateMapOf()` of already-animated item keys in `FeedScreen`. Check `key !in animatedKeys` before triggering entrance. Only first ~8 visible items on initial load animate. Items from infinite scroll appear instantly.
-```kotlin
-val animatedKeys = remember { mutableStateSetOf() }
-// Inside each item:
-val shouldAnimate = post.id !in animatedKeys
-LaunchedEffect(Unit) {
- if (shouldAnimate) {
- delay(SwooshMotion.StaggerDelay * index)
- animatedKeys.add(post.id)
- }
- visible.value = true // instant if already animated
-}
-```
+---
-Only animate the first batch visible on initial load (cap at ~8 items). Items appended via infinite scroll appear without stagger.
+## Feed Screen — 12 animations
-## Feed Screen — 7 animations
+### F1. FAB entrance (SPRING/Bouncy)
+Scale 0→1 with overshoot on screen open. Hidden during search mode.
+- `animateFloatAsState` with `Bouncy` spring on scale modifier
+- `LaunchedEffect(Unit)` sets target to 1f
+- FAB already conditionally hidden when `isSearchActive` (line 249)
-### 1. FAB entrance (SPRING/Bouncy)
-When the feed screen opens, the FAB scales from 0 to 1 with `Bouncy` spring. One visible overshoot — the FAB "pops" onto screen.
-- Compose API: `animateFloatAsState` with `Bouncy` spring on scale modifier
-- Trigger: `LaunchedEffect(Unit)` sets target to 1f
+### F2. FAB press (SPRING/BouncyQuick)
+Shrinks to 85%, snaps back. Settles before navigation fires.
+- `Modifier.pointerInput` press → `animateFloatAsState` 0.85f→1f
+- Navigation fires immediately; animation interrupted by transition (fine)
-### 2. FAB press (SPRING/BouncyQuick)
-On tap, FAB shrinks to 85% and snaps back to 100%. Must settle in ~150ms so it completes before navigation fires.
-- Compose API: `Modifier.pointerInput` detecting press → `animateFloatAsState` scale 0.85f → 1f
-- Spring spec: `BouncyQuick` (dampingRatio=0.7, stiffness=1000)
-- Navigation fires immediately on tap — the spring animation is interrupted by the screen transition, which is fine
+### F3. Post cards staggered entrance (SLIDE/Gentle)
+Cascading slide-in from bottom, 50ms delay per item. "Waterfall" effect.
+- `AnimatedVisibility` per item in LazyColumn with `slideInVertically + fadeIn`
+- `mutableStateMapOf` tracking (see shared section)
+- Applies to both `SwipeablePostCard` and search-mode `PostCard`
+- Capped at first 8 items, infinite scroll items appear instantly
-### 3. Post cards staggered entrance (SLIDE/Gentle)
-Cards slide in from bottom with cascading delay — 50ms per item. "Waterfall" effect.
-- Compose API: `AnimatedVisibility` per item with `slideInVertically` + `fadeIn` using `Gentle` spring
-- Delay: Stagger pattern (see shared section). Capped at first ~8 visible items
-- Already-animated tracking via `animatedKeys` set (see shared section)
-- Items appended via infinite scroll appear without stagger (instant `visible = true`)
+### F4. "Show more" expand (SPRING/Snappy)
+`AnimatedContent(targetState = expanded)` with `fadeIn + expandVertically` / `fadeOut + shrinkVertically`. Replaces discrete text swap in PostCardContent.
-### 4. "Show more" expand (SPRING/Snappy)
-Card content transitions between truncated and full text with animated height change.
-- Compose API: `AnimatedContent(targetState = expanded)` with `fadeIn + expandVertically` / `fadeOut + shrinkVertically` as content transform
-- Height spring: `Snappy`
-- This replaces the current discrete text swap with a smooth animated transition
+### F5. Empty states (FADE/Quick + scale)
+All empty states (connection error, filter empty, search no results, normal empty) wrapped in `AnimatedVisibility` with `fadeIn + scaleIn(0.9f)`.
-### 5. Empty state (FADE/Quick + scale)
-"No posts yet" icon and text fade in with subtle scale from 0.9 to 1.0.
-- Compose API: `AnimatedVisibility` with `fadeIn + scaleIn(initialScale = 0.9f)`
+### F6. Queue status chip (BOUNCE/Bouncy)
+- UPLOADING: `rememberInfiniteTransition` pulse alpha 0.6→1.0
+- Status change: bounce scale + `animateColorAsState`
+- FAILED: shake via `Animatable` offset oscillation (-4dp→4dp→0dp, 3 cycles)
-### 6. Queue status chip (BOUNCE/Bouncy)
-During upload: chip pulses (infinite alpha animation 0.6→1.0). On status change (success/fail): bounce scale + color crossfade. On FAILED: shake animation (horizontal offset oscillation) to draw attention.
-- Compose API: `rememberInfiniteTransition` for pulse; `animateColorAsState` + scale bounce on status change
-- Shake on FAILED: `Animatable` with `animateTo` offset -4dp → 4dp → 0dp (3 oscillations)
-- Color transition: `Quick` tween
-- Note: Each chip in UPLOADING state creates its own `rememberInfiniteTransition`. If many posts queue simultaneously, this is fine for ~5 items but could cause jank beyond that (unlikely in normal use)
+### F7. Snackbar (SLIDE/Snappy)
+Both error snackbar (line 578) and pin confirmation snackbar (line 562) — `AnimatedVisibility` with `slideInVertically` + overshoot.
-### 7. Snackbar error (SLIDE/Snappy)
-Slides in from bottom with slight overshoot. Fades out on timeout.
-- Compose API: `AnimatedVisibility` with `slideInVertically(initialOffsetY = { it })` + `Snappy` spring
-- Exit: `fadeOut` with `Quick` tween
+### F8. Search bar (SLIDE/Quick) — NEW
+`SearchTopBar` slides in from top with fade when search activates. Reverse on close.
+- `AnimatedVisibility` wrapping the `if (isSearchActive)` branch (line 157)
+- `slideInVertically(initialOffsetY = { -it }) + fadeIn` / reverse
-## Composer Screen — 7 animations
+### F9. Filter chips bar (FADE/Quick) — NEW
+`FilterChipsBar` fades in/out when toggling between search and normal mode.
+- Already has `animateColorAsState` for chip selection (line 715)
+- Add `AnimatedVisibility` wrapper for the `if (!isSearchActive)` block (line 263)
-### 8. Image preview (SCALE/Bouncy)
-After picking an image, the preview scales from 0 with bouncy spring. Close button fades in with scale.
-- Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring
-- Close button: `fadeIn + scaleIn(initialScale = 0.5f)` — no rotation (an "X" icon should feel stable and immediately tappable)
+### F10. Account switcher items (SLIDE/Gentle) — NEW
+Inside `AccountSwitcherBottomSheet` (line 954), account `ListItem`s get staggered slide-in.
+- ModalBottomSheet has built-in slide animation
+- Add stagger pattern to account items inside the sheet
-### 9. Link preview card (SLIDE/Gentle)
-After link loads, card slides up from below + fades in. While loading: pulsing placeholder card.
-- Compose API: `AnimatedVisibility` with `slideInVertically + fadeIn` using `Gentle` spring
-- Loading state: Replace current `LinearProgressIndicator` with a pulsing placeholder card using `rememberInfiniteTransition` on alpha (0.3→0.7). Simpler than a full shimmer gradient — just a fading placeholder shape
+### F11. Pinned section header (FADE/Quick) — NEW
+"📌 Pinned" header at line 790 — `AnimatedVisibility` with `fadeIn + slideInVertically` when pinned posts exist.
-### 10. Schedule chip (SPRING/Bouncy)
-After picking date, chip pops in with bouncy spring.
-- Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy`
+### F12. Account switch overlay (FADE/Quick) — NEW
+"Switching account..." overlay (line 288) — crossfade entrance with scale on the spinner.
-### 11. Publish button (BOUNCE/BouncyQuick)
-Subtle bounce on activation. During publishing: loading pulse. On success: checkmark icon scales in.
-- Compose API: Scale bounce on click via `animateFloatAsState` with `BouncyQuick`; `rememberInfiniteTransition` for pulse; `AnimatedContent` for icon swap with `scaleIn`
+---
-### 12. Character counter color (FADE/Quick)
-Smooth color crossfade when exceeding 280 characters — neutral → red.
-- Compose API: `animateColorAsState` with `Quick` tween
-- Trigger: `text.length > 280`
+## Composer Screen — 9 animations
-### 13. Action buttons staggered entrance (SCALE/Gentle)
-Draft, Schedule, Publish buttons — cascading scale-in with 50ms delay each.
-- Compose API: Stagger pattern (see shared section) with `AnimatedVisibility` + `scaleIn + fadeIn`
+### C1. Image grid preview (SCALE/Bouncy)
+`ImageGridPreview` (line 556) — each image thumbnail scales in with bounce when added. Scale-out on removal.
+- `AnimatedVisibility` per grid item with `scaleIn` using `Bouncy`
-### NEW: 13b. Error text (SLIDE/Snappy)
-Composer error text slides in from left + fades in, consistent with snackbar style.
-- Compose API: `AnimatedVisibility` with `slideInHorizontally + fadeIn` using `Snappy` spring
+### C2. Link preview card (SLIDE/Gentle)
+After link loads, card slides up + fades in. Loading: `PulsingPlaceholder` replaces `LinearProgressIndicator` (line 317).
-## Detail Screen — 4 animations
+### C3. Schedule chip (SPRING/Bouncy)
+Chip at line 365 pops in with `scaleIn` using `Bouncy` when `state.scheduledAt != null`.
-### 14. Content reveal (FADE/Gentle)
-Elements appear sequentially: status badge → text → image → metadata. 80ms delay each.
-- Compose API: Stagger pattern (see shared section) with `AnimatedVisibility` per section using `fadeIn + slideInVertically(initialOffsetY = { 20 })`
-- Delay: 80ms per element (4 elements total = 320ms cascade)
+### C4. Publish button (BOUNCE/BouncyQuick)
+Button at line 421 — bounce on click, loading pulse during submit, `AnimatedContent` icon swap to checkmark on success.
-### 15. Status badge entrance (SPRING/Bouncy)
-Badge scales from 0 with bounce — first visible element, draws attention. Part of the content reveal sequence (index 0, no delay).
-- Compose API: `animateFloatAsState` scale with `Bouncy` spring
+### C5. Character counter color (FADE/Quick)
+Three-tier `animateColorAsState`: onSurfaceVariant → tertiary (>280) → error (>500). Matches current logic at line 211.
-### 16. Delete confirmation dialog (SCALE/Snappy)
-Dialog scales from center (0.8→1.0) with spring + backdrop fades in.
-- Compose API: Custom `AnimatedDialog` wrapper with `scaleIn(initialScale = 0.8f)` + `fadeIn` backdrop
-- Spring spec: `Snappy`
+### C6. Action buttons staggered (SCALE/Gentle)
+Publish button (line 421) + Row of [Draft, Schedule] (line 433). Two-step stagger: button first, then row.
-### 17. Metadata section (SLIDE/Gentle)
-Bottom metadata slides up — last in the content reveal sequence (index 3, 240ms delay).
-- Compose API: Part of stagger pattern in #14
+### C7. Error text (SLIDE/Snappy)
+Error at line 407 — `AnimatedVisibility` with `slideInHorizontally + fadeIn`.
+
+### C8. Hashtag chips (SPRING/Bouncy) — NEW
+Extracted tags FlowRow (line 225) — staggered `scaleIn` per chip as tags appear/change.
+
+### C9. Edit/Preview crossfade (FADE/Quick) — NEW
+`Crossfade(targetState = state.isPreviewMode)` wrapping the `if (state.isPreviewMode)` branch (line 167). Smooth transition between edit and preview modes.
+
+---
+
+## Detail Screen — 5 animations
+
+### D1. Content reveal (FADE/Gentle)
+Sequential: status row → text → tags → image gallery → link preview → PostStatsSection. 80ms delay each.
+- Stagger pattern with `AnimatedVisibility` per section
+- PostStatsSection (line 307) already has internal `AnimatedVisibility` for expand/collapse
+
+### D2. Status badge entrance (SPRING/Bouncy)
+First in reveal sequence (index 0). Scale-in with bounce.
+
+### D3. Delete dialog (SCALE/Snappy)
+Replace `AlertDialog` at line 312 with `AnimatedDialog` wrapper.
+
+### D4. PostStatsSection (SLIDE/Gentle)
+Last in reveal sequence. Slides up. Internal expand/collapse already animated.
+
+### D5. Pin toggle feedback (BOUNCE/Bouncy) — NEW
+Pin icon in TopAppBar (line 78 area) — bouncy scale pulse when toggling featured state. `animateFloatAsState` scale 1→1.2→1.
+
+---
## Settings Screen — 3 animations
-### 18. "Settings saved" feedback (SPRING/Bouncy)
-Existing "Settings saved" text (currently static conditional) gets animated: pops in with bounce (scale 0→1). After 2 seconds, fades out.
-- Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring; `LaunchedEffect` → `delay(2000)` → hide
-- Exit: `fadeOut` with `Quick` tween
-- Text and color remain as-is: "Settings saved" with `MaterialTheme.colorScheme.primary`
+### S1. Account card (FADE/Quick) — NEW
+Current account Card (line 78) — fade-in with subtle scale on screen entry.
-### 19. Disconnect confirmation dialog (NEW behavior + FADE+SCALE/Snappy)
-**New behavior:** Add a confirmation dialog before disconnect (currently the button directly clears credentials). The dialog uses the same `AnimatedDialog` wrapper as #16. "Disconnect" button has subtle red pulse.
-- Compose API: Same `AnimatedDialog` wrapper as #16
-- Red pulse: `rememberInfiniteTransition` on alpha of error color
+### S2. Disconnect confirmation dialog (SCALE/Snappy)
+**New behavior:** Add confirmation dialog before both "Disconnect Current Account" (line 139) and "Disconnect All Accounts" (line 165). Uses `AnimatedDialog` wrapper.
-### NEW: 19b. Disconnect dialog behavior
-The `OutlinedButton` currently calls `credentials.clear()` + `onLogout()` directly. After this change, it shows a confirmation dialog first. Confirm action triggers the existing clear + logout flow.
+### S3. Theme chip selection (SPRING/Bouncy) — NEW
+In `ThemeModeSelector` (line 184), selected chip gets a brief bouncy scale pulse on selection change.
-## Navigation Transitions — 5
+---
-Each `composable()` call in `NavGraph.kt` receives `enterTransition`, `exitTransition`, `popEnterTransition`, and `popExitTransition` lambdas. Back navigation uses `popEnterTransition`/`popExitTransition` (the reverse of the enter animation) — these are distinct parameters, not automatic.
+## Stats Screen — 3 animations (all NEW)
-### Feed → Composer
-Slide up from bottom + fade.
-- `enterTransition = slideInVertically(initialOffsetY = { it }) + fadeIn()`
-- `exitTransition = fadeOut()`
-- `popEnterTransition = fadeIn()`
-- `popExitTransition = slideOutVertically(targetOffsetY = { it }) + fadeOut()`
+### ST1. Stats cards staggered entrance (SCALE/Bouncy)
+Four `StatsCard` composables (lines 68-98) — staggered scale-in with bounce. 50ms delay per card.
-### Feed → Detail
-Slide in from right + fade.
-- `enterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn()`
-- `exitTransition = fadeOut()`
-- `popEnterTransition = fadeIn()`
-- `popExitTransition = slideOutHorizontally(targetOffsetX = { it }) + fadeOut()`
+### ST2. Writing stats reveal (SLIDE/Gentle)
+`OutlinedCard` at line 109 — slide-up with fade after cards complete. Internal `WritingStatRow`s cascade.
-### Detail → Composer (via Edit)
-Same as Feed → Composer (slide up from bottom). The Composer always enters with the same transition regardless of source.
+### ST3. Number count-up (FADE/Quick)
+Stat values animate from 0 to target via `animateIntAsState`. Subtle but adds life to the dashboard.
-### Feed → Settings
-Standard slide from right.
-- `enterTransition = slideInHorizontally(initialOffsetX = { it })`
-- `exitTransition = fadeOut()`
-- `popEnterTransition = fadeIn()`
-- `popExitTransition = slideOutHorizontally(targetOffsetX = { it })`
+---
-### Setup → Feed
-Crossfade — smooth transition from animated setup background to feed.
-- `enterTransition = fadeIn(tween(500))`
-- `exitTransition = fadeOut(tween(500))`
+## Navigation Transitions — 8 routes
+
+Each `composable()` receives `enterTransition`, `exitTransition`, `popEnterTransition`, `popExitTransition`.
+
+| Route | Enter | Pop Exit |
+|-------|-------|----------|
+| Setup | fadeIn(500ms) | — (inclusive popUpTo) |
+| Feed | fadeIn(300ms) | fadeOut(200ms) |
+| Composer | slideInVertically + fadeIn | slideOutVertically + fadeOut |
+| Detail | slideInHorizontally + fadeIn | slideOutHorizontally + fadeOut |
+| Settings | slideInHorizontally | slideOutHorizontally |
+| Stats | slideInHorizontally | slideOutHorizontally |
+| Preview | slideInVertically + fadeIn | slideOutVertically + fadeOut |
+| AddAccount | slideInVertically + fadeIn | slideOutVertically + fadeOut |
+
+---
## File Structure
```
ui/
├── animation/
-│ └── SwooshMotion.kt # Shared animation specs object + reduced motion check
+│ └── SwooshMotion.kt # Shared specs + reduced motion
├── components/
-│ ├── AnimatedDialog.kt # Reusable animated dialog wrapper (used by #16, #19)
-│ └── PulsingPlaceholder.kt # Pulsing loading placeholder (used by #9)
+│ ├── AnimatedDialog.kt # Scale-in dialog wrapper
+│ └── PulsingPlaceholder.kt # Pulsing loading placeholder
├── feed/
-│ └── FeedScreen.kt # Modified: #1-#7
+│ └── FeedScreen.kt # F1-F12
├── composer/
-│ └── ComposerScreen.kt # Modified: #8-#13, #13b
+│ └── ComposerScreen.kt # C1-C9
├── detail/
-│ └── DetailScreen.kt # Modified: #14-#17
+│ └── DetailScreen.kt # D1-D5
├── settings/
-│ └── SettingsScreen.kt # Modified: #18-#19, #19b (new disconnect dialog)
+│ └── SettingsScreen.kt # S1-S3
+├── stats/
+│ └── StatsScreen.kt # ST1-ST3
└── navigation/
- └── NavGraph.kt # Modified: navigation transitions with enter/exit/popEnter/popExit
+ └── NavGraph.kt # 8 route transitions
```
-## New files: 3
-- `ui/animation/SwooshMotion.kt`
-- `ui/components/AnimatedDialog.kt`
-- `ui/components/PulsingPlaceholder.kt`
-
-## Modified files: 5
-- `FeedScreen.kt`, `ComposerScreen.kt`, `DetailScreen.kt`, `SettingsScreen.kt`, `NavGraph.kt`
+**New files:** 3 (`SwooshMotion.kt`, `AnimatedDialog.kt`, `PulsingPlaceholder.kt`)
+**Modified files:** 6 (`FeedScreen.kt`, `ComposerScreen.kt`, `DetailScreen.kt`, `SettingsScreen.kt`, `StatsScreen.kt`, `NavGraph.kt`)
## Testing Strategy
-- Existing unit tests should pass unchanged (animations don't affect business logic)
-- Manual verification: each animation visually correct on emulator
-- Compose Preview for individual animated components where feasible
-- Test with system "Remove animations" setting enabled to verify reduced-motion fallback
+- Existing unit tests pass unchanged (animations don't affect business logic)
+- Manual verification on emulator per animation
+- Test with "Remove animations" accessibility setting for reduced-motion fallback