52 KiB
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:
// 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:
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
./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).
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:
@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
./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
./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
./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
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
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
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
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
@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
./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.postssuspend 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
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 fromTagRepositoryintoavailableTags -
fun updateTagInput(input: String): filteravailableTagsby input (case-insensitive contains on name), updatetagSuggestions -
fun addTag(tagName: String): add to existingextractedTags, cleartagInput -
fun removeTag(tagName: String): remove fromextractedTags -
Step 3: Add autocomplete UI to ComposerScreen
Below text input, add "Tags:" section with:
-
OutlinedTextFieldfor tag input -
LazyColumndropdown showingtagSuggestions(each row: tag name + post count). Max 5 visible. -
Last item: "+ Create {input} as new tag"
-
Below input:
FlowRowof existing tags asInputChipwith trailing X icon -
Tapping a suggestion calls
addTag(tag.name) -
Step 4: Test manually, commit
./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
./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
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
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
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
@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
./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
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
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
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
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
ElevatedCardtiles: 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:
FlowRowofAssistChips -
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
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
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
@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):
// 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:
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
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
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: checkNewsletterPreferences.isNewsletterEnabled. If true, fetch newsletters. -
fun toggleSendAsNewsletter(): togglessendAsNewsletter -
fun selectNewsletter(newsletter): setsselectedNewsletter -
fun setEmailSegment(segment: String): sets segment ("all", "status:free", "status:-free") -
fun confirmNewsletterSend(): setsshowNewsletterConfirmation = true -
Modify
publish(): ifsendAsNewsletter, usecreatePost (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 (
RadioButtonlist), segment picker (RadioButton: All/Free only/Paid only) -
Fetch subscriber count via lightweight call:
getMembers(limit=1)→ readmeta.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
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
enum class PostStatus { DRAFT, PUBLISHED, SCHEDULED, SENT }
enum class QueueStatus { NONE, QUEUED_PUBLISH, QUEUED_SCHEDULED, QUEUED_EMAIL_ONLY, UPLOADING, FAILED }
Add to PostFilter:
SENT("Sent", "status:sent");
Update toPostStatus() and emptyMessage() for SENT — these are exhaustive when expressions that will fail to compile without the new case:
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
./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(): setsemailOnly = 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:
// 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
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
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
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
@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
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
./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
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
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
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
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
@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
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 fromContentResolver, 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):
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
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
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
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
@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
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
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
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 forgetMemberStats()) - Phase 4a:
NewsletterPreferencesTest(per-account enable/disable),NewsletterModelsTest - Phase 4b:
PostFilterTest(update for SENT —toPostStatus()andemptyMessage()),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:
./gradlew app:testDebugUnitTest
Build after each phase:
./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