diff --git a/.claude/worktrees/agent-a0a9ebc1 b/.claude/worktrees/agent-a0a9ebc1
new file mode 160000
index 0000000..b119d75
--- /dev/null
+++ b/.claude/worktrees/agent-a0a9ebc1
@@ -0,0 +1 @@
+Subproject commit b119d75bac1feadd91a47922d5846faf45ad71fd
diff --git a/.claude/worktrees/agent-a17cf3a8 b/.claude/worktrees/agent-a17cf3a8
new file mode 160000
index 0000000..636c9f7
--- /dev/null
+++ b/.claude/worktrees/agent-a17cf3a8
@@ -0,0 +1 @@
+Subproject commit 636c9f7649792147e1e4f00c324ccdc757d06247
diff --git a/.claude/worktrees/agent-a3538537 b/.claude/worktrees/agent-a3538537
new file mode 160000
index 0000000..6927259
--- /dev/null
+++ b/.claude/worktrees/agent-a3538537
@@ -0,0 +1 @@
+Subproject commit 6927259a41e2c999a09da22ed202011db9e9e524
diff --git a/.claude/worktrees/agent-a3aee2cc b/.claude/worktrees/agent-a3aee2cc
new file mode 160000
index 0000000..fe60d17
--- /dev/null
+++ b/.claude/worktrees/agent-a3aee2cc
@@ -0,0 +1 @@
+Subproject commit fe60d17d39cc9bd100cd7eac745570a2bb1b8910
diff --git a/.claude/worktrees/agent-a3d73367 b/.claude/worktrees/agent-a3d73367
new file mode 160000
index 0000000..0265a11
--- /dev/null
+++ b/.claude/worktrees/agent-a3d73367
@@ -0,0 +1 @@
+Subproject commit 0265a1159d88eeef5ed4df2e9d6f661fb09fe5a1
diff --git a/.claude/worktrees/agent-a724b276 b/.claude/worktrees/agent-a724b276
new file mode 160000
index 0000000..5001ba1
--- /dev/null
+++ b/.claude/worktrees/agent-a724b276
@@ -0,0 +1 @@
+Subproject commit 5001ba18cb8326e200d4fd84468e17af8a90726e
diff --git a/.claude/worktrees/agent-a9b958b9 b/.claude/worktrees/agent-a9b958b9
new file mode 160000
index 0000000..c24b2f7
--- /dev/null
+++ b/.claude/worktrees/agent-a9b958b9
@@ -0,0 +1 @@
+Subproject commit c24b2f7fa75283196b30a24e71bd9fa9135def99
diff --git a/.claude/worktrees/agent-aac5dc7b b/.claude/worktrees/agent-aac5dc7b
new file mode 160000
index 0000000..f2ccf53
--- /dev/null
+++ b/.claude/worktrees/agent-aac5dc7b
@@ -0,0 +1 @@
+Subproject commit f2ccf535775a7f7f52ac6aa390e08c02027b7e13
diff --git a/.claude/worktrees/agent-ac86a40f b/.claude/worktrees/agent-ac86a40f
new file mode 160000
index 0000000..bbc408d
--- /dev/null
+++ b/.claude/worktrees/agent-ac86a40f
@@ -0,0 +1 @@
+Subproject commit bbc408d5dfe258906ad4798542ffbcf929ccac12
diff --git a/.claude/worktrees/agent-adfbc1bb b/.claude/worktrees/agent-adfbc1bb
new file mode 160000
index 0000000..5a41944
--- /dev/null
+++ b/.claude/worktrees/agent-adfbc1bb
@@ -0,0 +1 @@
+Subproject commit 5a41944a97090f3441916376a1df8c13979503a7
diff --git a/.claude/worktrees/agent-aee48ad4 b/.claude/worktrees/agent-aee48ad4
new file mode 160000
index 0000000..9da3b0d
--- /dev/null
+++ b/.claude/worktrees/agent-aee48ad4
@@ -0,0 +1 @@
+Subproject commit 9da3b0da0fc7fb11d34143bdb5f14280c62a9a53
diff --git a/.claude/worktrees/agent-afa60c48 b/.claude/worktrees/agent-afa60c48
new file mode 160000
index 0000000..202e25b
--- /dev/null
+++ b/.claude/worktrees/agent-afa60c48
@@ -0,0 +1 @@
+Subproject commit 202e25b572e7304ba0f2d59c533a34aaffe90d8f
diff --git a/.superpowers/brainstorm/17336-1773912049/.server-info b/.superpowers/brainstorm/17336-1773912049/.server-info
new file mode 100644
index 0000000..585c8b3
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/.server-info
@@ -0,0 +1 @@
+{"type":"server-started","port":65178,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65178","screen_dir":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049"}
diff --git a/.superpowers/brainstorm/17336-1773912049/.server.log b/.superpowers/brainstorm/17336-1773912049/.server.log
new file mode 100644
index 0000000..dc241c2
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/.server.log
@@ -0,0 +1,4 @@
+{"type":"server-started","port":65178,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65178","screen_dir":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049"}
+{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map.html"}
+{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/animation-map-v2.html"}
+{"type":"screen-added","file":"/Users/pawelorzech/Programowanie/Swoosh/.superpowers/brainstorm/17336-1773912049/waiting.html"}
diff --git a/.superpowers/brainstorm/17336-1773912049/.server.pid b/.superpowers/brainstorm/17336-1773912049/.server.pid
new file mode 100644
index 0000000..f305373
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/.server.pid
@@ -0,0 +1 @@
+17363
diff --git a/.superpowers/brainstorm/17336-1773912049/animation-map-v2.html b/.superpowers/brainstorm/17336-1773912049/animation-map-v2.html
new file mode 100644
index 0000000..3fb9ccf
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/animation-map-v2.html
@@ -0,0 +1,165 @@
+
Mapa mikro-animacji Swoosh
+19 animacji + 5 przejść nawigacyjnych. Ekspresyjny styl: sprężynki, bounce, overshoot.
+
+
+
+
+
+
📋 Feed Screen — 7 animacji
+
+
+
FAB wejście SPRING
+
Przy otwarciu ekranu FAB skaluje się z 0 do 1 z wyraźnym overshootem — "wyskakuje" na ekran
+
+
+
FAB press SPRING
+
Przy tapnięciu kurczy się do 85% i sprężyście wraca do 100%. Czujesz "klik"
+
+
+
Karty postów wejście SLIDE
+
Karty wjeżdżają od dołu kaskadowo — każda z 50ms opóźnieniem. Efekt "wodospadu"
+
+
+
"Show more" expand SPRING
+
Karta rozszerza się sprężyście (animateContentSize). Tekst wchodzi z fade
+
+
+
Empty state FADE
+
Ikona i tekst "No posts yet" fade-in + delikatny scale z 0.9 do 1.0
+
+
+
Queue status chip BOUNCE
+
Podczas uploadu chip pulsuje. Przy zmianie statusu (success/fail) — bounce + zmiana koloru
+
+
+
Snackbar error SLIDE
+
Wjeżdża od dołu z lekkim overshootem. Znika z fade po timeout
+
+
+
+
+
+
+
✏️ Composer Screen — 6 animacji
+
+
+
Image preview SCALE
+
Po wybraniu zdjęcia — preview skaluje się z 0 z bouncy spring. Przycisk "X" rotuje wchodząc
+
+
+
Link preview card SLIDE
+
Po załadowaniu — karta wysuwa się od dołu z fade. Shimmer placeholder podczas ładowania
+
+
+
Schedule chip SPRING
+
Po wybraniu daty chip "wyskakuje" sprężyście. Ikonka zegara lekko się obraca
+
+
+
Publish button BOUNCE
+
Delikatny bounce przy aktywacji. Podczas publishingu — loading pulse. Po sukcesie — checkmark z scale-in
+
+
+
Character counter FADE
+
Płynna zmiana koloru (crossfade) przy przekroczeniu 280 znaków — neutral → czerwony
+
+
+
Action buttons SCALE
+
Rząd przycisków (Draft, Schedule, Publish) — kaskadowy scale-in z 50ms opóźnieniem
+
+
+
+
+
+
+
📖 Detail Screen — 4 animacje
+
+
+
Content reveal FADE
+
Elementy pojawiają się sekwencyjnie: status → tekst → obraz → metadata. Każdy z 80ms opóźnieniem
+
+
+
Status badge SPRING
+
Badge skaluje się z 0 z bounce — pierwszy element na ekranie, przyciąga uwagę
+
+
+
Delete dialog SCALE
+
Dialog skaluje się z centrum ekranu (0.8→1.0) + backdrop fade. Sprężyste wejście
+
+
+
Metadata sekcja SLIDE
+
Dolna sekcja z metadanymi wysuwa się od dołu — ostatnia w sekwencji reveal
+
+
+
+
+
+
+
⚙️ Settings Screen — 2 animacje
+
+
+
"Saved!" feedback SPRING
+
Zielony tekst "Saved!" wyskakuje z bounce (scale 0→1). Po 2s fade out
+
+
+
Disconnect dialog FADE
+
Analogicznie do delete — scale z centrum + backdrop. Przycisk "Disconnect" z lekkim czerwonym pulsem
+
+
+
+
+
+
+
🎛️ SwooshMotion — wspólne parametry
+
+
+
Bouncy
+
Spring: damping=0.55, stiffness=400
→ FAB, buttony, chipy
+
+
+
Snappy
+
Spring: damping=0.7, stiffness=800
→ expand/collapse, dialogi
+
+
+
Gentle
+
Spring: damping=0.8, stiffness=300
→ karty, content reveal
+
+
+
Quick
+
Tween: 200ms FastOutSlowIn
→ fade, color transitions
+
+
+
+
+
+
+
🔀 Przejścia nawigacyjne
+
+ | Feed → Composer | Slide up z dołu ekranu + fade. FAB "transformuje się" w pełny ekran |
+ | Feed → Detail | Slide in z prawej + fade. Karta posta płynnie przechodzi w pełny widok |
+ | Feed → Settings | Standardowy slide z prawej |
+ | Back (powrót) | Reverse wejścia — slide out w odpowiednią stronę |
+ | Setup → Feed | Crossfade — płynne przejście z animowanego tła setup do feedu |
+
+
diff --git a/.superpowers/brainstorm/17336-1773912049/animation-map.html b/.superpowers/brainstorm/17336-1773912049/animation-map.html
new file mode 100644
index 0000000..9ab49f5
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/animation-map.html
@@ -0,0 +1,104 @@
+Mapa mikro-animacji Swoosh
+Przegląd wszystkich proponowanych animacji per ekran. Kliknij ekran aby zobaczyć szczegóły.
+
+
+
+
+
+
+
+
📋 Feed Screen 7
+
+ - SPRING FAB wejście — scale 0→1 z overshoot przy starcie ekranu
+ - SPRING FAB press — sprężyste zmniejszenie przy tapnięciu (0.85→1.0)
+ - SLIDE Karty postów — staggered wejście od dołu, każda karta z lekkim opóźnieniem
+ - SPRING "Show more" expand — animateContentSize ze sprężyną
+ - FADE Empty state — fade in + delikatny scale up
+ - BOUNCE Queue status — pulsujący chip podczas uploadu, bounce przy zmianie statusu
+ - SLIDE Snackbar — slide in od dołu z overshoot
+
+
+
+
+
+
✏️ Composer Screen 6
+
+ - SCALE Image preview — scale in z spring przy dodaniu zdjęcia
+ - SLIDE Link preview card — slide up + fade in po załadowaniu
+ - SPRING Schedule chip — sprężyste pojawienie się po wybraniu daty
+ - BOUNCE Publish button — subtle bounce na hover/focus, loading pulse
+ - FADE Character counter — color crossfade przy przekroczeniu limitu
+ - SCALE Action buttons row — staggered scale-in wejście
+
+
+
+
+
+
📖 Detail Screen 4
+
+ - FADE Content reveal — sekwencyjne fade-in elementów (status → tekst → obraz → metadata)
+ - SPRING Status badge — scale-in z bounce przy wejściu
+ - SCALE Delete dialog — scale z center + backdrop fade
+ - SLIDE Metadata sekcja — slide up od dolnej krawędzi
+
+
+
+
+
+
⚙️ Settings Screen 2
+
+ - SPRING "Saved!" feedback — scale-in z bounce + auto fade-out po 2s
+ - FADE Disconnect confirm — dialog fade + scale
+
+
+
+
+
+
+
+
🎛️ Wspólne parametry animacji (SwooshMotion)
+
Centralny obiekt z predefiniowanymi specyfikacjami — zapewnia spójny "character" w całej aplikacji
+
+ | Nazwa | Typ | Parametry | Zastosowanie |
+ Bouncy | Spring | dampingRatio=0.55, stiffness=400 | FAB, buttony, chipy |
+ Snappy | Spring | dampingRatio=0.7, stiffness=800 | Expand/collapse, dialogi |
+ Gentle | Spring | dampingRatio=0.8, stiffness=300 | Karty, content reveal |
+ Quick | Tween | 200ms, FastOutSlowIn | Fade, color transitions |
+ StaggerDelay | Offset | 50ms per item | List item wejścia |
+
+
+
+
+
+
🔀 Przejścia między ekranami
+
+ | Przejście | Animacja |
+ | Feed → Composer | Slide up z dołu + fade (shared element z FAB → ekran) |
+ | Feed → Detail | Slide in z prawej + fade (karta "wyrasta" w pełny widok) |
+ | Feed → Settings | Slide in z prawej, standard |
+ | Composer/Detail → Back | Reverse odpowiedniego wejścia |
+ | Setup → Feed | Crossfade (płynne przejście z animowanego tła) |
+
+
diff --git a/.superpowers/brainstorm/17336-1773912049/waiting.html b/.superpowers/brainstorm/17336-1773912049/waiting.html
new file mode 100644
index 0000000..ef07652
--- /dev/null
+++ b/.superpowers/brainstorm/17336-1773912049/waiting.html
@@ -0,0 +1,3 @@
+
+
Continuing in terminal...
+
diff --git a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt
index 02a5651..996ade1 100644
--- a/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt
+++ b/app/src/main/java/com/swoosh/microblog/data/db/LocalPostDao.kt
@@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db
import androidx.room.*
import com.swoosh.microblog.data.model.LocalPost
+import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.Flow
@@ -39,4 +40,13 @@ interface LocalPostDao {
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE)
+
+ @Query("SELECT COUNT(*) FROM local_posts")
+ suspend fun getTotalPostCount(): Int
+
+ @Query("SELECT COUNT(*) FROM local_posts WHERE status = :status")
+ suspend fun getPostCountByStatus(status: PostStatus): Int
+
+ @Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
+ suspend fun getAllPostsList(): List
}
diff --git a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt
index 9adfc02..530bb49 100644
--- a/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt
+++ b/app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt
@@ -41,7 +41,8 @@ data class GhostPost(
val published_at: String? = null,
val custom_excerpt: String? = null,
val visibility: String? = "public",
- val authors: List? = null
+ val authors: List? = null,
+ val reading_time: Int? = null
)
data class Author(
diff --git a/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt
new file mode 100644
index 0000000..570603d
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/data/model/OverallStats.kt
@@ -0,0 +1,71 @@
+package com.swoosh.microblog.data.model
+
+/**
+ * Aggregate statistics across all posts.
+ */
+data class OverallStats(
+ val totalPosts: Int = 0,
+ val publishedCount: Int = 0,
+ val draftCount: Int = 0,
+ val scheduledCount: Int = 0,
+ val totalWords: Int = 0,
+ val totalCharacters: Int = 0,
+ val averageWordCount: Int = 0,
+ val averageCharCount: Int = 0,
+ val longestPostWords: Int = 0,
+ val shortestPostWords: Int = 0
+) {
+ companion object {
+ /**
+ * Calculate overall stats from lists of local and remote posts.
+ */
+ fun calculate(
+ localPosts: List,
+ remotePosts: List
+ ): OverallStats {
+ // Combine all text content
+ val allTexts = mutableListOf()
+ var publishedCount = 0
+ var draftCount = 0
+ var scheduledCount = 0
+
+ for (post in localPosts) {
+ allTexts.add(post.content)
+ when (post.status) {
+ PostStatus.PUBLISHED -> publishedCount++
+ PostStatus.DRAFT -> draftCount++
+ PostStatus.SCHEDULED -> scheduledCount++
+ }
+ }
+
+ for (post in remotePosts) {
+ allTexts.add(post.textContent)
+ when (post.status.lowercase()) {
+ "published" -> publishedCount++
+ "draft" -> draftCount++
+ "scheduled" -> scheduledCount++
+ }
+ }
+
+ val wordCounts = allTexts.map { PostStats.countWords(it) }
+ val charCounts = allTexts.map { it.length }
+
+ val totalPosts = allTexts.size
+ val totalWords = wordCounts.sum()
+ val totalChars = charCounts.sum()
+
+ return OverallStats(
+ totalPosts = totalPosts,
+ publishedCount = publishedCount,
+ draftCount = draftCount,
+ scheduledCount = scheduledCount,
+ totalWords = totalWords,
+ totalCharacters = totalChars,
+ averageWordCount = if (totalPosts > 0) totalWords / totalPosts else 0,
+ averageCharCount = if (totalPosts > 0) totalChars / totalPosts else 0,
+ longestPostWords = wordCounts.maxOrNull() ?: 0,
+ shortestPostWords = wordCounts.minOrNull() ?: 0
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt
new file mode 100644
index 0000000..3d8d95a
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/data/model/PostStats.kt
@@ -0,0 +1,94 @@
+package com.swoosh.microblog.data.model
+
+/**
+ * Statistics calculated for a single post.
+ */
+data class PostStats(
+ val wordCount: Int,
+ val charCount: Int,
+ val readingTimeMinutes: Int,
+ val hasImage: Boolean,
+ val hasLink: Boolean
+) {
+ companion object {
+ private const val WORDS_PER_MINUTE = 200
+
+ /**
+ * Calculate post statistics from text content and metadata.
+ */
+ fun fromContent(
+ text: String,
+ hasImage: Boolean = false,
+ hasLink: Boolean = false
+ ): PostStats {
+ val charCount = text.length
+ val wordCount = countWords(text)
+ val readingTime = estimateReadingTime(wordCount)
+ return PostStats(
+ wordCount = wordCount,
+ charCount = charCount,
+ readingTimeMinutes = readingTime,
+ hasImage = hasImage,
+ hasLink = hasLink
+ )
+ }
+
+ /**
+ * Calculate post statistics from a FeedPost.
+ */
+ fun fromFeedPost(post: FeedPost): PostStats {
+ return fromContent(
+ text = post.textContent,
+ hasImage = post.imageUrl != null,
+ hasLink = post.linkUrl != null
+ )
+ }
+
+ /**
+ * Count words in text by splitting on whitespace.
+ * Handles multiple spaces, newlines, tabs, and empty strings.
+ */
+ fun countWords(text: String): Int {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return 0
+ return trimmed.split(Regex("\\s+")).size
+ }
+
+ /**
+ * Estimate reading time in minutes based on word count.
+ * Uses 200 words per minute as average reading speed.
+ * Returns at least 1 minute for non-empty content.
+ */
+ fun estimateReadingTime(wordCount: Int): Int {
+ if (wordCount <= 0) return 0
+ val minutes = wordCount / WORDS_PER_MINUTE
+ return maxOf(1, minutes)
+ }
+
+ /**
+ * Format reading time for display.
+ */
+ fun formatReadingTime(minutes: Int): String {
+ return when {
+ minutes <= 0 -> ""
+ minutes == 1 -> "1 min read"
+ else -> "$minutes min read"
+ }
+ }
+
+ /**
+ * Format a live stats string for the composer.
+ */
+ fun formatComposerStats(text: String): String {
+ val charCount = text.length
+ val wordCount = countWords(text)
+ val readingTime = estimateReadingTime(wordCount)
+ return if (charCount == 0) {
+ "0 chars"
+ } else {
+ val readingLabel = formatReadingTime(readingTime)
+ "$charCount chars · $wordCount words · ~$readingLabel"
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt
index 8a567ab..4d4dfc7 100644
--- a/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt
+++ b/app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt
@@ -142,6 +142,14 @@ class PostRepository(private val context: Context) {
suspend fun markUploaded(localId: Long, ghostId: String) =
dao.markUploaded(localId, ghostId)
+ // --- Stats queries ---
+
+ suspend fun getTotalLocalPostCount(): Int = dao.getTotalPostCount()
+
+ suspend fun getLocalPostCountByStatus(status: PostStatus): Int = dao.getPostCountByStatus(status)
+
+ suspend fun getAllLocalPostsList(): List = dao.getAllPostsList()
+
// --- Connectivity check ---
fun isNetworkAvailable(): Boolean {
diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
index b26f002..5decbae 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
+import com.swoosh.microblog.data.model.PostStats
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -90,12 +91,17 @@ fun ComposerScreen(
.heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") },
supportingText = {
+ val charCount = state.text.length
+ val statsText = PostStats.formatComposerStats(state.text)
+ val color = when {
+ charCount > 500 -> MaterialTheme.colorScheme.error
+ charCount > 280 -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
Text(
- "${state.text.length} characters",
+ text = statsText,
style = MaterialTheme.typography.labelSmall,
- color = if (state.text.length > 280)
- MaterialTheme.colorScheme.error
- else MaterialTheme.colorScheme.onSurfaceVariant
+ color = color
)
}
)
diff --git a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
index bc6d465..855327c 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt
@@ -1,20 +1,32 @@
package com.swoosh.microblog.ui.detail
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
+import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.feed.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime
@@ -130,18 +142,9 @@ fun DetailScreen(
}
}
- // Metadata
+ // Stats section
Spacer(modifier = Modifier.height(24.dp))
- Divider()
- Spacer(modifier = Modifier.height(12.dp))
-
- if (post.createdAt != null) {
- MetadataRow("Created", post.createdAt)
- }
- if (post.publishedAt != null) {
- MetadataRow("Published", post.publishedAt)
- }
- MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
+ PostStatsSection(post)
}
}
@@ -168,6 +171,155 @@ fun DetailScreen(
}
}
+@Composable
+private fun PostStatsSection(post: FeedPost) {
+ val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
+ PostStats.fromFeedPost(post)
+ }
+ var expanded by remember { mutableStateOf(false) }
+
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Header row - always visible, clickable to expand
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Post Statistics",
+ style = MaterialTheme.typography.titleSmall
+ )
+ IconButton(
+ onClick = { expanded = !expanded },
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (expanded) "Collapse" else "Expand",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Quick stats row - always visible
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StatItem(
+ icon = Icons.Default.TextFields,
+ value = "${stats.wordCount}",
+ label = "Words"
+ )
+ StatItem(
+ icon = Icons.AutoMirrored.Filled.Article,
+ value = "${stats.charCount}",
+ label = "Chars"
+ )
+ StatItem(
+ icon = Icons.Default.AccessTime,
+ value = PostStats.formatReadingTime(stats.readingTimeMinutes).ifEmpty { "< 1 min" },
+ label = "Read"
+ )
+ }
+
+ // Expandable details
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column(modifier = Modifier.padding(top = 12.dp)) {
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(12.dp))
+
+ MetadataRow("Status", post.status.replaceFirstChar { it.uppercase() })
+ if (post.createdAt != null) {
+ MetadataRow("Created", post.createdAt)
+ }
+ if (post.updatedAt != null) {
+ MetadataRow("Updated", post.updatedAt)
+ }
+ if (post.publishedAt != null) {
+ MetadataRow("Published", post.publishedAt)
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Content indicators
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ if (stats.hasImage) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Image,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ "Image",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ if (stats.hasLink) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Link,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ "Link",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatItem(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ value: String,
+ label: String
+) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
@Composable
private fun MetadataRow(label: String, value: String) {
Row(
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 661679f..a5386da 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
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
@@ -30,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost
+import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus
import com.swoosh.microblog.ui.theme.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel
@@ -338,6 +340,40 @@ fun PostCard(
}
}
}
+
+ // Post stats badges
+ if (post.textContent.isNotBlank()) {
+ val stats = remember(post.textContent, post.imageUrl, post.linkUrl) {
+ PostStats.fromFeedPost(post)
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Reading time with clock icon
+ val readingLabel = PostStats.formatReadingTime(stats.readingTimeMinutes)
+ if (readingLabel.isNotEmpty()) {
+ Icon(
+ Icons.Default.AccessTime,
+ contentDescription = null,
+ modifier = Modifier.size(12.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ Text(
+ text = readingLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+ // Word count
+ Text(
+ text = "${stats.wordCount} words",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
index 3b8326f..36accda 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt
@@ -13,6 +13,7 @@ import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen
+import com.swoosh.microblog.ui.stats.StatsScreen
import com.swoosh.microblog.ui.theme.ThemeViewModel
object Routes {
@@ -21,6 +22,7 @@ object Routes {
const val COMPOSER = "composer"
const val DETAIL = "detail"
const val SETTINGS = "settings"
+ const val STATS = "stats"
}
@Composable
@@ -101,8 +103,17 @@ fun SwooshNavGraph(
navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true }
}
+ },
+ onStatsClick = {
+ navController.navigate(Routes.STATS)
}
)
}
+
+ composable(Routes.STATS) {
+ StatsScreen(
+ onBack = { navController.popBackStack() }
+ )
+ }
}
}
diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
index 770e9fd..d6340a6 100644
--- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
@@ -28,7 +29,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit,
- themeViewModel: ThemeViewModel? = null
+ themeViewModel: ThemeViewModel? = null,
+ onStatsClick: () -> Unit = {}
) {
val context = LocalContext.current
val credentials = remember { CredentialsManager(context) }
@@ -123,6 +125,24 @@ fun SettingsScreen(
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
+ // Writing Statistics button
+ FilledTonalButton(
+ onClick = onStatsClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ Icons.Default.BarChart,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Writing Statistics")
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
OutlinedButton(
onClick = {
credentials.clear()
diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
new file mode 100644
index 0000000..196fe2d
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
@@ -0,0 +1,195 @@
+package com.swoosh.microblog.ui.stats
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Article
+import androidx.compose.material.icons.filled.Create
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StatsScreen(
+ onBack: () -> Unit,
+ viewModel: StatsViewModel = viewModel()
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Writing Statistics") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ if (state.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize().padding(padding),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Post counts section
+ Text(
+ "Posts Overview",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ StatsCard(
+ modifier = Modifier.weight(1f),
+ value = "${state.stats.totalPosts}",
+ label = "Total Posts",
+ icon = Icons.AutoMirrored.Filled.Article
+ )
+ StatsCard(
+ modifier = Modifier.weight(1f),
+ value = "${state.stats.publishedCount}",
+ label = "Published",
+ icon = Icons.Default.Create
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ StatsCard(
+ modifier = Modifier.weight(1f),
+ value = "${state.stats.draftCount}",
+ label = "Drafts",
+ icon = Icons.Default.TextFields
+ )
+ StatsCard(
+ modifier = Modifier.weight(1f),
+ value = "${state.stats.scheduledCount}",
+ label = "Scheduled",
+ icon = Icons.Default.Schedule
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Writing stats section
+ Text(
+ "Writing Stats",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ OutlinedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ WritingStatRow("Total words written", "${state.stats.totalWords}")
+ HorizontalDivider()
+ WritingStatRow("Total characters", "${state.stats.totalCharacters}")
+ HorizontalDivider()
+ WritingStatRow("Average post length", "${state.stats.averageWordCount} words")
+ HorizontalDivider()
+ WritingStatRow("Average characters", "${state.stats.averageCharCount} chars")
+ HorizontalDivider()
+ WritingStatRow("Longest post", "${state.stats.longestPostWords} words")
+ HorizontalDivider()
+ WritingStatRow("Shortest post", "${state.stats.shortestPostWords} words")
+ }
+ }
+
+ if (state.error != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Note: Remote post data may be incomplete. ${state.error}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatsCard(
+ modifier: Modifier = Modifier,
+ value: String,
+ label: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector
+) {
+ OutlinedCard(modifier = modifier) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@Composable
+private fun WritingStatRow(label: String, value: String) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Medium
+ )
+ }
+}
diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt
new file mode 100644
index 0000000..790a7c1
--- /dev/null
+++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt
@@ -0,0 +1,88 @@
+package com.swoosh.microblog.ui.stats
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.swoosh.microblog.data.model.FeedPost
+import com.swoosh.microblog.data.model.OverallStats
+import com.swoosh.microblog.data.repository.PostRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class StatsViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val repository = PostRepository(application)
+
+ private val _uiState = MutableStateFlow(StatsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadStats()
+ }
+
+ fun loadStats() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+
+ try {
+ // Get local posts
+ val localPosts = repository.getAllLocalPostsList()
+
+ // Get remote posts
+ val remotePosts = mutableListOf()
+ var page = 1
+ var hasMore = true
+ while (hasMore) {
+ val result = repository.fetchPosts(page = page, limit = 50)
+ result.fold(
+ onSuccess = { response ->
+ remotePosts.addAll(response.posts.map { ghost ->
+ FeedPost(
+ ghostId = ghost.id,
+ title = ghost.title ?: "",
+ textContent = ghost.plaintext ?: ghost.html?.replace(Regex("<[^>]*>"), "") ?: "",
+ htmlContent = ghost.html,
+ imageUrl = ghost.feature_image,
+ linkUrl = null,
+ linkTitle = null,
+ linkDescription = null,
+ linkImageUrl = null,
+ status = ghost.status ?: "draft",
+ publishedAt = ghost.published_at,
+ createdAt = ghost.created_at,
+ updatedAt = ghost.updated_at,
+ isLocal = false
+ )
+ })
+ hasMore = response.meta?.pagination?.next != null
+ page++
+ },
+ onFailure = {
+ hasMore = false
+ }
+ )
+ // Safety limit
+ if (page > 20) break
+ }
+
+ // Remove remote duplicates that exist locally
+ val localGhostIds = localPosts.mapNotNull { it.ghostId }.toSet()
+ val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds }
+
+ val stats = OverallStats.calculate(localPosts, uniqueRemotePosts)
+ _uiState.update { it.copy(stats = stats, isLoading = false) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(isLoading = false, error = e.message) }
+ }
+ }
+ }
+}
+
+data class StatsUiState(
+ val stats: OverallStats = OverallStats(),
+ val isLoading: Boolean = false,
+ val error: String? = null
+)
diff --git a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt
index 6f4f370..42be602 100644
--- a/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt
+++ b/app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt
@@ -77,6 +77,14 @@ class GhostModelsTest {
assertNull(post.published_at)
assertNull(post.custom_excerpt)
assertNull(post.authors)
+ assertNull(post.reading_time)
+ }
+
+ @Test
+ fun `GhostPost reading_time deserializes from JSON`() {
+ val json = """{"id":"test","reading_time":3}"""
+ val post = gson.fromJson(json, GhostPost::class.java)
+ assertEquals(3, post.reading_time)
}
// --- FeedPost ---
diff --git a/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt
new file mode 100644
index 0000000..a05cd8c
--- /dev/null
+++ b/app/src/test/java/com/swoosh/microblog/data/model/OverallStatsTest.kt
@@ -0,0 +1,169 @@
+package com.swoosh.microblog.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class OverallStatsTest {
+
+ @Test
+ fun `calculate with empty lists returns zero stats`() {
+ val stats = OverallStats.calculate(emptyList(), emptyList())
+ assertEquals(0, stats.totalPosts)
+ assertEquals(0, stats.publishedCount)
+ assertEquals(0, stats.draftCount)
+ assertEquals(0, stats.scheduledCount)
+ assertEquals(0, stats.totalWords)
+ assertEquals(0, stats.totalCharacters)
+ assertEquals(0, stats.averageWordCount)
+ assertEquals(0, stats.averageCharCount)
+ assertEquals(0, stats.longestPostWords)
+ assertEquals(0, stats.shortestPostWords)
+ }
+
+ @Test
+ fun `calculate counts local posts by status`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello", status = PostStatus.DRAFT),
+ LocalPost(localId = 2, content = "world", status = PostStatus.PUBLISHED),
+ LocalPost(localId = 3, content = "foo bar", status = PostStatus.SCHEDULED)
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(3, stats.totalPosts)
+ assertEquals(1, stats.publishedCount)
+ assertEquals(1, stats.draftCount)
+ assertEquals(1, stats.scheduledCount)
+ }
+
+ @Test
+ fun `calculate counts remote posts by status`() {
+ val remotePosts = listOf(
+ makeFeedPost("hello world", "published"),
+ makeFeedPost("foo bar", "published"),
+ makeFeedPost("baz", "draft")
+ )
+ val stats = OverallStats.calculate(emptyList(), remotePosts)
+ assertEquals(3, stats.totalPosts)
+ assertEquals(2, stats.publishedCount)
+ assertEquals(1, stats.draftCount)
+ assertEquals(0, stats.scheduledCount)
+ }
+
+ @Test
+ fun `calculate computes total words`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello world"), // 2 words
+ LocalPost(localId = 2, content = "foo bar baz") // 3 words
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(5, stats.totalWords)
+ }
+
+ @Test
+ fun `calculate computes total characters`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello"), // 5 chars
+ LocalPost(localId = 2, content = "world") // 5 chars
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(10, stats.totalCharacters)
+ }
+
+ @Test
+ fun `calculate computes average word count`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello world"), // 2 words
+ LocalPost(localId = 2, content = "one two three four") // 4 words
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(3, stats.averageWordCount) // (2 + 4) / 2 = 3
+ }
+
+ @Test
+ fun `calculate computes average char count`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello"), // 5 chars
+ LocalPost(localId = 2, content = "world!!") // 7 chars
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(6, stats.averageCharCount) // (5 + 7) / 2 = 6
+ }
+
+ @Test
+ fun `calculate finds longest post`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello"), // 1 word
+ LocalPost(localId = 2, content = "one two three four five six") // 6 words
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(6, stats.longestPostWords)
+ }
+
+ @Test
+ fun `calculate finds shortest post`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "hello"), // 1 word
+ LocalPost(localId = 2, content = "one two three four five six") // 6 words
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(1, stats.shortestPostWords)
+ }
+
+ @Test
+ fun `calculate combines local and remote posts`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "local post", status = PostStatus.DRAFT)
+ )
+ val remotePosts = listOf(
+ makeFeedPost("remote post here", "published")
+ )
+ val stats = OverallStats.calculate(localPosts, remotePosts)
+ assertEquals(2, stats.totalPosts)
+ assertEquals(1, stats.publishedCount)
+ assertEquals(1, stats.draftCount)
+ assertEquals(5, stats.totalWords) // 2 + 3
+ }
+
+ @Test
+ fun `calculate handles scheduled remote posts`() {
+ val remotePosts = listOf(
+ makeFeedPost("scheduled post", "scheduled")
+ )
+ val stats = OverallStats.calculate(emptyList(), remotePosts)
+ assertEquals(1, stats.scheduledCount)
+ }
+
+ @Test
+ fun `calculate handles single post`() {
+ val localPosts = listOf(
+ LocalPost(localId = 1, content = "single word test post")
+ )
+ val stats = OverallStats.calculate(localPosts, emptyList())
+ assertEquals(1, stats.totalPosts)
+ assertEquals(4, stats.averageWordCount) // same as total since only 1 post
+ assertEquals(stats.longestPostWords, stats.shortestPostWords)
+ }
+
+ @Test
+ fun `default OverallStats has zero values`() {
+ val stats = OverallStats()
+ assertEquals(0, stats.totalPosts)
+ assertEquals(0, stats.totalWords)
+ }
+
+ private fun makeFeedPost(text: String, status: String): FeedPost {
+ return FeedPost(
+ title = "",
+ textContent = text,
+ htmlContent = null,
+ imageUrl = null,
+ linkUrl = null,
+ linkTitle = null,
+ linkDescription = null,
+ linkImageUrl = null,
+ status = status,
+ publishedAt = null,
+ createdAt = null,
+ updatedAt = null
+ )
+ }
+}
diff --git a/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt
new file mode 100644
index 0000000..e123783
--- /dev/null
+++ b/app/src/test/java/com/swoosh/microblog/data/model/PostStatsTest.kt
@@ -0,0 +1,338 @@
+package com.swoosh.microblog.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class PostStatsTest {
+
+ // --- Word count tests ---
+
+ @Test
+ fun `countWords returns 0 for empty string`() {
+ assertEquals(0, PostStats.countWords(""))
+ }
+
+ @Test
+ fun `countWords returns 0 for whitespace only`() {
+ assertEquals(0, PostStats.countWords(" "))
+ }
+
+ @Test
+ fun `countWords returns 0 for tabs and newlines only`() {
+ assertEquals(0, PostStats.countWords("\t\n\r\n"))
+ }
+
+ @Test
+ fun `countWords counts single word`() {
+ assertEquals(1, PostStats.countWords("hello"))
+ }
+
+ @Test
+ fun `countWords counts multiple words`() {
+ assertEquals(5, PostStats.countWords("the quick brown fox jumps"))
+ }
+
+ @Test
+ fun `countWords handles multiple spaces between words`() {
+ assertEquals(3, PostStats.countWords("hello world foo"))
+ }
+
+ @Test
+ fun `countWords handles newlines between words`() {
+ assertEquals(3, PostStats.countWords("hello\nworld\nfoo"))
+ }
+
+ @Test
+ fun `countWords handles tabs between words`() {
+ assertEquals(2, PostStats.countWords("hello\tworld"))
+ }
+
+ @Test
+ fun `countWords handles mixed whitespace`() {
+ assertEquals(4, PostStats.countWords(" hello\n\tworld foo\nbar "))
+ }
+
+ @Test
+ fun `countWords handles leading and trailing whitespace`() {
+ assertEquals(2, PostStats.countWords(" hello world "))
+ }
+
+ @Test
+ fun `countWords handles unicode characters`() {
+ assertEquals(2, PostStats.countWords("caf\u00E9 latt\u00E9"))
+ }
+
+ @Test
+ fun `countWords handles emoji as word`() {
+ assertEquals(3, PostStats.countWords("hello \uD83D\uDE00 world"))
+ }
+
+ @Test
+ fun `countWords handles punctuation attached to words`() {
+ assertEquals(4, PostStats.countWords("hello, world! foo. bar?"))
+ }
+
+ @Test
+ fun `countWords handles long text`() {
+ val text = (1..200).joinToString(" ") { "word$it" }
+ assertEquals(200, PostStats.countWords(text))
+ }
+
+ @Test
+ fun `countWords handles single character`() {
+ assertEquals(1, PostStats.countWords("a"))
+ }
+
+ @Test
+ fun `countWords handles hyphenated words as single word`() {
+ assertEquals(1, PostStats.countWords("well-known"))
+ }
+
+ // --- Reading time estimation tests ---
+
+ @Test
+ fun `estimateReadingTime returns 0 for 0 words`() {
+ assertEquals(0, PostStats.estimateReadingTime(0))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 0 for negative words`() {
+ assertEquals(0, PostStats.estimateReadingTime(-5))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 1 for small word count`() {
+ assertEquals(1, PostStats.estimateReadingTime(50))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 1 for 199 words`() {
+ assertEquals(1, PostStats.estimateReadingTime(199))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 1 for exactly 200 words`() {
+ assertEquals(1, PostStats.estimateReadingTime(200))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 2 for 400 words`() {
+ assertEquals(2, PostStats.estimateReadingTime(400))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 5 for 1000 words`() {
+ assertEquals(5, PostStats.estimateReadingTime(1000))
+ }
+
+ @Test
+ fun `estimateReadingTime returns 1 for 1 word`() {
+ assertEquals(1, PostStats.estimateReadingTime(1))
+ }
+
+ // --- PostStats creation tests ---
+
+ @Test
+ fun `fromContent creates correct stats for normal text`() {
+ val stats = PostStats.fromContent("hello world foo bar baz")
+ assertEquals(5, stats.wordCount)
+ assertEquals(23, stats.charCount)
+ assertEquals(1, stats.readingTimeMinutes)
+ assertFalse(stats.hasImage)
+ assertFalse(stats.hasLink)
+ }
+
+ @Test
+ fun `fromContent creates correct stats for empty text`() {
+ val stats = PostStats.fromContent("")
+ assertEquals(0, stats.wordCount)
+ assertEquals(0, stats.charCount)
+ assertEquals(0, stats.readingTimeMinutes)
+ }
+
+ @Test
+ fun `fromContent sets hasImage correctly`() {
+ val stats = PostStats.fromContent("hello", hasImage = true)
+ assertTrue(stats.hasImage)
+ assertFalse(stats.hasLink)
+ }
+
+ @Test
+ fun `fromContent sets hasLink correctly`() {
+ val stats = PostStats.fromContent("hello", hasLink = true)
+ assertFalse(stats.hasImage)
+ assertTrue(stats.hasLink)
+ }
+
+ @Test
+ fun `fromContent sets both hasImage and hasLink`() {
+ val stats = PostStats.fromContent("hello", hasImage = true, hasLink = true)
+ assertTrue(stats.hasImage)
+ assertTrue(stats.hasLink)
+ }
+
+ @Test
+ fun `fromFeedPost calculates from text content`() {
+ val post = FeedPost(
+ title = "Test",
+ textContent = "This is a test post with some words",
+ htmlContent = null,
+ imageUrl = "https://example.com/img.jpg",
+ linkUrl = "https://example.com",
+ linkTitle = "Example",
+ linkDescription = null,
+ linkImageUrl = null,
+ status = "published",
+ publishedAt = null,
+ createdAt = null,
+ updatedAt = null
+ )
+ val stats = PostStats.fromFeedPost(post)
+ assertEquals(8, stats.wordCount)
+ assertTrue(stats.hasImage)
+ assertTrue(stats.hasLink)
+ }
+
+ @Test
+ fun `fromFeedPost with no image and no link`() {
+ val post = FeedPost(
+ title = "Test",
+ textContent = "Just text",
+ htmlContent = null,
+ imageUrl = null,
+ linkUrl = null,
+ linkTitle = null,
+ linkDescription = null,
+ linkImageUrl = null,
+ status = "draft",
+ publishedAt = null,
+ createdAt = null,
+ updatedAt = null
+ )
+ val stats = PostStats.fromFeedPost(post)
+ assertEquals(2, stats.wordCount)
+ assertFalse(stats.hasImage)
+ assertFalse(stats.hasLink)
+ }
+
+ // --- Format reading time tests ---
+
+ @Test
+ fun `formatReadingTime returns empty string for 0 minutes`() {
+ assertEquals("", PostStats.formatReadingTime(0))
+ }
+
+ @Test
+ fun `formatReadingTime returns singular for 1 minute`() {
+ assertEquals("1 min read", PostStats.formatReadingTime(1))
+ }
+
+ @Test
+ fun `formatReadingTime returns plural for 2 minutes`() {
+ assertEquals("2 min read", PostStats.formatReadingTime(2))
+ }
+
+ @Test
+ fun `formatReadingTime handles large values`() {
+ assertEquals("10 min read", PostStats.formatReadingTime(10))
+ }
+
+ @Test
+ fun `formatReadingTime returns empty for negative`() {
+ assertEquals("", PostStats.formatReadingTime(-1))
+ }
+
+ // --- formatComposerStats tests ---
+
+ @Test
+ fun `formatComposerStats for empty text`() {
+ assertEquals("0 chars", PostStats.formatComposerStats(""))
+ }
+
+ @Test
+ fun `formatComposerStats for single word`() {
+ val result = PostStats.formatComposerStats("hello")
+ assertTrue(result.contains("5 chars"))
+ assertTrue(result.contains("1 words"))
+ assertTrue(result.contains("~1 min read"))
+ }
+
+ @Test
+ fun `formatComposerStats for multiple words`() {
+ val result = PostStats.formatComposerStats("hello world foo")
+ assertTrue(result.contains("15 chars"))
+ assertTrue(result.contains("3 words"))
+ }
+
+ @Test
+ fun `formatComposerStats uses dot separator`() {
+ val result = PostStats.formatComposerStats("hello world")
+ assertTrue(result.contains(" · "))
+ }
+
+ // --- Character count edge cases ---
+
+ @Test
+ fun `charCount for empty string is 0`() {
+ val stats = PostStats.fromContent("")
+ assertEquals(0, stats.charCount)
+ }
+
+ @Test
+ fun `charCount for unicode string`() {
+ val stats = PostStats.fromContent("cafe\u0301")
+ // "cafe" + combining accent = 5 chars in Kotlin
+ assertEquals(5, stats.charCount)
+ }
+
+ @Test
+ fun `charCount for emoji string`() {
+ val text = "\uD83D\uDE00\uD83D\uDE01\uD83D\uDE02"
+ val stats = PostStats.fromContent(text)
+ // Each emoji is 2 chars (surrogate pair) in Kotlin
+ assertEquals(6, stats.charCount)
+ }
+
+ @Test
+ fun `charCount for string with only spaces`() {
+ val stats = PostStats.fromContent(" ")
+ assertEquals(5, stats.charCount)
+ }
+
+ @Test
+ fun `charCount for string with newlines`() {
+ val stats = PostStats.fromContent("hello\nworld")
+ assertEquals(11, stats.charCount)
+ }
+
+ @Test
+ fun `charCount matches string length`() {
+ val text = "A test string with 35 characters!!"
+ val stats = PostStats.fromContent(text)
+ assertEquals(text.length, stats.charCount)
+ }
+
+ // --- PostStats data class tests ---
+
+ @Test
+ fun `PostStats equality`() {
+ val a = PostStats(10, 50, 1, false, false)
+ val b = PostStats(10, 50, 1, false, false)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun `PostStats inequality`() {
+ val a = PostStats(10, 50, 1, false, false)
+ val b = PostStats(10, 50, 1, true, false)
+ assertNotEquals(a, b)
+ }
+
+ @Test
+ fun `PostStats copy`() {
+ val original = PostStats(10, 50, 1, false, false)
+ val copied = original.copy(hasImage = true)
+ assertEquals(10, copied.wordCount)
+ assertTrue(copied.hasImage)
+ }
+}
diff --git a/docs/superpowers/plans/2026-03-19-micro-animations.md b/docs/superpowers/plans/2026-03-19-micro-animations.md
new file mode 100644
index 0000000..d0624f8
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-19-micro-animations.md
@@ -0,0 +1,1039 @@
+# 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 bouncy(): FiniteAnimationSpec = spring(
+ dampingRatio = 0.65f,
+ stiffness = 400f
+ )
+
+ // Fast snap-back — press feedback, button taps. Settles in ~150ms.
+ fun bouncyQuick(): FiniteAnimationSpec = spring(
+ dampingRatio = 0.7f,
+ stiffness = 1000f
+ )
+
+ // Controlled spring — expand/collapse, dialogs.
+ fun snappy(): FiniteAnimationSpec = spring(
+ dampingRatio = 0.7f,
+ stiffness = 800f
+ )
+
+ // Soft entrance — cards, content reveal.
+ fun gentle(): FiniteAnimationSpec = spring(
+ dampingRatio = 0.8f,
+ stiffness = 300f
+ )
+
+ // Quick tween — fade, color transitions.
+ fun quick(): FiniteAnimationSpec = 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() }
+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"
+```