mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
1039 lines
31 KiB
Markdown
1039 lines
31 KiB
Markdown
# Micro-Animations Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add 19 micro-animations + 5 navigation transitions to make Swoosh feel alive with expressive, bouncy character.
|
|
|
|
**Architecture:** Centralized `SwooshMotion` object provides shared spring/tween specs. Each screen gets targeted animation modifications. A reusable `AnimatedDialog` and `PulsingPlaceholder` component are shared across screens. Navigation transitions are configured per-route in `NavGraph.kt`.
|
|
|
|
**Tech Stack:** Jetpack Compose Animation APIs (`animateFloatAsState`, `AnimatedVisibility`, `AnimatedContent`, `rememberInfiniteTransition`, `Animatable`), Spring physics (`spring()`), Navigation Compose transitions.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-03-19-micro-animations-design.md`
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
### New Files
|
|
| File | Responsibility |
|
|
|------|---------------|
|
|
| `ui/animation/SwooshMotion.kt` | Shared animation specs (Bouncy, BouncyQuick, Snappy, Gentle, Quick) + reduced motion check |
|
|
| `ui/components/AnimatedDialog.kt` | Reusable scale-in dialog wrapper with backdrop fade |
|
|
| `ui/components/PulsingPlaceholder.kt` | Pulsing alpha placeholder for loading states |
|
|
|
|
### Modified Files
|
|
| File | Changes |
|
|
|------|---------|
|
|
| `ui/feed/FeedScreen.kt` | FAB animations, staggered cards, expand animation, empty state, queue chip, snackbar |
|
|
| `ui/composer/ComposerScreen.kt` | Image preview, link preview, schedule chip, publish button, char counter, action buttons, error text |
|
|
| `ui/detail/DetailScreen.kt` | Content reveal sequence, status badge bounce, animated delete dialog, metadata slide |
|
|
| `ui/settings/SettingsScreen.kt` | "Settings saved" animation, disconnect confirmation dialog |
|
|
| `ui/navigation/NavGraph.kt` | Per-route enter/exit/popEnter/popExit transitions |
|
|
|
|
---
|
|
|
|
## Task 1: SwooshMotion — Shared Animation Specs
|
|
|
|
**Files:**
|
|
- Create: `app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt`
|
|
|
|
- [ ] **Step 1: Create SwooshMotion object**
|
|
|
|
```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 {
|
|
|
|
// Expressive bounce — FAB entrance, chips, badges. One visible overshoot.
|
|
fun <T> bouncy(): FiniteAnimationSpec<T> = spring(
|
|
dampingRatio = 0.65f,
|
|
stiffness = 400f
|
|
)
|
|
|
|
// Fast snap-back — press feedback, button taps. Settles in ~150ms.
|
|
fun <T> bouncyQuick(): FiniteAnimationSpec<T> = spring(
|
|
dampingRatio = 0.7f,
|
|
stiffness = 1000f
|
|
)
|
|
|
|
// Controlled spring — expand/collapse, dialogs.
|
|
fun <T> snappy(): FiniteAnimationSpec<T> = spring(
|
|
dampingRatio = 0.7f,
|
|
stiffness = 800f
|
|
)
|
|
|
|
// Soft entrance — cards, content reveal.
|
|
fun <T> gentle(): FiniteAnimationSpec<T> = spring(
|
|
dampingRatio = 0.8f,
|
|
stiffness = 300f
|
|
)
|
|
|
|
// Quick tween — fade, color transitions.
|
|
fun <T> quick(): FiniteAnimationSpec<T> = tween(
|
|
durationMillis = 200,
|
|
easing = FastOutSlowInEasing
|
|
)
|
|
|
|
// Stagger delay per item in cascading animations.
|
|
const val StaggerDelayMs = 50L
|
|
|
|
// Content reveal stagger (Detail screen).
|
|
const val RevealDelayMs = 80L
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/src/main/java/com/swoosh/microblog/ui/animation/SwooshMotion.kt
|
|
git commit -m "feat: add SwooshMotion shared animation specs"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: AnimatedDialog — Reusable Dialog Wrapper
|
|
|
|
**Files:**
|
|
- Create: `app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt`
|
|
|
|
- [ ] **Step 1: Create AnimatedDialog composable**
|
|
|
|
```kotlin
|
|
package com.swoosh.microblog.ui.components
|
|
|
|
import androidx.compose.animation.AnimatedVisibility
|
|
import androidx.compose.animation.core.MutableTransitionState
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.scaleIn
|
|
import androidx.compose.animation.scaleOut
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
|
|
|
@Composable
|
|
fun AnimatedDialog(
|
|
onDismissRequest: () -> Unit,
|
|
content: @Composable () -> Unit
|
|
) {
|
|
val transitionState = remember {
|
|
MutableTransitionState(false).apply { targetState = true }
|
|
}
|
|
|
|
Dialog(
|
|
onDismissRequest = onDismissRequest,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
|
) {
|
|
Box(
|
|
modifier = Modifier.fillMaxSize(),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
// Backdrop
|
|
AnimatedVisibility(
|
|
visibleState = transitionState,
|
|
enter = fadeIn(animationSpec = SwooshMotion.quick()),
|
|
exit = fadeOut(animationSpec = SwooshMotion.quick())
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(Color.Black.copy(alpha = 0.4f))
|
|
.clickable(
|
|
indication = null,
|
|
interactionSource = remember { MutableInteractionSource() }
|
|
) { onDismissRequest() }
|
|
)
|
|
}
|
|
// Content
|
|
AnimatedVisibility(
|
|
visibleState = transitionState,
|
|
enter = scaleIn(
|
|
initialScale = 0.8f,
|
|
animationSpec = SwooshMotion.snappy()
|
|
) + fadeIn(animationSpec = SwooshMotion.quick()),
|
|
exit = scaleOut(
|
|
targetScale = 0.8f,
|
|
animationSpec = SwooshMotion.quick()
|
|
) + fadeOut(animationSpec = SwooshMotion.quick())
|
|
) {
|
|
content()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/src/main/java/com/swoosh/microblog/ui/components/AnimatedDialog.kt
|
|
git commit -m "feat: add AnimatedDialog reusable component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: PulsingPlaceholder — Loading Placeholder
|
|
|
|
**Files:**
|
|
- Create: `app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt`
|
|
|
|
- [ ] **Step 1: Create PulsingPlaceholder composable**
|
|
|
|
```kotlin
|
|
package com.swoosh.microblog.ui.components
|
|
|
|
import androidx.compose.animation.core.RepeatMode
|
|
import androidx.compose.animation.core.animateFloat
|
|
import androidx.compose.animation.core.infiniteRepeatable
|
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clip
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
@Composable
|
|
fun PulsingPlaceholder(
|
|
modifier: Modifier = Modifier,
|
|
height: Dp = 80.dp
|
|
) {
|
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
|
val alpha by infiniteTransition.animateFloat(
|
|
initialValue = 0.12f,
|
|
targetValue = 0.28f,
|
|
animationSpec = infiniteRepeatable(
|
|
animation = tween(800),
|
|
repeatMode = RepeatMode.Reverse
|
|
),
|
|
label = "pulseAlpha"
|
|
)
|
|
|
|
Box(
|
|
modifier = modifier
|
|
.fillMaxWidth()
|
|
.height(height)
|
|
.clip(RoundedCornerShape(12.dp))
|
|
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = alpha))
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add app/src/main/java/com/swoosh/microblog/ui/components/PulsingPlaceholder.kt
|
|
git commit -m "feat: add PulsingPlaceholder loading component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Navigation Transitions
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
|
|
|
|
- [ ] **Step 1: Add animation imports to NavGraph.kt**
|
|
|
|
At the top of `NavGraph.kt`, add these imports (after existing imports around line 15):
|
|
|
|
```kotlin
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.slideInHorizontally
|
|
import androidx.compose.animation.slideInVertically
|
|
import androidx.compose.animation.slideOutHorizontally
|
|
import androidx.compose.animation.slideOutVertically
|
|
import androidx.compose.animation.core.tween
|
|
```
|
|
|
|
- [ ] **Step 2: Add transitions to Setup route**
|
|
|
|
Modify the `composable(Routes.SETUP)` call (around line 37) to include transitions:
|
|
|
|
```kotlin
|
|
composable(
|
|
Routes.SETUP,
|
|
enterTransition = { fadeIn(tween(500)) },
|
|
exitTransition = { fadeOut(tween(500)) }
|
|
) {
|
|
```
|
|
|
|
- [ ] **Step 3: Add transitions to Feed route**
|
|
|
|
Modify the `composable(Routes.FEED)` call (around line 48) to include transitions:
|
|
|
|
```kotlin
|
|
composable(
|
|
Routes.FEED,
|
|
enterTransition = { fadeIn(tween(300)) },
|
|
exitTransition = { fadeOut(tween(200)) },
|
|
popEnterTransition = { fadeIn(tween(300)) },
|
|
popExitTransition = { fadeOut(tween(200)) }
|
|
) {
|
|
```
|
|
|
|
- [ ] **Step 4: Add transitions to Composer route**
|
|
|
|
Modify the `composable(Routes.COMPOSER)` call (around line 63) to include slide-up transitions:
|
|
|
|
```kotlin
|
|
composable(
|
|
Routes.COMPOSER,
|
|
enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() },
|
|
exitTransition = { fadeOut(tween(200)) },
|
|
popEnterTransition = { fadeIn(tween(300)) },
|
|
popExitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }
|
|
) {
|
|
```
|
|
|
|
- [ ] **Step 5: Add transitions to Detail route**
|
|
|
|
Modify the `composable(Routes.DETAIL)` call (around line 75) to include slide-from-right transitions:
|
|
|
|
```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**
|
|
|
|
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"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Feed Screen — FAB Animations
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt` (lines 77-81 for FAB)
|
|
|
|
- [ ] **Step 1: Add animation imports to FeedScreen.kt**
|
|
|
|
Add these imports at the top of the file:
|
|
|
|
```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
|
|
```
|
|
|
|
- [ ] **Step 2: Add FAB entrance + press animation state**
|
|
|
|
Before the `Scaffold` call (around line 52), add:
|
|
|
|
```kotlin
|
|
// FAB entrance animation
|
|
var fabVisible by remember { mutableStateOf(false) }
|
|
val fabScale by animateFloatAsState(
|
|
targetValue = if (fabVisible) 1f else 0f,
|
|
animationSpec = SwooshMotion.bouncy(),
|
|
label = "fabEntrance"
|
|
)
|
|
LaunchedEffect(Unit) { fabVisible = true }
|
|
|
|
// FAB press animation
|
|
var fabPressed by remember { mutableStateOf(false) }
|
|
val fabPressScale by animateFloatAsState(
|
|
targetValue = if (fabPressed) 0.85f else 1f,
|
|
animationSpec = SwooshMotion.bouncyQuick(),
|
|
label = "fabPress"
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Replace FAB with animated version**
|
|
|
|
Replace the existing FAB (lines 77-81) with:
|
|
|
|
```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")
|
|
}
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 4: Verify it compiles**
|
|
|
|
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 bouncy FAB entrance and press animations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Feed Screen — Staggered Card Entrance
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt` (lines 145-168 for LazyColumn)
|
|
|
|
- [ ] **Step 1: Add stagger tracking state**
|
|
|
|
Before the `Scaffold` call, alongside the FAB state, add:
|
|
|
|
```kotlin
|
|
// Staggered entrance tracking
|
|
val animatedKeys = remember { mutableStateSetOf<String>() }
|
|
var initialLoadComplete by remember { mutableStateOf(false) }
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap each LazyColumn item with staggered AnimatedVisibility**
|
|
|
|
Inside the `items()` block (around line 151), wrap the `PostCard` call. The item key is `post.ghostId ?: "local_${post.localId}"`. Wrap the card:
|
|
|
|
```kotlin
|
|
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
|
|
val itemKey = post.ghostId ?: "local_${post.localId}"
|
|
val shouldAnimate = !initialLoadComplete && itemKey !in animatedKeys
|
|
var visible by remember { mutableStateOf(!shouldAnimate) }
|
|
|
|
LaunchedEffect(itemKey) {
|
|
if (shouldAnimate) {
|
|
val index = animatedKeys.size
|
|
if (index < 8) {
|
|
delay(SwooshMotion.StaggerDelayMs * index)
|
|
}
|
|
animatedKeys.add(itemKey)
|
|
visible = true
|
|
}
|
|
}
|
|
|
|
AnimatedVisibility(
|
|
visible = visible,
|
|
enter = slideInVertically(
|
|
initialOffsetY = { it / 3 },
|
|
animationSpec = SwooshMotion.gentle()
|
|
) + fadeIn(animationSpec = SwooshMotion.quick())
|
|
) {
|
|
PostCard(
|
|
// ... existing PostCard parameters unchanged
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Mark initial load complete after first batch**
|
|
|
|
After the LazyColumn, add:
|
|
|
|
```kotlin
|
|
LaunchedEffect(state.posts) {
|
|
if (state.posts.isNotEmpty() && !initialLoadComplete) {
|
|
delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 300)
|
|
initialLoadComplete = true
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```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"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Feed Screen — Show More, Empty State, Queue Chip, Snackbar
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
|
|
|
|
- [ ] **Step 1: Animate "Show more" expand (lines 225-260 in PostCard)**
|
|
|
|
Replace the truncated text display with `AnimatedContent`:
|
|
|
|
```kotlin
|
|
AnimatedContent(
|
|
targetState = expanded,
|
|
transitionSpec = {
|
|
(fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()))
|
|
.togetherWith(fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy()))
|
|
},
|
|
label = "expandText"
|
|
) { isExpanded ->
|
|
Text(
|
|
text = if (isExpanded) post.text else post.text.take(280) + "...",
|
|
style = MaterialTheme.typography.bodyMedium
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Animate empty states (lines 90-142)**
|
|
|
|
Wrap both empty state blocks with `AnimatedVisibility`:
|
|
|
|
```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:
|
|
|
|
```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
|
|
```
|
|
|
|
- [ ] **Step 4: Verify it compiles**
|
|
|
|
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"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Composer Screen — All Animations
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
|
|
|
|
- [ ] **Step 1: Add animation imports**
|
|
|
|
```kotlin
|
|
import androidx.compose.animation.*
|
|
import androidx.compose.animation.core.*
|
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
|
import com.swoosh.microblog.ui.components.PulsingPlaceholder
|
|
```
|
|
|
|
- [ ] **Step 2: Animate image preview (lines 118-140)**
|
|
|
|
Wrap the image preview section with `AnimatedVisibility`:
|
|
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = state.imageUri != null,
|
|
enter = scaleIn(
|
|
initialScale = 0f,
|
|
animationSpec = SwooshMotion.bouncy()
|
|
) + fadeIn(SwooshMotion.quick()),
|
|
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
|
) {
|
|
// existing Box with AsyncImage + close button
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Animate link preview (lines 143-188)**
|
|
|
|
Replace `LinearProgressIndicator` (lines 143-146) with `PulsingPlaceholder` when loading, and wrap the link preview card with `AnimatedVisibility`:
|
|
|
|
```kotlin
|
|
// Loading state
|
|
AnimatedVisibility(
|
|
visible = state.isLoadingLink,
|
|
enter = fadeIn(SwooshMotion.quick()),
|
|
exit = fadeOut(SwooshMotion.quick())
|
|
) {
|
|
PulsingPlaceholder(height = 80.dp)
|
|
}
|
|
|
|
// Loaded link preview
|
|
AnimatedVisibility(
|
|
visible = state.linkPreview != null && !state.isLoadingLink,
|
|
enter = slideInVertically(
|
|
initialOffsetY = { it / 2 },
|
|
animationSpec = SwooshMotion.gentle()
|
|
) + fadeIn(SwooshMotion.quick()),
|
|
exit = fadeOut(SwooshMotion.quick())
|
|
) {
|
|
// existing OutlinedCard with link preview
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Animate schedule chip (lines 191-206)**
|
|
|
|
Wrap schedule chip with `AnimatedVisibility`:
|
|
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = state.scheduledAt != null,
|
|
enter = scaleIn(animationSpec = SwooshMotion.bouncy()) + fadeIn(SwooshMotion.quick()),
|
|
exit = scaleOut(animationSpec = SwooshMotion.quick()) + fadeOut(SwooshMotion.quick())
|
|
) {
|
|
// existing AssistChip
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Animate character counter color (lines 92-99)**
|
|
|
|
Replace static color with animated color:
|
|
|
|
```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`:
|
|
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = state.error != null,
|
|
enter = slideInHorizontally(
|
|
initialOffsetX = { -it / 4 },
|
|
animationSpec = SwooshMotion.snappy()
|
|
) + fadeIn(SwooshMotion.quick()),
|
|
exit = fadeOut(SwooshMotion.quick())
|
|
) {
|
|
// existing error Text
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
|
|
git commit -m "feat: add all composer screen micro-animations"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Detail Screen — Content Reveal & Delete Dialog
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt`
|
|
|
|
- [ ] **Step 1: Add animation imports**
|
|
|
|
```kotlin
|
|
import androidx.compose.animation.*
|
|
import androidx.compose.animation.core.*
|
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
|
import com.swoosh.microblog.ui.components.AnimatedDialog
|
|
```
|
|
|
|
- [ ] **Step 2: Add sequential reveal states**
|
|
|
|
At the top of the `DetailScreen` composable (inside the function body, around line 40), add:
|
|
|
|
```kotlin
|
|
// Sequential content reveal
|
|
val revealSections = 4 // status, text, image, metadata
|
|
val sectionVisible = remember {
|
|
List(revealSections) { mutableStateOf(false) }
|
|
}
|
|
LaunchedEffect(Unit) {
|
|
sectionVisible.forEachIndexed { index, state ->
|
|
delay(SwooshMotion.RevealDelayMs * index)
|
|
state.value = true
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Wrap each content section with AnimatedVisibility**
|
|
|
|
Wrap sections in the Column (lines 59-145):
|
|
|
|
Section 0 — Status + time row (lines 59-69):
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = sectionVisible[0].value,
|
|
enter = fadeIn(SwooshMotion.quick()) + scaleIn(
|
|
initialScale = 0.8f,
|
|
animationSpec = SwooshMotion.bouncy()
|
|
)
|
|
) {
|
|
Row(/* existing status + time */) { ... }
|
|
}
|
|
```
|
|
|
|
Section 1 — Text content (lines 74-77):
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = sectionVisible[1].value,
|
|
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
|
|
initialOffsetY = { 20 },
|
|
animationSpec = SwooshMotion.gentle()
|
|
)
|
|
) {
|
|
Text(/* existing */)
|
|
}
|
|
```
|
|
|
|
Section 2 — Image (lines 80-90):
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = sectionVisible[2].value && post.imageUrl != null,
|
|
enter = fadeIn(SwooshMotion.quick()) + slideInVertically(
|
|
initialOffsetY = { 20 },
|
|
animationSpec = SwooshMotion.gentle()
|
|
)
|
|
) {
|
|
AsyncImage(/* existing */)
|
|
}
|
|
```
|
|
|
|
Section 3 — Metadata (lines 134-145):
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = sectionVisible[3].value,
|
|
enter = slideInVertically(
|
|
initialOffsetY = { it / 4 },
|
|
animationSpec = SwooshMotion.gentle()
|
|
) + fadeIn(SwooshMotion.quick())
|
|
) {
|
|
Column(/* existing metadata */) { ... }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Replace delete AlertDialog with AnimatedDialog**
|
|
|
|
Replace the `AlertDialog` (lines 148-168) with:
|
|
|
|
```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)
|
|
) {
|
|
Column(modifier = Modifier.padding(24.dp)) {
|
|
Text("Delete Post", style = MaterialTheme.typography.headlineSmall)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Text("Are you sure you want to delete this post? This action cannot be undone.")
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.End
|
|
) {
|
|
TextButton(onClick = { showDeleteDialog = false }) {
|
|
Text("Cancel")
|
|
}
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Button(
|
|
onClick = {
|
|
showDeleteDialog = false
|
|
onDelete(post)
|
|
},
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.error
|
|
)
|
|
) {
|
|
Text("Delete")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```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"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Settings Screen — Saved Feedback & Disconnect Dialog
|
|
|
|
**Files:**
|
|
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
|
|
|
|
- [ ] **Step 1: Add animation imports**
|
|
|
|
```kotlin
|
|
import androidx.compose.animation.*
|
|
import androidx.compose.animation.core.*
|
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
|
import com.swoosh.microblog.ui.components.AnimatedDialog
|
|
```
|
|
|
|
- [ ] **Step 2: Animate "Settings saved" text (lines 84-91)**
|
|
|
|
Replace the static conditional with `AnimatedVisibility`:
|
|
|
|
```kotlin
|
|
AnimatedVisibility(
|
|
visible = saved,
|
|
enter = scaleIn(
|
|
initialScale = 0f,
|
|
animationSpec = SwooshMotion.bouncy()
|
|
) + fadeIn(SwooshMotion.quick()),
|
|
exit = fadeOut(SwooshMotion.quick())
|
|
) {
|
|
Text(
|
|
"Settings saved",
|
|
color = MaterialTheme.colorScheme.primary,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.padding(top = 8.dp)
|
|
)
|
|
}
|
|
```
|
|
|
|
Add auto-hide after 2 seconds:
|
|
|
|
```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:
|
|
|
|
```kotlin
|
|
var showDisconnectDialog by remember { mutableStateOf(false) }
|
|
```
|
|
|
|
Change the disconnect button to show dialog instead of directly disconnecting:
|
|
|
|
```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)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Text("This will clear your Ghost credentials. You'll need to set up again.")
|
|
Spacer(modifier = Modifier.height(24.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.End
|
|
) {
|
|
TextButton(onClick = { showDisconnectDialog = false }) {
|
|
Text("Cancel")
|
|
}
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Button(
|
|
onClick = {
|
|
showDisconnectDialog = false
|
|
credentials.clear()
|
|
ApiClient.resetClient()
|
|
onLogout()
|
|
},
|
|
colors = ButtonDefaults.buttonColors(
|
|
containerColor = MaterialTheme.colorScheme.error
|
|
)
|
|
) {
|
|
Text("Disconnect")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Verify it compiles**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew compileDebugKotlin 2>&1 | tail -5`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```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"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Run All Tests & Final Verification
|
|
|
|
**Files:** None (verification only)
|
|
|
|
- [ ] **Step 1: Run all unit tests**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew test 2>&1 | tail -20`
|
|
Expected: All tests pass. Animations don't affect business logic.
|
|
|
|
- [ ] **Step 2: Build debug APK**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew assembleDebug 2>&1 | tail -10`
|
|
Expected: BUILD SUCCESSFUL
|
|
|
|
- [ ] **Step 3: Verify no unused imports or lint issues**
|
|
|
|
Run: `cd /Users/pawelorzech/Programowanie/Swoosh && ./gradlew lintDebug 2>&1 | tail -20`
|
|
Expected: No new errors introduced
|
|
|
|
- [ ] **Step 4: Final commit if any cleanup needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: clean up lint and unused imports after animation additions"
|
|
```
|