From cfaba04039955f1aa592ac252703b64459d4b75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 14:02:48 +0100 Subject: [PATCH] feat: bold expressive theme with custom green palette, elevated cards, and high-contrast swipe actions --- .../46859-1773916456/.server-stopped | 1 + .../brainstorm/46859-1773916456/.server.log | 4 + .../brainstorm/46859-1773916456/.server.pid | 1 + .../46859-1773916456/animation-map-v3.html | 249 ++++ .../46859-1773916456/waiting-2.html | 3 + .../swoosh/microblog/ui/feed/FeedScreen.kt | 25 +- .../com/swoosh/microblog/ui/theme/Color.kt | 59 + .../com/swoosh/microblog/ui/theme/Theme.kt | 136 ++- .../plans/2026-03-19-micro-animations.md | 1044 ++++++++++------- .../2026-03-19-micro-animations-design.md | 308 +++-- 10 files changed, 1231 insertions(+), 599 deletions(-) create mode 100644 .superpowers/brainstorm/46859-1773916456/.server-stopped create mode 100644 .superpowers/brainstorm/46859-1773916456/.server.log create mode 100644 .superpowers/brainstorm/46859-1773916456/.server.pid create mode 100644 .superpowers/brainstorm/46859-1773916456/animation-map-v3.html create mode 100644 .superpowers/brainstorm/46859-1773916456/waiting-2.html create mode 100644 app/src/main/java/com/swoosh/microblog/ui/theme/Color.kt 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

+ + + + + + + + + + +
+ + +
+ 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