merge: integrate post statistics feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:38:26 +01:00
commit b85bc96dcf
No known key found for this signature in database
34 changed files with 2553 additions and 17 deletions

@ -0,0 +1 @@
Subproject commit b119d75bac1feadd91a47922d5846faf45ad71fd

@ -0,0 +1 @@
Subproject commit 636c9f7649792147e1e4f00c324ccdc757d06247

@ -0,0 +1 @@
Subproject commit 6927259a41e2c999a09da22ed202011db9e9e524

@ -0,0 +1 @@
Subproject commit fe60d17d39cc9bd100cd7eac745570a2bb1b8910

@ -0,0 +1 @@
Subproject commit 0265a1159d88eeef5ed4df2e9d6f661fb09fe5a1

@ -0,0 +1 @@
Subproject commit 5001ba18cb8326e200d4fd84468e17af8a90726e

@ -0,0 +1 @@
Subproject commit c24b2f7fa75283196b30a24e71bd9fa9135def99

@ -0,0 +1 @@
Subproject commit f2ccf535775a7f7f52ac6aa390e08c02027b7e13

@ -0,0 +1 @@
Subproject commit bbc408d5dfe258906ad4798542ffbcf929ccac12

@ -0,0 +1 @@
Subproject commit 5a41944a97090f3441916376a1df8c13979503a7

@ -0,0 +1 @@
Subproject commit 9da3b0da0fc7fb11d34143bdb5f14280c62a9a53

@ -0,0 +1 @@
Subproject commit 202e25b572e7304ba0f2d59c533a34aaffe90d8f

View file

@ -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"}

View file

@ -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"}

View file

@ -0,0 +1 @@
17363

View file

@ -0,0 +1,165 @@
<h2>Mapa mikro-animacji Swoosh</h2>
<p class="subtitle">19 animacji + 5 przejść nawigacyjnych. Ekspresyjny styl: sprężynki, bounce, overshoot.</p>
<style>
.screen-section { margin: 28px 0; }
.screen-title { font-size: 1.2em; font-weight: 700; margin-bottom: 14px; padding-bottom: 8px; border-bottom: 2px solid rgba(108,99,255,0.3); }
.anim-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.anim-item { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 12px 14px; }
.anim-name { font-weight: 700; font-size: 0.95em; margin-bottom: 4px; }
.anim-desc { font-size: 0.85em; opacity: 0.75; line-height: 1.4; }
.anim-type { display: inline-block; font-size: 0.65em; padding: 2px 6px; border-radius: 3px; font-weight: 700; margin-left: 6px; vertical-align: middle; }
.t-spring { background: #7c4dff; color: #fff; }
.t-fade { background: #00c853; color: #000; }
.t-slide { background: #ff9100; color: #000; }
.t-scale { background: #448aff; color: #fff; }
.t-bounce { background: #ff4081; color: #fff; }
.spec-box { margin-top: 32px; padding: 18px; background: rgba(108,99,255,0.08); border: 1px solid rgba(108,99,255,0.25); border-radius: 10px; }
.spec-box h3 { margin: 0 0 12px 0; }
.spec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.spec-item { background: rgba(255,255,255,0.04); border-radius: 6px; padding: 10px 12px; }
.spec-name { font-weight: 700; font-size: 0.9em; }
.spec-val { font-size: 0.8em; opacity: 0.7; margin-top: 2px; }
.nav-table { width: 100%; margin-top: 12px; font-size: 0.85em; }
.nav-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.nav-table td:first-child { font-weight: 600; white-space: nowrap; width: 40%; }
</style>
<!-- ===== FEED SCREEN ===== -->
<div class="screen-section">
<div class="screen-title">📋 Feed Screen — 7 animacji</div>
<div class="anim-grid">
<div class="anim-item">
<div class="anim-name">FAB wejście <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Przy otwarciu ekranu FAB skaluje się z 0 do 1 z wyraźnym overshootem — "wyskakuje" na ekran</div>
</div>
<div class="anim-item">
<div class="anim-name">FAB press <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Przy tapnięciu kurczy się do 85% i sprężyście wraca do 100%. Czujesz "klik"</div>
</div>
<div class="anim-item">
<div class="anim-name">Karty postów wejście <span class="anim-type t-slide">SLIDE</span></div>
<div class="anim-desc">Karty wjeżdżają od dołu kaskadowo — każda z 50ms opóźnieniem. Efekt "wodospadu"</div>
</div>
<div class="anim-item">
<div class="anim-name">"Show more" expand <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Karta rozszerza się sprężyście (animateContentSize). Tekst wchodzi z fade</div>
</div>
<div class="anim-item">
<div class="anim-name">Empty state <span class="anim-type t-fade">FADE</span></div>
<div class="anim-desc">Ikona i tekst "No posts yet" fade-in + delikatny scale z 0.9 do 1.0</div>
</div>
<div class="anim-item">
<div class="anim-name">Queue status chip <span class="anim-type t-bounce">BOUNCE</span></div>
<div class="anim-desc">Podczas uploadu chip pulsuje. Przy zmianie statusu (success/fail) — bounce + zmiana koloru</div>
</div>
<div class="anim-item">
<div class="anim-name">Snackbar error <span class="anim-type t-slide">SLIDE</span></div>
<div class="anim-desc">Wjeżdża od dołu z lekkim overshootem. Znika z fade po timeout</div>
</div>
</div>
</div>
<!-- ===== COMPOSER SCREEN ===== -->
<div class="screen-section">
<div class="screen-title">✏️ Composer Screen — 6 animacji</div>
<div class="anim-grid">
<div class="anim-item">
<div class="anim-name">Image preview <span class="anim-type t-scale">SCALE</span></div>
<div class="anim-desc">Po wybraniu zdjęcia — preview skaluje się z 0 z bouncy spring. Przycisk "X" rotuje wchodząc</div>
</div>
<div class="anim-item">
<div class="anim-name">Link preview card <span class="anim-type t-slide">SLIDE</span></div>
<div class="anim-desc">Po załadowaniu — karta wysuwa się od dołu z fade. Shimmer placeholder podczas ładowania</div>
</div>
<div class="anim-item">
<div class="anim-name">Schedule chip <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Po wybraniu daty chip "wyskakuje" sprężyście. Ikonka zegara lekko się obraca</div>
</div>
<div class="anim-item">
<div class="anim-name">Publish button <span class="anim-type t-bounce">BOUNCE</span></div>
<div class="anim-desc">Delikatny bounce przy aktywacji. Podczas publishingu — loading pulse. Po sukcesie — checkmark z scale-in</div>
</div>
<div class="anim-item">
<div class="anim-name">Character counter <span class="anim-type t-fade">FADE</span></div>
<div class="anim-desc">Płynna zmiana koloru (crossfade) przy przekroczeniu 280 znaków — neutral → czerwony</div>
</div>
<div class="anim-item">
<div class="anim-name">Action buttons <span class="anim-type t-scale">SCALE</span></div>
<div class="anim-desc">Rząd przycisków (Draft, Schedule, Publish) — kaskadowy scale-in z 50ms opóźnieniem</div>
</div>
</div>
</div>
<!-- ===== DETAIL SCREEN ===== -->
<div class="screen-section">
<div class="screen-title">📖 Detail Screen — 4 animacje</div>
<div class="anim-grid">
<div class="anim-item">
<div class="anim-name">Content reveal <span class="anim-type t-fade">FADE</span></div>
<div class="anim-desc">Elementy pojawiają się sekwencyjnie: status → tekst → obraz → metadata. Każdy z 80ms opóźnieniem</div>
</div>
<div class="anim-item">
<div class="anim-name">Status badge <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Badge skaluje się z 0 z bounce — pierwszy element na ekranie, przyciąga uwagę</div>
</div>
<div class="anim-item">
<div class="anim-name">Delete dialog <span class="anim-type t-scale">SCALE</span></div>
<div class="anim-desc">Dialog skaluje się z centrum ekranu (0.8→1.0) + backdrop fade. Sprężyste wejście</div>
</div>
<div class="anim-item">
<div class="anim-name">Metadata sekcja <span class="anim-type t-slide">SLIDE</span></div>
<div class="anim-desc">Dolna sekcja z metadanymi wysuwa się od dołu — ostatnia w sekwencji reveal</div>
</div>
</div>
</div>
<!-- ===== SETTINGS SCREEN ===== -->
<div class="screen-section">
<div class="screen-title">⚙️ Settings Screen — 2 animacje</div>
<div class="anim-grid">
<div class="anim-item">
<div class="anim-name">"Saved!" feedback <span class="anim-type t-spring">SPRING</span></div>
<div class="anim-desc">Zielony tekst "Saved!" wyskakuje z bounce (scale 0→1). Po 2s fade out</div>
</div>
<div class="anim-item">
<div class="anim-name">Disconnect dialog <span class="anim-type t-fade">FADE</span></div>
<div class="anim-desc">Analogicznie do delete — scale z centrum + backdrop. Przycisk "Disconnect" z lekkim czerwonym pulsem</div>
</div>
</div>
</div>
<!-- ===== SHARED SPECS ===== -->
<div class="spec-box">
<h3>🎛️ SwooshMotion — wspólne parametry</h3>
<div class="spec-grid">
<div class="spec-item">
<div class="spec-name">Bouncy</div>
<div class="spec-val">Spring: damping=0.55, stiffness=400<br>→ FAB, buttony, chipy</div>
</div>
<div class="spec-item">
<div class="spec-name">Snappy</div>
<div class="spec-val">Spring: damping=0.7, stiffness=800<br>→ expand/collapse, dialogi</div>
</div>
<div class="spec-item">
<div class="spec-name">Gentle</div>
<div class="spec-val">Spring: damping=0.8, stiffness=300<br>→ karty, content reveal</div>
</div>
<div class="spec-item">
<div class="spec-name">Quick</div>
<div class="spec-val">Tween: 200ms FastOutSlowIn<br>→ fade, color transitions</div>
</div>
</div>
</div>
<!-- ===== NAVIGATION ===== -->
<div class="spec-box" style="margin-top: 14px;">
<h3>🔀 Przejścia nawigacyjne</h3>
<table class="nav-table">
<tr><td>Feed → Composer</td><td>Slide up z dołu ekranu + fade. FAB "transformuje się" w pełny ekran</td></tr>
<tr><td>Feed → Detail</td><td>Slide in z prawej + fade. Karta posta płynnie przechodzi w pełny widok</td></tr>
<tr><td>Feed → Settings</td><td>Standardowy slide z prawej</td></tr>
<tr><td>Back (powrót)</td><td>Reverse wejścia — slide out w odpowiednią stronę</td></tr>
<tr><td>Setup → Feed</td><td>Crossfade — płynne przejście z animowanego tła setup do feedu</td></tr>
</table>
</div>

View file

@ -0,0 +1,104 @@
<h2>Mapa mikro-animacji Swoosh</h2>
<p class="subtitle">Przegląd wszystkich proponowanych animacji per ekran. Kliknij ekran aby zobaczyć szczegóły.</p>
<style>
.screen-map { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin: 24px 0; }
.screen-card { background: var(--card-bg, #1a1a2e); border: 1px solid var(--border, #333); border-radius: 12px; padding: 20px; cursor: default; transition: transform 0.2s; }
.screen-card:hover { transform: translateY(-2px); }
.screen-name { font-size: 1.1em; font-weight: 700; color: var(--accent, #6c63ff); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
.screen-name .icon { font-size: 1.4em; }
.anim-list { list-style: none; padding: 0; margin: 0; }
.anim-list li { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); display: flex; align-items: flex-start; gap: 8px; font-size: 0.9em; }
.anim-list li:last-child { border: none; }
.anim-tag { font-size: 0.7em; padding: 2px 6px; border-radius: 4px; font-weight: 600; white-space: nowrap; flex-shrink: 0; margin-top: 2px; }
.tag-spring { background: #2d1b69; color: #b39ddb; }
.tag-fade { background: #1b3a2d; color: #81c784; }
.tag-slide { background: #3a2a1b; color: #ffb74d; }
.tag-scale { background: #1b2d3a; color: #64b5f6; }
.tag-bounce { background: #3a1b2d; color: #f06292; }
.count-badge { background: var(--accent, #6c63ff); color: white; font-size: 0.75em; padding: 2px 8px; border-radius: 10px; margin-left: auto; }
.shared-section { margin-top: 32px; padding: 20px; background: rgba(108,99,255,0.08); border-radius: 12px; border: 1px solid rgba(108,99,255,0.2); }
.shared-section h3 { margin-top: 0; color: var(--accent, #6c63ff); }
.spec-table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.85em; }
.spec-table th { text-align: left; padding: 8px; border-bottom: 1px solid var(--border, #333); color: var(--accent, #6c63ff); }
.spec-table td { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); }
.spec-table code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
</style>
<div class="screen-map">
<!-- FEED SCREEN -->
<div class="screen-card">
<div class="screen-name"><span class="icon">📋</span> Feed Screen <span class="count-badge">7</span></div>
<ul class="anim-list">
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>FAB wejście</b> — scale 0→1 z overshoot przy starcie ekranu</span></li>
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>FAB press</b> — sprężyste zmniejszenie przy tapnięciu (0.85→1.0)</span></li>
<li><span class="anim-tag tag-slide">SLIDE</span> <span><b>Karty postów</b> — staggered wejście od dołu, każda karta z lekkim opóźnieniem</span></li>
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>"Show more" expand</b> — animateContentSize ze sprężyną</span></li>
<li><span class="anim-tag tag-fade">FADE</span> <span><b>Empty state</b> — fade in + delikatny scale up</span></li>
<li><span class="anim-tag tag-bounce">BOUNCE</span> <span><b>Queue status</b> — pulsujący chip podczas uploadu, bounce przy zmianie statusu</span></li>
<li><span class="anim-tag tag-slide">SLIDE</span> <span><b>Snackbar</b> — slide in od dołu z overshoot</span></li>
</ul>
</div>
<!-- COMPOSER SCREEN -->
<div class="screen-card">
<div class="screen-name"><span class="icon">✏️</span> Composer Screen <span class="count-badge">6</span></div>
<ul class="anim-list">
<li><span class="anim-tag tag-scale">SCALE</span> <span><b>Image preview</b> — scale in z spring przy dodaniu zdjęcia</span></li>
<li><span class="anim-tag tag-slide">SLIDE</span> <span><b>Link preview card</b> — slide up + fade in po załadowaniu</span></li>
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>Schedule chip</b> — sprężyste pojawienie się po wybraniu daty</span></li>
<li><span class="anim-tag tag-bounce">BOUNCE</span> <span><b>Publish button</b> — subtle bounce na hover/focus, loading pulse</span></li>
<li><span class="anim-tag tag-fade">FADE</span> <span><b>Character counter</b> — color crossfade przy przekroczeniu limitu</span></li>
<li><span class="anim-tag tag-scale">SCALE</span> <span><b>Action buttons row</b> — staggered scale-in wejście</span></li>
</ul>
</div>
<!-- DETAIL SCREEN -->
<div class="screen-card">
<div class="screen-name"><span class="icon">📖</span> Detail Screen <span class="count-badge">4</span></div>
<ul class="anim-list">
<li><span class="anim-tag tag-fade">FADE</span> <span><b>Content reveal</b> — sekwencyjne fade-in elementów (status → tekst → obraz → metadata)</span></li>
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>Status badge</b> — scale-in z bounce przy wejściu</span></li>
<li><span class="anim-tag tag-scale">SCALE</span> <span><b>Delete dialog</b> — scale z center + backdrop fade</span></li>
<li><span class="anim-tag tag-slide">SLIDE</span> <span><b>Metadata sekcja</b> — slide up od dolnej krawędzi</span></li>
</ul>
</div>
<!-- SETTINGS SCREEN -->
<div class="screen-card">
<div class="screen-name"><span class="icon">⚙️</span> Settings Screen <span class="count-badge">2</span></div>
<ul class="anim-list">
<li><span class="anim-tag tag-spring">SPRING</span> <span><b>"Saved!" feedback</b> — scale-in z bounce + auto fade-out po 2s</span></li>
<li><span class="anim-tag tag-fade">FADE</span> <span><b>Disconnect confirm</b> — dialog fade + scale</span></li>
</ul>
</div>
</div>
<!-- SHARED ANIMATION SPECS -->
<div class="shared-section">
<h3>🎛️ Wspólne parametry animacji (SwooshMotion)</h3>
<p style="font-size:0.9em; opacity:0.8;">Centralny obiekt z predefiniowanymi specyfikacjami — zapewnia spójny "character" w całej aplikacji</p>
<table class="spec-table">
<tr><th>Nazwa</th><th>Typ</th><th>Parametry</th><th>Zastosowanie</th></tr>
<tr><td><code>Bouncy</code></td><td>Spring</td><td>dampingRatio=0.55, stiffness=400</td><td>FAB, buttony, chipy</td></tr>
<tr><td><code>Snappy</code></td><td>Spring</td><td>dampingRatio=0.7, stiffness=800</td><td>Expand/collapse, dialogi</td></tr>
<tr><td><code>Gentle</code></td><td>Spring</td><td>dampingRatio=0.8, stiffness=300</td><td>Karty, content reveal</td></tr>
<tr><td><code>Quick</code></td><td>Tween</td><td>200ms, FastOutSlowIn</td><td>Fade, color transitions</td></tr>
<tr><td><code>StaggerDelay</code></td><td>Offset</td><td>50ms per item</td><td>List item wejścia</td></tr>
</table>
</div>
<!-- NAVIGATION -->
<div class="shared-section" style="margin-top: 16px;">
<h3>🔀 Przejścia między ekranami</h3>
<table class="spec-table">
<tr><th>Przejście</th><th>Animacja</th></tr>
<tr><td>Feed → Composer</td><td>Slide up z dołu + fade (shared element z FAB → ekran)</td></tr>
<tr><td>Feed → Detail</td><td>Slide in z prawej + fade (karta "wyrasta" w pełny widok)</td></tr>
<tr><td>Feed → Settings</td><td>Slide in z prawej, standard</td></tr>
<tr><td>Composer/Detail → Back</td><td>Reverse odpowiedniego wejścia</td></tr>
<tr><td>Setup → Feed</td><td>Crossfade (płynne przejście z animowanego tła)</td></tr>
</table>
</div>

View file

@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.db
import androidx.room.* import androidx.room.*
import com.swoosh.microblog.data.model.LocalPost import com.swoosh.microblog.data.model.LocalPost
import com.swoosh.microblog.data.model.PostStatus
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -39,4 +40,13 @@ interface LocalPostDao {
@Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId") @Query("UPDATE local_posts SET ghostId = :ghostId, queueStatus = :status WHERE localId = :localId")
suspend fun markUploaded(localId: Long, ghostId: String, status: QueueStatus = QueueStatus.NONE) 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<LocalPost>
} }

View file

@ -41,7 +41,8 @@ data class GhostPost(
val published_at: String? = null, val published_at: String? = null,
val custom_excerpt: String? = null, val custom_excerpt: String? = null,
val visibility: String? = "public", val visibility: String? = "public",
val authors: List<Author>? = null val authors: List<Author>? = null,
val reading_time: Int? = null
) )
data class Author( data class Author(

View file

@ -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<LocalPost>,
remotePosts: List<FeedPost>
): OverallStats {
// Combine all text content
val allTexts = mutableListOf<String>()
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
)
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -142,6 +142,14 @@ class PostRepository(private val context: Context) {
suspend fun markUploaded(localId: Long, ghostId: String) = suspend fun markUploaded(localId: Long, ghostId: String) =
dao.markUploaded(localId, ghostId) 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<LocalPost> = dao.getAllPostsList()
// --- Connectivity check --- // --- Connectivity check ---
fun isNetworkAvailable(): Boolean { fun isNetworkAvailable(): Boolean {

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.PostStats
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@ -90,12 +91,17 @@ fun ComposerScreen(
.heightIn(min = 150.dp), .heightIn(min = 150.dp),
placeholder = { Text("What's on your mind?") }, placeholder = { Text("What's on your mind?") },
supportingText = { 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( Text(
"${state.text.length} characters", text = statsText,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (state.text.length > 280) color = color
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
) )

View file

@ -1,20 +1,32 @@
package com.swoosh.microblog.ui.detail 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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Delete
import androidx.compose.material.icons.filled.Edit 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost 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.StatusBadge
import com.swoosh.microblog.ui.feed.formatRelativeTime import com.swoosh.microblog.ui.feed.formatRelativeTime
@ -130,18 +142,9 @@ fun DetailScreen(
} }
} }
// Metadata // Stats section
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Divider() PostStatsSection(post)
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() })
} }
} }
@ -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 @Composable
private fun MetadataRow(label: String, value: String) { private fun MetadataRow(label: String, value: String) {
Row( Row(

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
@ -30,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.model.FeedPost 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.data.model.QueueStatus
import com.swoosh.microblog.ui.theme.ThemeMode import com.swoosh.microblog.ui.theme.ThemeMode
import com.swoosh.microblog.ui.theme.ThemeViewModel 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)
)
}
}
} }
} }
} }

View file

@ -13,6 +13,7 @@ import com.swoosh.microblog.ui.feed.FeedScreen
import com.swoosh.microblog.ui.feed.FeedViewModel import com.swoosh.microblog.ui.feed.FeedViewModel
import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.setup.SetupScreen
import com.swoosh.microblog.ui.stats.StatsScreen
import com.swoosh.microblog.ui.theme.ThemeViewModel import com.swoosh.microblog.ui.theme.ThemeViewModel
object Routes { object Routes {
@ -21,6 +22,7 @@ object Routes {
const val COMPOSER = "composer" const val COMPOSER = "composer"
const val DETAIL = "detail" const val DETAIL = "detail"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val STATS = "stats"
} }
@Composable @Composable
@ -101,8 +103,17 @@ fun SwooshNavGraph(
navController.navigate(Routes.SETUP) { navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true } popUpTo(0) { inclusive = true }
} }
},
onStatsClick = {
navController.navigate(Routes.STATS)
} }
) )
} }
composable(Routes.STATS) {
StatsScreen(
onBack = { navController.popBackStack() }
)
}
} }
} }

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
@ -28,7 +29,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
themeViewModel: ThemeViewModel? = null themeViewModel: ThemeViewModel? = null,
onStatsClick: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val credentials = remember { CredentialsManager(context) } val credentials = remember { CredentialsManager(context) }
@ -123,6 +125,24 @@ fun SettingsScreen(
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) 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( OutlinedButton(
onClick = { onClick = {
credentials.clear() credentials.clear()

View file

@ -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
)
}
}

View file

@ -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<StatsUiState> = _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<FeedPost>()
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
)

View file

@ -77,6 +77,14 @@ class GhostModelsTest {
assertNull(post.published_at) assertNull(post.published_at)
assertNull(post.custom_excerpt) assertNull(post.custom_excerpt)
assertNull(post.authors) 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 --- // --- FeedPost ---

View file

@ -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
)
}
}

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff