Swoosh/docs/superpowers/plans/2026-03-19-ghost-api-features.md

1443 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Ghost API Features Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add 8 new Ghost Admin API features to Swoosh: Site Metadata, Tags CRUD, Members API, Newsletter Sending, Email-only Posts, Media Upload, File Upload, and Pages API.
**Architecture:** Each feature is a self-contained phase adding new API endpoints to `GhostApiService`, response models to `data/model/`, repository methods, and corresponding UI screens/modifications. Newsletter features (phases 4a+4b) have a Settings toggle to hide/show them. All phases follow existing MVVM + Repository pattern.
**Tech Stack:** Kotlin 1.9.22, Jetpack Compose (Material 3), Retrofit 2.9.0, Room 2.6.1, Coil 2.5.0, WorkManager 2.9.0, ExoPlayer/Media3 (new for Phase 6).
---
## Dependency Graph
```
Phase 0: DB Migration (prerequisite for 4b, 5, 6 — adds all new LocalPost columns)
Phase 1: Site Metadata (no deps)
Phase 2: Tags CRUD (no deps)
Phase 3: Members API (no deps)
Phase 4a: Newsletter (no deps, but needs Settings toggle)
Phase 4b: Email-only (depends on 4a + Phase 0)
Phase 5: Media Upload (depends on Phase 0)
Phase 6: File Upload (depends on Phase 0)
Phase 7: Pages API (no deps)
```
Phases 1, 2, 3, 7 are fully independent and can be implemented in parallel.
Phase 0 must complete before 4b, 5, or 6. Phase 4b depends on 4a.
All of Phase 4 has a per-account Settings toggle to hide/show newsletter features.
**Navigation note:** New sub-screens (Tags, Members, MemberDetail, Pages) are NOT added to `bottomBarRoutes` — they are detail screens navigated from Settings/Stats, and the bottom bar should be hidden. SettingsScreen is modified to accept navigation callbacks (e.g., `onNavigateToTags`, `onNavigateToPages`) — NOT a `navController` — consistent with its existing callback pattern.
---
## File Map (all phases)
### New files to create:
```
data/model/
├── SiteModels.kt (Phase 1: GhostSite — no wrapper, Ghost returns site object directly)
├── TagModels.kt (Phase 2: extended GhostTag, TagsResponse, TagWrapper)
├── MemberModels.kt (Phase 3: GhostMember, MembersResponse)
├── NewsletterModels.kt (Phase 4a: GhostNewsletter, NewslettersResponse)
├── PageModels.kt (Phase 7: GhostPage, PagesResponse, PageWrapper)
├── MediaModels.kt (Phase 5: MediaUploadResponse, UploadedMedia)
└── FileModels.kt (Phase 6: FileUploadResponse, UploadedFile)
data/
├── SiteMetadataCache.kt (Phase 1: per-account site metadata storage)
└── NewsletterPreferences.kt (Phase 4a: toggle + cached newsletter list)
data/repository/
├── TagRepository.kt (Phase 2)
├── MemberRepository.kt (Phase 3)
└── PageRepository.kt (Phase 7)
ui/tags/
├── TagsScreen.kt (Phase 2: tag management list + edit)
└── TagsViewModel.kt (Phase 2)
ui/members/
├── MembersScreen.kt (Phase 3: member list)
├── MemberDetailScreen.kt (Phase 3: member profile)
└── MembersViewModel.kt (Phase 3)
ui/pages/
├── PagesScreen.kt (Phase 7: page list)
├── PageComposerScreen.kt (Phase 7: reuses composer logic)
└── PagesViewModel.kt (Phase 7)
```
### Files to modify:
```
data/api/GhostApiService.kt — all phases (new endpoints)
data/model/GhostModels.kt — phases 4a, 4b (PostStatus.SENT, PostFilter.SENT, QueueStatus, GhostPost fields)
data/MobiledocBuilder.kt — phases 5, 6 (video, audio, file cards)
data/repository/PostRepository.kt — phases 5, 6 (uploadMedia, uploadFile methods)
data/db/AppDatabase.kt — phase 4b (migration v3→v4, new LocalPost columns)
data/AccountManager.kt — phase 1 (store site metadata per account)
ui/composer/ComposerViewModel.kt — phases 2, 4a, 4b, 5, 6
ui/composer/ComposerScreen.kt — phases 1, 2, 4a, 4b, 5, 6
ui/feed/FeedScreen.kt — phases 1, 2, 4b
ui/feed/FeedViewModel.kt — phase 4b
ui/settings/SettingsScreen.kt — phases 1, 4a, 7
ui/stats/StatsScreen.kt — phases 2, 3
ui/stats/StatsViewModel.kt — phases 2, 3
ui/setup/SetupViewModel.kt — phase 1
ui/setup/SetupScreen.kt — phase 1
ui/detail/DetailScreen.kt — phases 4b, 5, 6
ui/navigation/NavGraph.kt — phases 2, 3, 7
worker/PostUploadWorker.kt — phases 4a, 4b, 5, 6
```
---
## Phase 0: Database Migration (prerequisite for Phases 4b, 5, 6)
### Task 0.1: Add all new LocalPost columns in one migration
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/db/Converters.kt`
- [ ] **Step 1: Add new columns to LocalPost entity**
Add to `LocalPost` data class:
```kotlin
// Phase 4b: email-only
val emailOnly: Boolean = false,
val newsletterSlug: String? = null,
// Phase 5: media
val videoUri: String? = null,
val uploadedVideoUrl: String? = null,
val audioUri: String? = null,
val uploadedAudioUrl: String? = null,
// Phase 6: file
val fileUri: String? = null,
val uploadedFileUrl: String? = null,
val fileName: String? = null
```
- [ ] **Step 2: Write migration v3→v4**
Use the existing `addColumnsIfMissing` pattern (from `AppDatabase.kt` lines 22-35) which safely ignores already-existing columns:
```kotlin
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
val columns = listOf(
"ALTER TABLE local_posts ADD COLUMN emailOnly INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE local_posts ADD COLUMN newsletterSlug TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN videoUri TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN uploadedVideoUrl TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN audioUri TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN uploadedAudioUrl TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN fileUri TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN uploadedFileUrl TEXT DEFAULT NULL",
"ALTER TABLE local_posts ADD COLUMN fileName TEXT DEFAULT NULL"
)
for (sql in columns) {
try { db.execSQL(sql) } catch (_: Exception) { }
}
}
}
```
Bump database version to 4. Register `MIGRATION_3_4` in `getInstance()`.
- [ ] **Step 3: Add safety fallback in Converters**
In `Converters.kt`, wrap `QueueStatus.valueOf(value)` in try/catch with `QueueStatus.NONE` fallback, so existing DB rows with old enum values don't crash when `QUEUED_EMAIL_ONLY` is added.
- [ ] **Step 4: Run tests, commit**
```bash
./gradlew app:testDebugUnitTest
git commit -m "feat: add DB migration v3→v4 with new LocalPost columns for email, media, files"
```
---
## Phase 1: Site Metadata
### Task 1.1: API model + endpoint
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/SiteModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/model/SiteModelsTest.kt`
- [ ] **Step 1: Write SiteModels.kt**
Note: Ghost `/site/` returns the site object directly (NOT wrapped in a `site` key).
```kotlin
package com.swoosh.microblog.data.model
data class GhostSite(
val title: String?,
val description: String?,
val logo: String?,
val icon: String?,
val accent_color: String?,
val url: String?,
val version: String?,
val locale: String?
)
```
- [ ] **Step 2: Add endpoint to GhostApiService**
Add to `GhostApiService.kt`:
```kotlin
@GET("ghost/api/admin/site/")
suspend fun getSite(): Response<GhostSite>
```
- [ ] **Step 3: Write test for GhostSite model parsing**
Test that Gson correctly deserializes a JSON response into `SiteResponse`. Test `version` parsing for compatibility checks.
- [ ] **Step 4: Run tests, commit**
```bash
./gradlew app:testDebugUnitTest --tests "*.SiteModelsTest"
git commit -m "feat: add Ghost Site API model and endpoint"
```
### Task 1.2: Site metadata cache (per-account)
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/SiteMetadataCache.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/SiteMetadataCacheTest.kt`
- [ ] **Step 1: Write SiteMetadataCache**
Uses SharedPreferences (plain, not encrypted — site metadata is not sensitive). Stores serialized `GhostSite` per account ID. Methods: `save(accountId, site)`, `get(accountId): GhostSite?`, `getVersion(accountId): String?`. Uses `Gson` for serialization (same pattern as `AccountManager`).
- [ ] **Step 2: Write tests** — save/get round-trip, returns null for unknown account
- [ ] **Step 3: Run tests, commit**
```bash
./gradlew app:testDebugUnitTest --tests "*.SiteMetadataCacheTest"
git commit -m "feat: add per-account site metadata cache"
```
### Task 1.3: Fetch site metadata in Setup flow
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt`
- [ ] **Step 1: Add site metadata to SetupUiState**
Add fields: `val siteName: String? = null`, `val siteDescription: String? = null`, `val siteIcon: String? = null`, `val siteVersion: String? = null`, `val showConfirmation: Boolean = false`, `val versionWarning: Boolean = false`.
- [ ] **Step 2: Modify `save()` in SetupViewModel**
After successful `addAccount()`, call `getApi().getSite()`. Parse response, populate UI state fields, set `showConfirmation = true`. Check if `version` major < 5 set `versionWarning = true`. Cache site metadata in `SiteMetadataCache`. If `/site/` fails, fall back to existing behavior (test via fetchPosts).
- [ ] **Step 3: Add confirmation card to SetupScreen**
When `state.showConfirmation == true`, show a `Card` with: site icon (Coil `AsyncImage`), site title, site description, site URL, Ghost version. Version warning banner if `state.versionWarning`. Two buttons: "Tak, połącz" (confirms, navigates to Feed) and "Wstecz" (goes back).
- [ ] **Step 4: Test manually, commit**
```bash
./gradlew assembleDebug
git commit -m "feat: show blog confirmation card in Setup flow with version check"
```
### Task 1.4: Blog info section in Settings
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
- [ ] **Step 1: Add blog info card above "Current Account"**
Read `SiteMetadataCache` for active account. If site data exists, show a `Card` with: site logo/icon (Coil, fallback to colored initial letter), site title, site description (italic), blog URL, Ghost version, locale. Add `OutlinedButton("Open Ghost Admin")` that opens `{blogUrl}/ghost/` via `Intent(ACTION_VIEW)`.
- [ ] **Step 2: Show version warning banner if Ghost < 5.0**
`ElevatedCard` with `containerColor = colorScheme.errorContainer` if version major < 5.
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add blog info section with version warning in Settings"
```
### Task 1.5: Blog name + icon in Feed topbar
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- [ ] **Step 1: Replace "Swoosh" in TopAppBar with blog name from SiteMetadataCache**
Load `SiteMetadataCache(context).get(activeAccountId)`. If available, show site icon (small `AsyncImage`, 24dp, circular clip) + site title (truncated to ~20 chars with ellipsis). If not available, keep showing "Swoosh" as fallback.
- [ ] **Step 2: Commit**
```bash
git commit -m "feat: show blog name and icon in Feed topbar"
```
### Task 1.6: "Publishing to" chip in Composer (multi-account)
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- [ ] **Step 1: Add blog name chip at top of composer**
Only show when `AccountManager.getAccounts().size > 1`. Read site title from `SiteMetadataCache`. Show as `AssistChip` with site icon and text "Publishing to: {siteName}". Non-clickable, informational only.
- [ ] **Step 2: Commit**
```bash
git commit -m "feat: show 'publishing to' chip in Composer for multi-account"
```
---
## Phase 2: Tags CRUD
### Task 2.1: Extended tag model + API endpoints
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt`
- [ ] **Step 1: Write TagModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class TagsResponse(
val tags: List<GhostTagFull>,
val meta: Meta?
)
data class TagWrapper(
val tags: List<GhostTagFull>
)
data class GhostTagFull(
val id: String? = null,
val name: String,
val slug: String? = null,
val description: String? = null,
val feature_image: String? = null,
val visibility: String? = "public",
val accent_color: String? = null,
val count: TagCount? = null,
val created_at: String? = null,
val updated_at: String? = null,
val url: String? = null
)
data class TagCount(
val posts: Int?
)
```
- [ ] **Step 2: Add endpoints to GhostApiService**
```kotlin
@GET("ghost/api/admin/tags/")
suspend fun getTags(
@Query("limit") limit: String = "all",
@Query("include") include: String = "count.posts"
): Response<TagsResponse>
@GET("ghost/api/admin/tags/{id}/")
suspend fun getTag(@Path("id") id: String): Response<TagsResponse>
@POST("ghost/api/admin/tags/")
@Headers("Content-Type: application/json")
suspend fun createTag(@Body body: TagWrapper): Response<TagsResponse>
@PUT("ghost/api/admin/tags/{id}/")
@Headers("Content-Type: application/json")
suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response<TagsResponse>
@DELETE("ghost/api/admin/tags/{id}/")
suspend fun deleteTag(@Path("id") id: String): Response<Unit>
```
- [ ] **Step 3: Write tests, run, commit**
```bash
./gradlew app:testDebugUnitTest --tests "*.TagModelsTest"
git commit -m "feat: add Tags CRUD API models and endpoints"
```
### Task 2.2: TagRepository
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/repository/TagRepository.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/repository/TagRepositoryTest.kt`
- [ ] **Step 1: Write TagRepository**
Follow `PostRepository` pattern. Constructor takes `Context`, creates `AccountManager` and uses `ApiClient.getService()`. Methods:
- `suspend fun fetchTags(): Result<List<GhostTagFull>>` GET all tags with count.posts
- `suspend fun createTag(name: String, description: String?, accentColor: String?): Result<GhostTagFull>`
- `suspend fun updateTag(id: String, tag: GhostTagFull): Result<GhostTagFull>`
- `suspend fun deleteTag(id: String): Result<Unit>`
All wrapped in `withContext(Dispatchers.IO)` with try/catch, same pattern as PostRepository.
- [ ] **Step 2: Write test for TagRepository** mock or unit test for data mapping
- [ ] **Step 3: Run tests, commit**
```bash
git commit -m "feat: add TagRepository with CRUD operations"
```
### Task 2.3: Tag autocomplete in Composer
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- [ ] **Step 1: Add tag state to ComposerUiState**
Add fields: `val availableTags: List<GhostTagFull> = emptyList()`, `val tagSuggestions: List<GhostTagFull> = emptyList()`, `val tagInput: String = ""`.
- [ ] **Step 2: Add methods to ComposerViewModel**
- `init {}` block: launch coroutine to fetch tags from `TagRepository` into `availableTags`
- `fun updateTagInput(input: String)`: filter `availableTags` by input (case-insensitive contains on name), update `tagSuggestions`
- `fun addTag(tagName: String)`: add to existing `extractedTags`, clear `tagInput`
- `fun removeTag(tagName: String)`: remove from `extractedTags`
- [ ] **Step 3: Add autocomplete UI to ComposerScreen**
Below text input, add "Tags:" section with:
- `OutlinedTextField` for tag input
- `LazyColumn` dropdown showing `tagSuggestions` (each row: tag name + post count). Max 5 visible.
- Last item: "+ Create {input} as new tag"
- Below input: `FlowRow` of existing tags as `InputChip` with trailing X icon
- Tapping a suggestion calls `addTag(tag.name)`
- [ ] **Step 4: Test manually, commit**
```bash
./gradlew assembleDebug
git commit -m "feat: add tag autocomplete with suggestions in Composer"
```
### Task 2.4: Tags management screen
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt`
- Create: `app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
- [ ] **Step 1: Write TagsViewModel**
Uses `TagRepository`. State: `tags: List<GhostTagFull>`, `isLoading`, `error`, `searchQuery`, `editingTag: GhostTagFull?`. Methods: `loadTags()`, `searchTags(query)`, `saveTag(tag)`, `deleteTag(id)`.
- [ ] **Step 2: Write TagsScreen**
Two modes: List and Edit.
**List mode:** TopAppBar "Tags" with [+] button. `LazyColumn` of tags, each as `OutlinedCard` showing: accent_color dot, name, post count, description (if any). Internal tags marked `[internal]`. Search field at top. Tap switch to edit mode. Footer: "{n} tags · {m} posts total".
**Edit mode:** TopAppBar "Edit tag" with delete icon. Fields: name (`OutlinedTextField`), slug (read-only), description (`OutlinedTextField`), accent_color (color picker or hex input), visibility radio (Public / Internal). Post count (read-only info). Save button.
- [ ] **Step 3: Add route and navigation**
Add `Routes.TAGS = "tags"` to `NavGraph.kt`. Add `composable(Routes.TAGS)` with `TagsScreen`.
Modify `SettingsScreen` signature to add `onNavigateToTags: () -> Unit = {}` callback (consistent with existing `onBack`/`onLogout` pattern SettingsScreen uses callbacks, not navController). Add a clickable row "Tags " that calls `onNavigateToTags()`. Wire callback in NavGraph's Settings composable: `onNavigateToTags = { navController.navigate(Routes.TAGS) }`.
- [ ] **Step 4: Test manually, commit**
```bash
./gradlew assembleDebug
git commit -m "feat: add tag management screen accessible from Settings"
```
### Task 2.5: Tag filter chips in Feed
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- [ ] **Step 1: Add tag filter state to FeedViewModel**
Add `_popularTags: StateFlow<List<GhostTagFull>>`. On `refresh()`, also fetch tags from `TagRepository`, take top 10 by post count. The existing `activeTagFilter` StateFlow already handles the tag filter string.
- [ ] **Step 2: Add horizontal chip bar in FeedScreen**
Below the status filter chips (`PostFilter`), add a `LazyRow` of `FilterChip` for tags. First chip: "All tags" (clears tag filter). Then one chip per popular tag. Selected chip: filled with tag's `accent_color` (parse hex, fallback to `primary`). Tapping calls `viewModel.updateTagFilter(tag.slug)` / `viewModel.clearTagFilter()`.
- [ ] **Step 3: Display tags on post cards**
In post card composable, if `post.tags` is non-empty, show tags below content as small `Text` items joined by " · ", styled `labelSmall`, color `onSurfaceVariant`.
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add tag filter chips in Feed and display tags on post cards"
```
### Task 2.6: Tag statistics in Stats screen
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt`
- [ ] **Step 1: Fetch tag data in StatsViewModel**
Add `TagRepository`. In `loadStats()`, also fetch tags. Add to state: `val tagStats: List<GhostTagFull> = emptyList()`.
- [ ] **Step 2: Add tag distribution section in StatsScreen**
After existing stats cards, add section "Tags post distribution". Horizontal bar chart using `LinearProgressIndicator` for each tag. Bar width proportional to post count / max count. Tag accent_color for bar color. Show: tag name, bar, count. Below: "Most used: {tag} ({percentage}%)", "Posts without tags: {n}" with warning icon.
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add tag distribution chart in Stats screen"
```
---
## Phase 3: Members API
### Task 3.1: Member model + API endpoints
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt`
- [ ] **Step 1: Write MemberModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class MembersResponse(
val members: List<GhostMember>,
val meta: Meta?
)
data class GhostMember(
val id: String,
val email: String?,
val name: String?,
val status: String?, // "free" or "paid"
val avatar_image: String?,
val email_count: Int?,
val email_opened_count: Int?,
val email_open_rate: Double?, // 0.0-1.0 or null
val last_seen_at: String?,
val created_at: String?,
val updated_at: String?,
val labels: List<MemberLabel>?,
val newsletters: List<MemberNewsletter>?,
val subscriptions: List<MemberSubscription>?,
val note: String?,
val geolocation: String?
)
data class MemberLabel(
val id: String?,
val name: String,
val slug: String?
)
data class MemberNewsletter(
val id: String,
val name: String?,
val slug: String?
)
data class MemberSubscription(
val id: String?,
val status: String?,
val start_date: String?,
val current_period_end: String?,
val cancel_at_period_end: Boolean?,
val price: SubscriptionPrice?,
val tier: SubscriptionTier?
)
data class SubscriptionPrice(
val amount: Int?,
val currency: String?,
val interval: String? // "month" or "year"
)
data class SubscriptionTier(
val id: String?,
val name: String?
)
```
- [ ] **Step 2: Add endpoints to GhostApiService**
```kotlin
@GET("ghost/api/admin/members/")
suspend fun getMembers(
@Query("limit") limit: Int = 15,
@Query("page") page: Int = 1,
@Query("order") order: String = "created_at desc",
@Query("filter") filter: String? = null,
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
@GET("ghost/api/admin/members/{id}/")
suspend fun getMember(
@Path("id") id: String,
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
```
- [ ] **Step 3: Write tests, run, commit**
```bash
./gradlew app:testDebugUnitTest --tests "*.MemberModelsTest"
git commit -m "feat: add Members API models and endpoints"
```
### Task 3.2: MemberRepository
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/repository/MemberRepository.kt`
- [ ] **Step 1: Write MemberRepository**
Same pattern as PostRepository. Methods:
- `suspend fun fetchMembers(page: Int, limit: Int, filter: String?): Result<MembersResponse>`
- `suspend fun fetchMember(id: String): Result<GhostMember>`
- `suspend fun fetchAllMembers(): Result<List<GhostMember>>` paginated fetch all (limit 50, max 20 pages)
- `fun getMemberStats(members: List<GhostMember>): MemberStats` pure function calculating totals
```kotlin
data class MemberStats(
val total: Int,
val free: Int,
val paid: Int,
val newThisWeek: Int,
val avgOpenRate: Double?,
val mrr: Int // monthly recurring revenue in cents
)
```
- [ ] **Step 2: Commit**
```bash
git commit -m "feat: add MemberRepository with stats calculation"
```
### Task 3.3: Member stats tiles in Stats screen
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt`
- [ ] **Step 1: Add member stats to StatsUiState**
Add `val memberStats: MemberStats? = null`. In `loadStats()`, also call `MemberRepository.fetchAllMembers()`, compute `MemberStats`, update state. Wrap in try/catch if members API fails (e.g., Ghost instance doesn't support it), leave `memberStats` as null.
- [ ] **Step 2: Add member tiles to StatsScreen**
If `memberStats != null`, show a section "Members" with 2x3 grid of `ElevatedCard` tiles: Total ({total}), New this week (+{n}), Open rate ({pct}%), Free ({free}), Paid ({paid}), MRR (${amount}/mo). Each tile: animated counter (reuse existing animated counter pattern from stats). Button "See all members " navigating to members list.
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add member stats tiles in Stats screen"
```
### Task 3.4: Members list screen
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/members/MembersViewModel.kt`
- Create: `app/src/main/java/com/swoosh/microblog/ui/members/MembersScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
- [ ] **Step 1: Write MembersViewModel**
State: `members: List<GhostMember>`, `isLoading`, `hasMore`, `filter` (all/free/paid), `searchQuery`, `error`. Methods: `loadMembers()`, `loadMore()`, `updateFilter(filter)`, `search(query)`. Uses `MemberRepository` with pagination.
- [ ] **Step 2: Write MembersScreen**
TopAppBar "Members ({total})" with search icon. Search field. `SingleChoiceSegmentedButtonRow` for All/Free/Paid filter. `LazyColumn` of members: each row shows avatar (Coil or colored initial), name, email, open rate as tiny progress bar, relative time since `created_at`, badge 💎 for paid, badge "NEW" for `created_at` < 7 days. Load more pagination at bottom. Tap navigate to member detail.
- [ ] **Step 3: Add routes**
Add `Routes.MEMBERS = "members"`, `Routes.MEMBER_DETAIL = "member_detail"` to NavGraph. Wire navigation from Stats screen button.
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add Members list screen with filtering and search"
```
### Task 3.5: Member detail screen
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/members/MemberDetailScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
- [ ] **Step 1: Write MemberDetailScreen**
Scrollable `Column` with sections:
- **Header:** Large avatar, name, email
- **Quick stats:** 3 `ElevatedCard` tiles: status (Free/Paid), open rate, emails received/opened
- **Subscription** (paid only): tier name, amount, start date, status, Stripe ID (truncated)
- **Activity:** joined date, last seen (relative), geolocation
- **Newsletters:** checkboxes (read-only) showing subscribed newsletters
- **Labels:** `FlowRow` of `AssistChip`s
- **Email activity:** progress bar + "Opened: {n} of {total}"
- [ ] **Step 2: Wire into NavGraph**
Pass member ID or member object via navigation state (use `remember` pattern like `selectedPost`).
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add Member detail screen with subscription and activity info"
```
---
## Phase 4a: Newsletter Sending
### Task 4a.1: Newsletter model + API endpoint + preferences
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/NewsletterModels.kt`
- Create: `app/src/main/java/com/swoosh/microblog/data/NewsletterPreferences.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- [ ] **Step 1: Write NewsletterModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class NewslettersResponse(
val newsletters: List<GhostNewsletter>
)
data class GhostNewsletter(
val id: String,
val uuid: String?,
val name: String,
val slug: String,
val description: String?,
val status: String?, // "active" or "archived"
val visibility: String?,
val subscribe_on_signup: Boolean?,
val sort_order: Int?,
val sender_name: String?,
val sender_email: String?,
val created_at: String?,
val updated_at: String?
)
```
- [ ] **Step 2: Add endpoint to GhostApiService**
```kotlin
@GET("ghost/api/admin/newsletters/")
suspend fun getNewsletters(
@Query("filter") filter: String = "status:active",
@Query("limit") limit: String = "all"
): Response<NewslettersResponse>
```
Also add optional newsletter query params to existing `createPost` and `updatePost` (no separate methods same endpoint, just optional params):
```kotlin
// Modify existing createPost signature:
@POST("ghost/api/admin/posts/")
@Headers("Content-Type: application/json")
suspend fun createPost(
@Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse>
// Modify existing updatePost signature:
@PUT("ghost/api/admin/posts/{id}/")
@Headers("Content-Type: application/json")
suspend fun updatePost(
@Path("id") id: String,
@Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse>
```
Existing callers pass `null` for these params (default), so no breakage.
- [ ] **Step 3: Write NewsletterPreferences.kt**
Keyed per-account so enabling newsletters for one blog doesn't affect others:
```kotlin
class NewsletterPreferences(context: Context) {
private val prefs = context.getSharedPreferences("newsletter_prefs", Context.MODE_PRIVATE)
private val accountManager = AccountManager(context)
private fun activeAccountId(): String = accountManager.getActiveAccount()?.id ?: ""
fun isNewsletterEnabled(): Boolean =
prefs.getBoolean("newsletter_enabled_${activeAccountId()}", false)
fun setNewsletterEnabled(enabled: Boolean) =
prefs.edit().putBoolean("newsletter_enabled_${activeAccountId()}", enabled).apply()
}
```
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add Newsletter API models, endpoint, and preferences toggle"
```
### Task 4a.2: Newsletter toggle in Settings
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
- [ ] **Step 1: Add newsletter section in Settings**
After "Appearance" section, add "Newsletter" section with a `Switch` labeled "Enable newsletter features". When toggled ON for the first time, fetch newsletters from API to validate. Store in `NewsletterPreferences`. Show small info text: "Show newsletter sending options when publishing posts."
- [ ] **Step 2: Commit**
```bash
git commit -m "feat: add newsletter features toggle in Settings"
```
### Task 4a.3: Newsletter toggle in Composer publish dialog
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- [ ] **Step 1: Add newsletter state to ComposerUiState**
Add: `val newsletterEnabled: Boolean = false` (from prefs), `val availableNewsletters: List<GhostNewsletter> = emptyList()`, `val selectedNewsletter: GhostNewsletter? = null`, `val sendAsNewsletter: Boolean = false`, `val emailSegment: String = "all"`, `val showNewsletterConfirmation: Boolean = false`.
- [ ] **Step 2: Add newsletter methods to ComposerViewModel**
- `init`: check `NewsletterPreferences.isNewsletterEnabled`. If true, fetch newsletters.
- `fun toggleSendAsNewsletter()`: toggles `sendAsNewsletter`
- `fun selectNewsletter(newsletter)`: sets `selectedNewsletter`
- `fun setEmailSegment(segment: String)`: sets segment ("all", "status:free", "status:-free")
- `fun confirmNewsletterSend()`: sets `showNewsletterConfirmation = true`
- Modify `publish()`: if `sendAsNewsletter`, use `createPost (with newsletter params)()` endpoint
- [ ] **Step 3: Modify publish dialog in ComposerScreen**
When `newsletterEnabled` is true, add to the publish dialog:
- Divider after "Publish on blog" section
- `Switch` "Send as newsletter" (default OFF)
- When ON, reveal: newsletter picker (`RadioButton` list), segment picker (`RadioButton`: All/Free only/Paid only)
- Fetch subscriber count via lightweight call: `getMembers(limit=1)` read `meta.pagination.total`. If fails, show "subscribers" without number.
- Warning text: "⚠ Email will be sent to ~{count} subscribers. This cannot be undone."
- Change publish button color to `tertiaryContainer` (orange tone) and text to "Publish & Send Email ✉"
- [ ] **Step 4: Add confirmation dialog**
When user clicks "Publish & Send Email", show `AlertDialog` with:
- Summary: newsletter name, segment, subscriber count (or "subscribers" if count unavailable), post title
- Bold warning: "This operation is IRREVERSIBLE"
- Text field: type "WYSLIJ" to confirm (button disabled until correct)
- "Send Newsletter" button (enabled only after typing), "Back to editing" text button
- [ ] **Step 5: Modify PostRepository.createPost to support newsletter**
Add optional params `newsletter: String?` and `emailSegment: String?` to `PostRepository.createPost()` and `updatePost()`. Pass them through to the Retrofit service methods (which already have the optional query params from Step 2).
- [ ] **Step 6: Commit**
```bash
git commit -m "feat: add newsletter sending option in Composer with confirmation dialog"
```
---
## Phase 4b: Email-only Posts
**Depends on:** Phase 4a (newsletter infrastructure)
### Task 4b.1: Add SENT status and email_only field
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/data/model/GhostModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/db/AppDatabase.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/model/GhostModelsTest.kt`
- [ ] **Step 1: Extend PostStatus, QueueStatus, PostFilter**
```kotlin
enum class PostStatus { DRAFT, PUBLISHED, SCHEDULED, SENT }
enum class QueueStatus { NONE, QUEUED_PUBLISH, QUEUED_SCHEDULED, QUEUED_EMAIL_ONLY, UPLOADING, FAILED }
```
Add to `PostFilter`:
```kotlin
SENT("Sent", "status:sent");
```
Update `toPostStatus()` and `emptyMessage()` for SENT these are exhaustive `when` expressions that will fail to compile without the new case:
```kotlin
fun toPostStatus(): PostStatus? = when (this) {
ALL -> null
PUBLISHED -> PostStatus.PUBLISHED
DRAFT -> PostStatus.DRAFT
SCHEDULED -> PostStatus.SCHEDULED
SENT -> PostStatus.SENT
}
fun emptyMessage(): String = when (this) {
ALL -> "No posts yet"
PUBLISHED -> "No published posts yet"
DRAFT -> "No drafts yet"
SCHEDULED -> "No scheduled posts yet"
SENT -> "No sent newsletters yet"
}
```
Also update `Converters.toQueueStatus()` in `data/db/Converters.kt` to handle unknown values gracefully (e.g., wrap `QueueStatus.valueOf(value)` in try/catch with `QueueStatus.NONE` fallback) so existing DB rows with old values don't crash.
- [ ] **Step 2: Add email_only to GhostPost**
Add `val email_only: Boolean? = null` to `GhostPost`.
- [ ] **Step 3: Verify LocalPost columns exist**
Columns `emailOnly` and `newsletterSlug` were already added to `LocalPost` in Phase 0 (Task 0.1). Verify they exist and the migration is registered.
- [ ] **Step 4: Update FeedPost with emailOnly field**
Add `val emailOnly: Boolean = false` to `FeedPost`.
- [ ] **Step 6: Run tests, commit**
```bash
./gradlew app:testDebugUnitTest
git commit -m "feat: add SENT status, email_only field, and DB migration v3→v4"
```
### Task 4b.2: Email-only option in Composer
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt`
- [ ] **Step 1: Add "Send via Email Only" to dropdown**
In the publish dropdown menu (ComposerScreen), after "Schedule...", add a `HorizontalDivider()` then a `DropdownMenuItem` "Send via Email Only" with `Icons.Default.Email` icon. Only show when `NewsletterPreferences.isNewsletterEnabled`.
- [ ] **Step 2: Add emailOnly methods to ComposerViewModel**
- `fun sendEmailOnly()`: shows email-only confirmation dialog
- `fun confirmEmailOnly()`: sets `emailOnly = true`, `status = PUBLISHED`, `queueStatus = QUEUED_EMAIL_ONLY`, saves to Room, triggers PostUploadWorker
- [ ] **Step 3: Add email-only confirmation dialog**
`AlertDialog` with: warning icon, "Send via email only?", post preview (first 80 chars), newsletter picker, warning "This cannot be undone. Post will NOT appear on blog.", error-colored confirm button "✉ SEND EMAIL".
- [ ] **Step 4: Update PostUploadWorker for QUEUED_EMAIL_ONLY**
In `PostUploadWorker.doWork()`, add handling for `QUEUED_EMAIL_ONLY` alongside existing `QUEUED_PUBLISH`/`QUEUED_SCHEDULED`:
```kotlin
// In the ghostPost construction:
val status = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH -> "published"
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
QueueStatus.QUEUED_EMAIL_ONLY -> "published" // Ghost requires published status for email
else -> "draft"
}
val ghostPost = ghostPost.copy(
email_only = if (post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) true else null
)
// In the API call:
val newsletter = post.newsletterSlug
val result = if (post.ghostId != null) {
repository.updatePost(post.ghostId, ghostPost, newsletter = newsletter)
} else {
repository.createPost(ghostPost, newsletter = newsletter)
}
```
Also update `PostRepository.createPost()` and `updatePost()` to accept optional `newsletter: String?` and `emailSegment: String?` params, passing them through to the API.
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add email-only post option in Composer with confirmation"
```
### Task 4b.3: "Sent" status in Feed
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt`
- [ ] **Step 1: Add SENT filter chip**
In the filter chips row (FeedScreen), add "Sent" chip. Only show when `NewsletterPreferences.isNewsletterEnabled`. Color: magenta/purple (`#6A1B9A`).
- [ ] **Step 2: Style "sent" status on post cards**
In post card, when `status == "sent"`, show envelope icon (📧) + "Sent" text in magenta. Hide "Share" button (no URL). Show "Copy content" instead.
- [ ] **Step 3: Show email-only info card in DetailScreen**
When post `status == "sent"` or `emailOnly == true`, show an info `Card` at bottom: "✉ SENT VIA EMAIL ONLY", newsletter name, sent date, note "This post is not visible on your blog."
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add Sent status display in Feed and Detail screens"
```
---
## Phase 5: Media Upload (Video + Audio)
### Task 5.1: Media upload API + models
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/MediaModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt`
- [ ] **Step 1: Write MediaModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class MediaUploadResponse(
val media: List<UploadedMedia>
)
data class UploadedMedia(
val url: String,
val ref: String?,
val fileName: String?
)
```
- [ ] **Step 2: Add endpoints**
```kotlin
@Multipart
@POST("ghost/api/admin/media/upload/")
suspend fun uploadMedia(
@Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null
): Response<MediaUploadResponse>
@Multipart
@POST("ghost/api/admin/media/thumbnail/upload/")
suspend fun uploadMediaThumbnail(
@Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null
): Response<MediaUploadResponse>
```
- [ ] **Step 3: Add `uploadMedia()` to PostRepository**
Follow same pattern as `uploadImage()`. Accept `Uri`, determine MIME type, copy to temp file, upload via multipart, return URL. Add `uploadMediaFile(uri: Uri): Result<String>`.
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add media upload API endpoint and repository method"
```
### Task 5.2: Video + audio cards in MobiledocBuilder
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt`
- Test: `app/src/test/java/com/swoosh/microblog/data/MobiledocBuilderTest.kt`
- [ ] **Step 1: Add video and audio card support**
Add new overload or extend existing `build()` to accept optional `videoUrl: String?`, `audioUrl: String?`, `videoThumbnailUrl: String?`.
Video card format: `["video", {"src": "url", "loop": false}]`
Audio card format: `["audio", {"src": "url"}]`
Add card sections after image cards, before bookmark card.
- [ ] **Step 2: Write tests**
Test mobiledoc output with video card, audio card, mixed (text + images + video + bookmark).
- [ ] **Step 3: Run tests, commit**
```bash
./gradlew app:testDebugUnitTest --tests "*.MobiledocBuilderTest"
git commit -m "feat: add video and audio card support to MobiledocBuilder"
```
### Task 5.3: Media buttons in Composer
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- [ ] **Step 1: Add media state to ComposerUiState**
Add: `val videoUri: Uri? = null`, `val audioUri: Uri? = null`, `val uploadedVideoUrl: String? = null`, `val uploadedAudioUrl: String? = null`, `val isUploadingMedia: Boolean = false`, `val mediaUploadProgress: Float = 0f`.
- [ ] **Step 2: Add media methods to ComposerViewModel**
- `fun setVideo(uri: Uri)` / `fun removeVideo()`
- `fun setAudio(uri: Uri)` / `fun removeAudio()`
- Modify `publish()`, `saveDraft()`, `schedule()` to include video/audio URLs in mobiledoc
- [ ] **Step 3: Add media buttons to ComposerScreen toolbar**
Extend the bottom toolbar: `[📷 Image] [🎬 Video] [🎤 Audio] [🔗 Link]`. Video button opens picker with filter `video/*`. Audio button opens picker with filter `audio/*` OR shows recording bottom sheet. Media preview cards showing filename, size, duration, remove button.
- [ ] **Step 4: Add video/audio preview cards**
Video: thumbnail frame (if extractable) + play icon overlay + filename + size + [X]. Audio: waveform icon + filename + duration + mini play button + [X].
- [ ] **Step 5: Update PostUploadWorker**
Handle `videoUri`/`audioUri` in `LocalPost` upload via `uploadMediaFile()` before creating post. Store `uploadedVideoUrl`/`uploadedAudioUrl`. (Columns already added in Phase 0.)
- [ ] **Step 6: Commit**
```bash
git commit -m "feat: add video and audio upload support in Composer"
```
### Task 5.4: Media playback in Feed and Detail
**Files:**
- Modify: `app/build.gradle.kts` (add Media3 dependency)
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt`
- [ ] **Step 1: Add Media3 dependency**
```kotlin
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
```
- [ ] **Step 2: Parse video/audio cards from mobiledoc in FeedPost**
Extend `FeedPost` with `videoUrl: String?`, `audioUrl: String?`. When mapping `GhostPost` to `FeedPost`, parse mobiledoc JSON to extract video and audio card URLs.
- [ ] **Step 3: Add video player composable**
Create inline video player: `ExoPlayer` with `PlayerView` in `AndroidView`. Thumbnail on initial state, tap to play. No autoplay. Fullscreen button.
- [ ] **Step 4: Add audio player composable**
Compact inline player: play/pause button + `Slider` for progress + duration text. Use `ExoPlayer` for playback.
- [ ] **Step 5: Integrate into post cards and detail screen**
In post card: show compact video/audio preview if URL present. In detail screen: full-size players.
- [ ] **Step 6: Commit**
```bash
git commit -m "feat: add video and audio playback in Feed and Detail screens"
```
---
## Phase 6: File Upload
### Task 6.1: File upload API + model
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/FileModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/repository/PostRepository.kt`
- [ ] **Step 1: Write FileModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class FileUploadResponse(
val files: List<UploadedFile>
)
data class UploadedFile(
val url: String,
val ref: String?
)
```
- [ ] **Step 2: Add endpoint**
```kotlin
@Multipart
@POST("ghost/api/admin/files/upload/")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
@Part("ref") ref: RequestBody? = null
): Response<FileUploadResponse>
```
- [ ] **Step 3: Add `uploadFile()` to PostRepository**
Same pattern as `uploadImage()`, returns `Result<String>` with uploaded file URL.
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add file upload API endpoint and repository method"
```
### Task 6.2: File attachment in Composer
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/MobiledocBuilder.kt`
- [ ] **Step 1: Add file state to ComposerUiState**
Add: `val fileUri: Uri? = null`, `val fileName: String? = null`, `val fileSize: Long? = null`, `val fileMimeType: String? = null`, `val uploadedFileUrl: String? = null`.
- [ ] **Step 2: Add file methods to ComposerViewModel**
- `fun addFile(uri: Uri)`: reads filename and size from `ContentResolver`, validates type and size (max 50MB), sets state
- `fun removeFile()`: clears file state
- Modify `publish()`: include file URL as bookmark card in mobiledoc
- [ ] **Step 3: Add file button to Composer toolbar**
New button `[📎 File]` after Link button. Opens `ActivityResultContracts.GetContent` with MIME filter `application/*`. Validation dialog for unsupported types or oversized files.
- [ ] **Step 4: Add file attachment card in Composer**
`OutlinedCard` showing: file type icon (PDF=red, DOC=blue, TXT=gray, other=purple), filename, size in KB/MB, [X] remove button. Positioned after link preview, before text.
- [ ] **Step 5: Add native Ghost file card to MobiledocBuilder**
Use Ghost's native `file` card format (NOT bookmark card):
```kotlin
cards.add("""["file",{"src":"$escapedUrl","fileName":"$escapedName","fileSize":$fileSize}]""")
```
This renders as a proper download card in Ghost's frontend.
- [ ] **Step 6: Update PostUploadWorker**
Handle `fileUri` in `LocalPost` upload via `uploadFile()` before creating post. (Columns `fileUri`, `uploadedFileUrl`, `fileName` already added in Phase 0.)
- [ ] **Step 7: Commit**
```bash
git commit -m "feat: add file attachment support in Composer"
```
### Task 6.3: File card display in Feed and Detail
**Files:**
- Modify: `app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/detail/DetailScreen.kt`
- [ ] **Step 1: Parse file bookmarks from mobiledoc**
In `FeedPost` mapping, detect bookmark cards where URL ends with file extensions (.pdf, .doc, .docx, etc.). Add `fileUrl: String?`, `fileName: String?` to `FeedPost`.
- [ ] **Step 2: Add clickable file card composable**
`OutlinedCard` with file type icon, filename, "tap to download" text. On click: open URL via `Intent(ACTION_VIEW)` in browser.
- [ ] **Step 3: Integrate into post cards and detail screen**
Show file card in post card (compact) and detail screen (full-width). Position after text, before/after images.
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add file attachment display in Feed and Detail screens"
```
---
## Phase 7: Pages API
### Task 7.1: Pages API model + endpoints
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/model/PageModels.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt`
- [ ] **Step 1: Write PageModels.kt**
```kotlin
package com.swoosh.microblog.data.model
data class PagesResponse(
val pages: List<GhostPage>,
val meta: Meta?
)
data class PageWrapper(
val pages: List<GhostPage>
)
data class GhostPage(
val id: String? = null,
val title: String? = null,
val slug: String? = null,
val url: String? = null,
val html: String? = null,
val plaintext: String? = null,
val mobiledoc: String? = null,
val status: String? = null,
val feature_image: String? = null,
val custom_excerpt: String? = null,
val created_at: String? = null,
val updated_at: String? = null,
val published_at: String? = null
)
```
- [ ] **Step 2: Add endpoints**
```kotlin
@GET("ghost/api/admin/pages/")
suspend fun getPages(
@Query("limit") limit: String = "all",
@Query("formats") formats: String = "html,plaintext,mobiledoc"
): Response<PagesResponse>
@POST("ghost/api/admin/pages/")
@Headers("Content-Type: application/json")
suspend fun createPage(@Body body: PageWrapper): Response<PagesResponse>
@PUT("ghost/api/admin/pages/{id}/")
@Headers("Content-Type: application/json")
suspend fun updatePage(@Path("id") id: String, @Body body: PageWrapper): Response<PagesResponse>
@DELETE("ghost/api/admin/pages/{id}/")
suspend fun deletePage(@Path("id") id: String): Response<Unit>
```
- [ ] **Step 3: Commit**
```bash
git commit -m "feat: add Pages API models and endpoints"
```
### Task 7.2: PageRepository
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/data/repository/PageRepository.kt`
- [ ] **Step 1: Write PageRepository**
Same pattern as PostRepository. Methods:
- `suspend fun fetchPages(): Result<List<GhostPage>>`
- `suspend fun createPage(page: GhostPage): Result<GhostPage>`
- `suspend fun updatePage(id: String, page: GhostPage): Result<GhostPage>`
- `suspend fun deletePage(id: String): Result<Unit>`
- [ ] **Step 2: Commit**
```bash
git commit -m "feat: add PageRepository with CRUD operations"
```
### Task 7.3: Pages list screen
**Files:**
- Create: `app/src/main/java/com/swoosh/microblog/ui/pages/PagesViewModel.kt`
- Create: `app/src/main/java/com/swoosh/microblog/ui/pages/PagesScreen.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt`
- Modify: `app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt`
- [ ] **Step 1: Write PagesViewModel**
State: `pages: List<GhostPage>`, `isLoading`, `error`, `editingPage: GhostPage?`, `isEditing: Boolean`. Methods: `loadPages()`, `savePage(title, content, slug, status)`, `updatePage(id, page)`, `deletePage(id)`.
- [ ] **Step 2: Write PagesScreen**
Two modes: List and Edit/Create.
**List mode:** TopAppBar "Pages" with [+] button. `LazyColumn` of pages as `OutlinedCard`: title, slug prefixed with "/", status chip (Published/Draft). Long-press `DropdownMenu` with "Edit" and "Delete" (delete shows `ConfirmationDialog`). Empty state: "No pages yet."
**Create/Edit mode:** TopAppBar "New page" or "Edit page" with save button. Required `OutlinedTextField` for title. `OutlinedTextField` for content (multiline). Optional `OutlinedTextField` for slug. Status: radio buttons (Draft/Publish). For edit: "Revert to draft" button if published. "Open in browser" button if published (opens `{blogUrl}/{slug}/`).
- [ ] **Step 3: Add route and navigation**
Add `Routes.PAGES = "pages"` to NavGraph. Add `composable(Routes.PAGES)` with `PagesScreen`. Add `onNavigateToPages: () -> Unit = {}` callback to `SettingsScreen` signature. Add clickable row "Static Pages " that calls `onNavigateToPages()`. Wire callback in NavGraph: `onNavigateToPages = { navController.navigate(Routes.PAGES) }`.
- [ ] **Step 4: Add delete confirmation dialog**
Reuse `ConfirmationDialog` composable: "Delete page?", ""{title}" will be permanently deleted from Ghost. This cannot be undone.", confirm "Delete".
- [ ] **Step 5: Commit**
```bash
git commit -m "feat: add Pages list and editor screen accessible from Settings"
```
---
## Database Migration
All new `LocalPost` columns are handled in **Phase 0 (Task 0.1)** a single coordinated migration v3v4 that must complete before Phases 4b, 5, or 6. Individual phases do NOT create their own migrations.
---
## Testing Strategy
**Unit tests (must be written per phase):**
- Phase 0: `ConvertersTest` QueueStatus fallback for unknown values
- Phase 1: `SiteModelsTest` (Gson parsing), `SiteMetadataCacheTest` (save/get round-trip)
- Phase 2: `TagModelsTest` (Gson parsing), `TagRepositoryTest`
- Phase 3: `MemberModelsTest` (Gson parsing), `MemberStatsTest` (pure function for `getMemberStats()`)
- Phase 4a: `NewsletterPreferencesTest` (per-account enable/disable), `NewsletterModelsTest`
- Phase 4b: `PostFilterTest` (update for SENT `toPostStatus()` and `emptyMessage()`), `GhostModelsTest` (email_only field)
- Phase 5: `MobiledocBuilderTest` (video + audio cards)
- Phase 6: `MobiledocBuilderTest` (native file card), `FileModelsTest`
- Phase 7: `PageModelsTest`, `PageRepositoryTest`
**Integration tests:** Manual verification of each screen.
Run all tests after each phase:
```bash
./gradlew app:testDebugUnitTest
```
Build after each phase:
```bash
./gradlew assembleDebug
```
---
## Version Bump
After all phases are complete, bump version in `app/build.gradle.kts`:
- `versionName`: `"0.2.0"` `"0.3.0"` (new features = minor bump)
- `versionCode`: `2` `3`