mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
1443 lines
52 KiB
Markdown
1443 lines
52 KiB
Markdown
# 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 v3→v4 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`
|