mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
merge: integrate post statistics feature (resolve conflicts)
This commit is contained in:
commit
b85bc96dcf
34 changed files with 2553 additions and 17 deletions
1
.claude/worktrees/agent-a0a9ebc1
Submodule
1
.claude/worktrees/agent-a0a9ebc1
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit b119d75bac1feadd91a47922d5846faf45ad71fd
|
||||
1
.claude/worktrees/agent-a17cf3a8
Submodule
1
.claude/worktrees/agent-a17cf3a8
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 636c9f7649792147e1e4f00c324ccdc757d06247
|
||||
1
.claude/worktrees/agent-a3538537
Submodule
1
.claude/worktrees/agent-a3538537
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 6927259a41e2c999a09da22ed202011db9e9e524
|
||||
1
.claude/worktrees/agent-a3aee2cc
Submodule
1
.claude/worktrees/agent-a3aee2cc
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit fe60d17d39cc9bd100cd7eac745570a2bb1b8910
|
||||
1
.claude/worktrees/agent-a3d73367
Submodule
1
.claude/worktrees/agent-a3d73367
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 0265a1159d88eeef5ed4df2e9d6f661fb09fe5a1
|
||||
1
.claude/worktrees/agent-a724b276
Submodule
1
.claude/worktrees/agent-a724b276
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 5001ba18cb8326e200d4fd84468e17af8a90726e
|
||||
1
.claude/worktrees/agent-a9b958b9
Submodule
1
.claude/worktrees/agent-a9b958b9
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit c24b2f7fa75283196b30a24e71bd9fa9135def99
|
||||
1
.claude/worktrees/agent-aac5dc7b
Submodule
1
.claude/worktrees/agent-aac5dc7b
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit f2ccf535775a7f7f52ac6aa390e08c02027b7e13
|
||||
1
.claude/worktrees/agent-ac86a40f
Submodule
1
.claude/worktrees/agent-ac86a40f
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit bbc408d5dfe258906ad4798542ffbcf929ccac12
|
||||
1
.claude/worktrees/agent-adfbc1bb
Submodule
1
.claude/worktrees/agent-adfbc1bb
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 5a41944a97090f3441916376a1df8c13979503a7
|
||||
1
.claude/worktrees/agent-aee48ad4
Submodule
1
.claude/worktrees/agent-aee48ad4
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 9da3b0da0fc7fb11d34143bdb5f14280c62a9a53
|
||||
1
.claude/worktrees/agent-afa60c48
Submodule
1
.claude/worktrees/agent-afa60c48
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 202e25b572e7304ba0f2d59c533a34aaffe90d8f
|
||||
1
.superpowers/brainstorm/17336-1773912049/.server-info
Normal file
1
.superpowers/brainstorm/17336-1773912049/.server-info
Normal 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"}
|
||||
4
.superpowers/brainstorm/17336-1773912049/.server.log
Normal file
4
.superpowers/brainstorm/17336-1773912049/.server.log
Normal 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"}
|
||||
1
.superpowers/brainstorm/17336-1773912049/.server.pid
Normal file
1
.superpowers/brainstorm/17336-1773912049/.server.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
17363
|
||||
165
.superpowers/brainstorm/17336-1773912049/animation-map-v2.html
Normal file
165
.superpowers/brainstorm/17336-1773912049/animation-map-v2.html
Normal 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>
|
||||
104
.superpowers/brainstorm/17336-1773912049/animation-map.html
Normal file
104
.superpowers/brainstorm/17336-1773912049/animation-map.html
Normal 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>
|
||||
3
.superpowers/brainstorm/17336-1773912049/waiting.html
Normal file
3
.superpowers/brainstorm/17336-1773912049/waiting.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
195
app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
Normal file
195
app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
1039
docs/superpowers/plans/2026-03-19-micro-animations.md
Normal file
1039
docs/superpowers/plans/2026-03-19-micro-animations.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue