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

+ + + + + + + +
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
+ +
+ + +
+
✏️ Composer Screen 6
+ +
+ + +
+
📖 Detail Screen 4
+ +
+ + +
+
⚙️ Settings Screen 2
+ +
+ +
+ + +
+

🎛️ Wspólne parametry animacji (SwooshMotion)

+

Centralny obiekt z predefiniowanymi specyfikacjami — zapewnia spójny "character" w całej aplikacji

+ + + + + + + +
NazwaTypParametryZastosowanie
BouncySpringdampingRatio=0.55, stiffness=400FAB, buttony, chipy
SnappySpringdampingRatio=0.7, stiffness=800Expand/collapse, dialogi
GentleSpringdampingRatio=0.8, stiffness=300Karty, content reveal
QuickTween200ms, FastOutSlowInFade, color transitions
StaggerDelayOffset50ms per itemList item wejścia
+
+ + +
+

🔀 Przejścia między ekranami

+ + + + + + + +
PrzejścieAnimacja
Feed → ComposerSlide up z dołu + fade (shared element z FAB → ekran)
Feed → DetailSlide in z prawej + fade (karta "wyrasta" w pełny widok)
Feed → SettingsSlide in z prawej, standard
Composer/Detail → BackReverse odpowiedniego wejścia
Setup → FeedCrossfade (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" +```