diff --git a/app/src/main/java/com/swoosh/microblog/data/SiteMetadataCache.kt b/app/src/main/java/com/swoosh/microblog/data/SiteMetadataCache.kt new file mode 100644 index 0000000..c104989 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/SiteMetadataCache.kt @@ -0,0 +1,42 @@ +package com.swoosh.microblog.data + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.swoosh.microblog.data.model.GhostSite + +class SiteMetadataCache(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences( + PREFS_NAME, Context.MODE_PRIVATE + ) + private val gson = Gson() + + fun save(accountId: String, site: GhostSite) { + val json = gson.toJson(site) + prefs.edit().putString(keyForAccount(accountId), json).apply() + } + + fun get(accountId: String): GhostSite? { + val json = prefs.getString(keyForAccount(accountId), null) ?: return null + return try { + gson.fromJson(json, GhostSite::class.java) + } catch (e: Exception) { + null + } + } + + fun getVersion(accountId: String): String? { + return get(accountId)?.version + } + + fun remove(accountId: String) { + prefs.edit().remove(keyForAccount(accountId)).apply() + } + + private fun keyForAccount(accountId: String): String = "site_$accountId" + + companion object { + const val PREFS_NAME = "site_metadata_cache" + } +} diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index f3d240c..70f529e 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -1,5 +1,6 @@ package com.swoosh.microblog.data.api +import com.swoosh.microblog.data.model.GhostSite import com.swoosh.microblog.data.model.PageWrapper import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PostWrapper @@ -39,6 +40,9 @@ interface GhostApiService { @Path("id") id: String ): Response + @GET("ghost/api/admin/site/") + suspend fun getSite(): Response + @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response diff --git a/app/src/main/java/com/swoosh/microblog/data/model/SiteModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/SiteModels.kt new file mode 100644 index 0000000..e6c33f9 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/SiteModels.kt @@ -0,0 +1,12 @@ +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? +) diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 560cd7b..08963fa 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -42,8 +42,12 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.platform.LocalContext import coil.compose.AsyncImage +import com.swoosh.microblog.data.AccountManager import com.swoosh.microblog.data.HashtagParser +import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.ui.animation.SwooshMotion @@ -209,6 +213,39 @@ fun ComposerScreen( .fillMaxSize() .padding(padding) ) { + // "Publishing to" chip when multiple accounts exist + val composerContext = LocalContext.current + val composerAccountManager = remember { AccountManager(composerContext) } + val composerAccounts = remember { composerAccountManager.getAccounts() } + val composerActiveAccount = remember { composerAccountManager.getActiveAccount() } + + if (composerAccounts.size > 1 && composerActiveAccount != null) { + val composerSiteCache = remember { SiteMetadataCache(composerContext) } + val composerSiteData = remember { + composerSiteCache.get(composerActiveAccount.id) + } + val siteName = composerSiteData?.title ?: composerActiveAccount.name + val siteIconUrl = composerSiteData?.icon ?: composerSiteData?.logo + + AssistChip( + onClick = { }, + label = { Text("Publishing to: $siteName") }, + leadingIcon = if (siteIconUrl != null) { + { + AsyncImage( + model = siteIconUrl, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + } else null, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + // Hashtag visual transformation for edit mode text field val hashtagColor = MaterialTheme.colorScheme.primary val hashtagTransformation = remember(hashtagColor) { diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index d46edb6..f7d58d2 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -81,6 +81,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.ShareUtils +import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.GhostAccount import com.swoosh.microblog.data.model.PostFilter @@ -113,6 +114,10 @@ fun FeedScreen( val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val baseUrl = remember { CredentialsManager(context).ghostUrl } + val siteMetadataCache = remember { SiteMetadataCache(context) } + val siteData = remember(activeAccount?.id) { + activeAccount?.let { siteMetadataCache.get(it.id) } + } // Track which post is pending delete confirmation var postPendingDelete by remember { mutableStateOf(null) } @@ -201,8 +206,19 @@ fun FeedScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { showAccountSwitcher = true } ) { - // Account color indicator - if (activeAccount != null) { + // Site icon or account avatar + val siteIconUrl = siteData?.icon ?: siteData?.logo + if (siteIconUrl != null) { + AsyncImage( + model = siteIconUrl, + contentDescription = "Site icon", + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(8.dp)) + } else if (activeAccount != null) { AccountAvatar( account = activeAccount!!, size = 28 @@ -211,8 +227,12 @@ fun FeedScreen( } Column { + // Use blog name from site metadata, truncate to ~20 chars + val displayName = siteData?.title?.let { + if (it.length > 20) it.take(20) + "\u2026" else it + } ?: activeAccount?.name ?: "Swoosh" Text( - text = activeAccount?.name ?: "Swoosh", + text = displayName, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index e80e9f8..36ebe5f 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -1,25 +1,38 @@ package com.swoosh.microblog.ui.settings +import android.content.Intent +import android.net.Uri import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.BrightnessAuto import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import com.swoosh.microblog.data.AccountManager +import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.api.ApiClient +import com.swoosh.microblog.data.model.GhostAccount import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.components.ConfirmationDialog import com.swoosh.microblog.ui.feed.AccountAvatar @@ -37,6 +50,8 @@ fun SettingsScreen( val context = LocalContext.current val accountManager = remember { AccountManager(context) } val activeAccount = remember { accountManager.getActiveAccount() } + val siteMetadataCache = remember { SiteMetadataCache(context) } + val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } } val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() @@ -77,6 +92,162 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) } + // --- Blog Info section --- + if (siteData != null) { + Text("Blog", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Site logo/icon or fallback initial + val siteImageUrl = siteData.logo ?: siteData.icon + if (siteImageUrl != null) { + AsyncImage( + model = siteImageUrl, + contentDescription = "Site icon", + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + val initial = siteData.title?.firstOrNull()?.uppercase() ?: "?" + val color = activeAccount?.let { + Color(GhostAccount.colorForIndex(it.colorIndex)) + } ?: MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(color), + contentAlignment = Alignment.Center + ) { + Text( + text = initial, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = siteData.title ?: "Ghost Blog", + style = MaterialTheme.typography.titleMedium + ) + if (!siteData.description.isNullOrBlank()) { + Text( + text = siteData.description!!, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // URL + val siteUrl = siteData.url + if (!siteUrl.isNullOrBlank()) { + Text( + text = siteUrl + .removePrefix("https://") + .removePrefix("http://") + .removeSuffix("/"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Version + locale + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (siteData.version != null) { + Text( + text = "Ghost ${siteData.version}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (siteData.locale != null) { + Text( + text = "Locale: ${siteData.locale}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // Version warning + val majorVersion = siteData.version?.split(".")?.firstOrNull()?.toIntOrNull() + if (majorVersion != null && majorVersion < 5) { + Spacer(modifier = Modifier.height(8.dp)) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Ghost ${siteData.version} is older than v5. Some features may not work.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Open Ghost Admin button + val ghostAdminUrl = remember(activeAccount) { + activeAccount?.blogUrl?.trimEnd('/') + "/ghost/" + } + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ghostAdminUrl)) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open Ghost Admin") + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + } + // --- Current Account section --- Text("Current Account", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt index 941379d..83b5aa7 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt @@ -9,24 +9,30 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration @@ -34,6 +40,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -102,6 +109,124 @@ fun SetupScreen( ) // Content layered on top + if (state.showConfirmation) { + // Confirmation card + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AnimatedVisibility( + visible = true, + enter = fadeIn() + scaleIn(initialScale = 0.9f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Site icon + if (state.siteIcon != null) { + AsyncImage( + model = state.siteIcon, + contentDescription = "Site icon", + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + // Site title + Text( + text = state.siteName ?: "Ghost Blog", + style = MaterialTheme.typography.titleLarge + ) + + // Site description + if (!state.siteDescription.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = state.siteDescription!!, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Ghost version + if (state.siteVersion != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Ghost ${state.siteVersion}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Version warning + if (state.versionWarning) { + Spacer(modifier = Modifier.height(12.dp)) + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Ghost version ${state.siteVersion} is older than v5. Some features may not work correctly.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Buttons + Button( + onClick = viewModel::confirmConnection, + modifier = Modifier.fillMaxWidth() + ) { + Text("Tak, po\u0142\u0105cz") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = viewModel::cancelConfirmation, + modifier = Modifier.fillMaxWidth() + ) { + Text("Wstecz") + } + } + } + } + } else { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally @@ -252,6 +377,7 @@ fun SetupScreen( } } } + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt index f3d42d7..7db9c95 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.swoosh.microblog.data.AccountManager +import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.api.ApiClient import com.swoosh.microblog.data.api.GhostJwtGenerator import com.swoosh.microblog.data.repository.PostRepository @@ -16,6 +17,7 @@ import kotlinx.coroutines.launch class SetupViewModel(application: Application) : AndroidViewModel(application) { private val accountManager = AccountManager(application) + private val siteMetadataCache = SiteMetadataCache(application) private val _uiState = MutableStateFlow(SetupUiState( isAddingAccount = accountManager.hasAnyAccount @@ -34,6 +36,30 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(accountName = name) } } + fun confirmConnection() { + _uiState.update { it.copy(isSuccess = true) } + } + + fun cancelConfirmation() { + // Remove the account that was added during save() + val accountId = _uiState.value.pendingAccountId + if (accountId != null) { + accountManager.removeAccount(accountId) + siteMetadataCache.remove(accountId) + } + _uiState.update { + it.copy( + showConfirmation = false, + siteName = null, + siteDescription = null, + siteIcon = null, + siteVersion = null, + versionWarning = false, + pendingAccountId = null + ) + } + } + fun save() { val state = _uiState.value if (state.url.isBlank() || state.apiKey.isBlank()) { @@ -83,7 +109,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) { accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl) } } catch (_: Exception) { /* avatar is best-effort */ } - _uiState.update { it.copy(isTesting = false, isSuccess = true) } + + // Fetch site metadata + var siteLoaded = false + try { + val service = ApiClient.getService( + state.url, + apiKeyProvider = { state.apiKey } + ) + val siteResponse = service.getSite() + val site = siteResponse.body() + if (site != null) { + siteMetadataCache.save(account.id, site) + val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull() + _uiState.update { + it.copy( + isTesting = false, + showConfirmation = true, + siteName = site.title, + siteDescription = site.description, + siteIcon = site.icon ?: site.logo, + siteVersion = site.version, + versionWarning = majorVersion != null && majorVersion < 5, + pendingAccountId = account.id + ) + } + siteLoaded = true + } + } catch (_: Exception) { /* site metadata is best-effort */ } + + // Fallback: skip confirmation if site fetch failed + if (!siteLoaded) { + _uiState.update { it.copy(isTesting = false, isSuccess = true) } + } }, onFailure = { e -> // Remove the account since connection failed @@ -110,5 +168,12 @@ data class SetupUiState( val isTesting: Boolean = false, val isSuccess: Boolean = false, val isAddingAccount: Boolean = false, - val error: String? = null + val error: String? = null, + val siteName: String? = null, + val siteDescription: String? = null, + val siteIcon: String? = null, + val siteVersion: String? = null, + val showConfirmation: Boolean = false, + val versionWarning: Boolean = false, + val pendingAccountId: String? = null ) diff --git a/app/src/test/java/com/swoosh/microblog/data/SiteMetadataCacheTest.kt b/app/src/test/java/com/swoosh/microblog/data/SiteMetadataCacheTest.kt new file mode 100644 index 0000000..359c2e8 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/SiteMetadataCacheTest.kt @@ -0,0 +1,161 @@ +package com.swoosh.microblog.data + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.swoosh.microblog.data.model.GhostSite +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class SiteMetadataCacheTest { + + private lateinit var cache: SiteMetadataCache + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + cache = SiteMetadataCache(context) + } + + @Test + fun `save and get round trip`() { + val site = GhostSite( + title = "My Blog", + description = "A test blog", + logo = "https://example.com/logo.png", + icon = "https://example.com/icon.png", + accent_color = "#ff1a75", + url = "https://example.com/", + version = "5.82.0", + locale = "en" + ) + + cache.save("account-1", site) + val retrieved = cache.get("account-1") + + assertNotNull(retrieved) + assertEquals("My Blog", retrieved!!.title) + assertEquals("A test blog", retrieved.description) + assertEquals("https://example.com/logo.png", retrieved.logo) + assertEquals("https://example.com/icon.png", retrieved.icon) + assertEquals("#ff1a75", retrieved.accent_color) + assertEquals("https://example.com/", retrieved.url) + assertEquals("5.82.0", retrieved.version) + assertEquals("en", retrieved.locale) + } + + @Test + fun `get returns null for unknown account`() { + val result = cache.get("nonexistent-account") + assertNull(result) + } + + @Test + fun `getVersion returns version string`() { + val site = GhostSite( + title = "Blog", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.82.0", + locale = null + ) + + cache.save("account-2", site) + assertEquals("5.82.0", cache.getVersion("account-2")) + } + + @Test + fun `getVersion returns null for unknown account`() { + assertNull(cache.getVersion("nonexistent")) + } + + @Test + fun `save overwrites existing data`() { + val site1 = GhostSite( + title = "Old Title", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.0.0", + locale = null + ) + val site2 = GhostSite( + title = "New Title", + description = "Updated", + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.82.0", + locale = null + ) + + cache.save("account-3", site1) + cache.save("account-3", site2) + val retrieved = cache.get("account-3") + + assertEquals("New Title", retrieved?.title) + assertEquals("Updated", retrieved?.description) + assertEquals("5.82.0", retrieved?.version) + } + + @Test + fun `different accounts have independent data`() { + val site1 = GhostSite( + title = "Blog One", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.0.0", + locale = null + ) + val site2 = GhostSite( + title = "Blog Two", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "4.48.0", + locale = null + ) + + cache.save("account-a", site1) + cache.save("account-b", site2) + + assertEquals("Blog One", cache.get("account-a")?.title) + assertEquals("Blog Two", cache.get("account-b")?.title) + } + + @Test + fun `remove deletes cached data`() { + val site = GhostSite( + title = "To Remove", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.0.0", + locale = null + ) + + cache.save("account-remove", site) + assertNotNull(cache.get("account-remove")) + + cache.remove("account-remove") + assertNull(cache.get("account-remove")) + } +} diff --git a/app/src/test/java/com/swoosh/microblog/data/model/SiteModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/SiteModelsTest.kt new file mode 100644 index 0000000..201825d --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/SiteModelsTest.kt @@ -0,0 +1,129 @@ +package com.swoosh.microblog.data.model + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class SiteModelsTest { + + private val gson = Gson() + + @Test + fun `deserialize full site response`() { + val json = """ + { + "title": "My Ghost Blog", + "description": "A blog about things", + "logo": "https://example.com/logo.png", + "icon": "https://example.com/icon.png", + "accent_color": "#ff1a75", + "url": "https://example.com/", + "version": "5.82.0", + "locale": "en" + } + """.trimIndent() + + val site = gson.fromJson(json, GhostSite::class.java) + + assertEquals("My Ghost Blog", site.title) + assertEquals("A blog about things", site.description) + assertEquals("https://example.com/logo.png", site.logo) + assertEquals("https://example.com/icon.png", site.icon) + assertEquals("#ff1a75", site.accent_color) + assertEquals("https://example.com/", site.url) + assertEquals("5.82.0", site.version) + assertEquals("en", site.locale) + } + + @Test + fun `deserialize site response with null fields`() { + val json = """ + { + "title": "Minimal Blog", + "url": "https://minimal.ghost.io/" + } + """.trimIndent() + + val site = gson.fromJson(json, GhostSite::class.java) + + assertEquals("Minimal Blog", site.title) + assertNull(site.description) + assertNull(site.logo) + assertNull(site.icon) + assertNull(site.accent_color) + assertEquals("https://minimal.ghost.io/", site.url) + assertNull(site.version) + assertNull(site.locale) + } + + @Test + fun `version parsing extracts major version`() { + val site = GhostSite( + title = "Test", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "5.82.0", + locale = null + ) + + val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull() + assertEquals(5, majorVersion) + } + + @Test + fun `version parsing handles old version`() { + val site = GhostSite( + title = "Old Blog", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = "4.48.9", + locale = null + ) + + val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull() + assertEquals(4, majorVersion) + assertTrue((majorVersion ?: 0) < 5) + } + + @Test + fun `version parsing handles null version`() { + val site = GhostSite( + title = "No Version", + description = null, + logo = null, + icon = null, + accent_color = null, + url = null, + version = null, + locale = null + ) + + val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull() + assertNull(majorVersion) + } + + @Test + fun `serialize and deserialize round trip`() { + val original = GhostSite( + title = "Round Trip Blog", + description = "Testing serialization", + logo = "https://example.com/logo.png", + icon = "https://example.com/icon.png", + accent_color = "#15171a", + url = "https://example.com/", + version = "5.82.0", + locale = "pl" + ) + + val json = gson.toJson(original) + val deserialized = gson.fromJson(json, GhostSite::class.java) + + assertEquals(original, deserialized) + } +} diff --git a/docs/superpowers/plans/2026-03-19-ghost-api-features.md b/docs/superpowers/plans/2026-03-19-ghost-api-features.md new file mode 100644 index 0000000..bf8170c --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-ghost-api-features.md @@ -0,0 +1,1443 @@ +# 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 +``` + +- [ ] **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, + val meta: Meta? +) + +data class TagWrapper( + val tags: List +) + +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 + +@GET("ghost/api/admin/tags/{id}/") +suspend fun getTag(@Path("id") id: String): Response + +@POST("ghost/api/admin/tags/") +@Headers("Content-Type: application/json") +suspend fun createTag(@Body body: TagWrapper): Response + +@PUT("ghost/api/admin/tags/{id}/") +@Headers("Content-Type: application/json") +suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response + +@DELETE("ghost/api/admin/tags/{id}/") +suspend fun deleteTag(@Path("id") id: String): Response +``` + +- [ ] **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>` — GET all tags with count.posts +- `suspend fun createTag(name: String, description: String?, accentColor: String?): Result` +- `suspend fun updateTag(id: String, tag: GhostTagFull): Result` +- `suspend fun deleteTag(id: String): Result` + +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 = emptyList()`, `val tagSuggestions: List = 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`, `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>`. 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 = 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, + 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?, + val newsletters: List?, + val subscriptions: List?, + 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 + +@GET("ghost/api/admin/members/{id}/") +suspend fun getMember( + @Path("id") id: String, + @Query("include") include: String = "newsletters,labels" +): Response +``` + +- [ ] **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` +- `suspend fun fetchMember(id: String): Result` +- `suspend fun fetchAllMembers(): Result>` — paginated fetch all (limit 50, max 20 pages) +- `fun getMemberStats(members: List): 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`, `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 +) + +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 +``` + +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 + +// 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 +``` + +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 = 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 +) + +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 + +@Multipart +@POST("ghost/api/admin/media/thumbnail/upload/") +suspend fun uploadMediaThumbnail( + @Part file: MultipartBody.Part, + @Part("ref") ref: RequestBody? = null +): Response +``` + +- [ ] **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`. + +- [ ] **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 +) + +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 +``` + +- [ ] **Step 3: Add `uploadFile()` to PostRepository** + +Same pattern as `uploadImage()`, returns `Result` 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, + val meta: Meta? +) + +data class PageWrapper( + val pages: List +) + +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 + +@POST("ghost/api/admin/pages/") +@Headers("Content-Type: application/json") +suspend fun createPage(@Body body: PageWrapper): Response + +@PUT("ghost/api/admin/pages/{id}/") +@Headers("Content-Type: application/json") +suspend fun updatePage(@Path("id") id: String, @Body body: PageWrapper): Response + +@DELETE("ghost/api/admin/pages/{id}/") +suspend fun deletePage(@Path("id") id: String): Response +``` + +- [ ] **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>` +- `suspend fun createPage(page: GhostPage): Result` +- `suspend fun updatePage(id: String, page: GhostPage): Result` +- `suspend fun deletePage(id: String): Result` + +- [ ] **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`, `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`