From 6761eae351252ec402c1e06c7a6a86c59ab4e490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:24:50 +0100 Subject: [PATCH 1/6] feat: add GhostSite model and /site/ API endpoint Add GhostSite data class for Ghost CMS site metadata (title, description, logo, icon, accent color, URL, version, locale). Add getSite() endpoint to GhostApiService. Include unit tests for Gson deserialization and version parsing. --- .../microblog/data/api/GhostApiService.kt | 4 + .../swoosh/microblog/data/model/SiteModels.kt | 12 ++ .../microblog/data/model/SiteModelsTest.kt | 129 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/SiteModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/SiteModelsTest.kt 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 cd41155..6cbef43 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.PostWrapper import com.swoosh.microblog.data.model.PostsResponse import okhttp3.MultipartBody @@ -37,6 +38,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/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) + } +} From 492ee1ca11c560709691d959b7db46255e69630e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:26:46 +0100 Subject: [PATCH 2/6] feat: add SiteMetadataCache for per-account site metadata storage SharedPreferences-based cache for GhostSite metadata keyed by account ID. Supports save/get/getVersion/remove operations with Gson serialization. Includes Robolectric tests for round-trip, overwrite, multi-account isolation, and removal. --- .../microblog/data/SiteMetadataCache.kt | 42 +++++ .../microblog/data/SiteMetadataCacheTest.kt | 161 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/SiteMetadataCache.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/SiteMetadataCacheTest.kt 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/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")) + } +} From be37f6284ffe0ab110307abc41ec6b50bf662b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:29:09 +0100 Subject: [PATCH 3/6] feat: fetch site metadata on setup and show confirmation card After successful connection test, fetch Ghost /site/ endpoint to get blog name, description, icon, and version. Show a confirmation card with site details before completing setup. Warn if Ghost version < 5. Cache site metadata per account via SiteMetadataCache. Falls back to existing behavior if site fetch fails. --- .../swoosh/microblog/ui/setup/SetupScreen.kt | 126 ++++++++++++++++++ .../microblog/ui/setup/SetupViewModel.kt | 69 +++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) 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 ) From 471fea6183ccdf8a79c64a8f8ff41244c780b59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:31:22 +0100 Subject: [PATCH 4/6] feat: add blog info section in Settings with site metadata Display cached Ghost site metadata (logo/icon, title, description, URL, version, locale) in a card above the Current Account section. Add "Open Ghost Admin" button that launches the blog's admin panel in browser. Show version warning banner if Ghost version is older than v5. --- .../microblog/ui/settings/SettingsScreen.kt | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) 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 28e7248..526cb4a 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,24 +1,37 @@ 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.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 @@ -35,6 +48,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() @@ -75,6 +90,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)) From 0679b18b8e5e8a408833918b75cf6cfff0f3dc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:32:24 +0100 Subject: [PATCH 5/6] feat: show blog name and site icon in Feed top bar Replace account name with blog title from SiteMetadataCache in the Feed TopAppBar. Show site icon (24dp, circular) before the title. Truncate blog name to 20 characters with ellipsis. Falls back to account name or "Swoosh" if no cached site data exists. --- .../swoosh/microblog/ui/feed/FeedScreen.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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 From ac461c3e6f700cd4100cc0593ca6894b71e34d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:33:16 +0100 Subject: [PATCH 6/6] feat: show "Publishing to" chip in Composer for multi-account users When more than one account is configured, display an informational AssistChip at the top of the Composer showing the active blog name and site icon. Uses SiteMetadataCache for the blog title, falls back to account name. Non-clickable, only shown for disambiguation. --- .../microblog/ui/composer/ComposerScreen.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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) {