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 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<LocalPost>
}

View file

@ -41,7 +41,8 @@ data class GhostPost(
val published_at: String? = null,
val custom_excerpt: String? = null,
val visibility: String? = "public",
val authors: List<Author>? = null
val authors: List<Author>? = null,
val reading_time: Int? = null
)
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) =
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 ---
fun isNetworkAvailable(): Boolean {

View file

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

View file

@ -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(

View file

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

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.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() }
)
}
}
}

View file

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

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

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