merge: integrate Phase 1 (Site Metadata) with Phase 7 (Pages)

This commit is contained in:
Paweł Orzech 2026-03-20 00:34:46 +01:00
commit 0752238578
11 changed files with 2215 additions and 5 deletions

View file

@ -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"
}
}

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.data.api 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.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostWrapper
@ -39,6 +40,9 @@ interface GhostApiService {
@Path("id") id: String @Path("id") id: String
): Response<Unit> ): Response<Unit>
@GET("ghost/api/admin/site/")
suspend fun getSite(): Response<GhostSite>
@GET("ghost/api/admin/users/me/") @GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse> suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -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?
)

View file

@ -42,8 +42,12 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.HashtagParser 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.FeedPost
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
@ -209,6 +213,39 @@ fun ComposerScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .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 // Hashtag visual transformation for edit mode text field
val hashtagColor = MaterialTheme.colorScheme.primary val hashtagColor = MaterialTheme.colorScheme.primary
val hashtagTransformation = remember(hashtagColor) { val hashtagTransformation = remember(hashtagColor) {

View file

@ -81,6 +81,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.CredentialsManager
import com.swoosh.microblog.data.ShareUtils 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.FeedPost
import com.swoosh.microblog.data.model.GhostAccount import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.data.model.PostFilter import com.swoosh.microblog.data.model.PostFilter
@ -113,6 +114,10 @@ fun FeedScreen(
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val baseUrl = remember { CredentialsManager(context).ghostUrl } 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 // Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) } var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
@ -201,8 +206,19 @@ fun FeedScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showAccountSwitcher = true } modifier = Modifier.clickable { showAccountSwitcher = true }
) { ) {
// Account color indicator // Site icon or account avatar
if (activeAccount != null) { 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( AccountAvatar(
account = activeAccount!!, account = activeAccount!!,
size = 28 size = 28
@ -211,8 +227,12 @@ fun FeedScreen(
} }
Column { 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(
text = activeAccount?.name ?: "Swoosh", text = displayName,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis

View file

@ -1,25 +1,38 @@
package com.swoosh.microblog.ui.settings package com.swoosh.microblog.ui.settings
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BrightnessAuto import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.swoosh.microblog.data.AccountManager 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.ApiClient
import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog import com.swoosh.microblog.ui.components.ConfirmationDialog
import com.swoosh.microblog.ui.feed.AccountAvatar import com.swoosh.microblog.ui.feed.AccountAvatar
@ -37,6 +50,8 @@ fun SettingsScreen(
val context = LocalContext.current val context = LocalContext.current
val accountManager = remember { AccountManager(context) } val accountManager = remember { AccountManager(context) }
val activeAccount = remember { accountManager.getActiveAccount() } val activeAccount = remember { accountManager.getActiveAccount() }
val siteMetadataCache = remember { SiteMetadataCache(context) }
val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } }
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle() val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
@ -77,6 +92,162 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) 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 --- // --- Current Account section ---
Text("Current Account", style = MaterialTheme.typography.titleMedium) Text("Current Account", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View file

@ -9,24 +9,30 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material.icons.outlined.Info
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration 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.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -102,6 +109,124 @@ fun SetupScreen(
) )
// Content layered on top // 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( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@ -255,6 +380,7 @@ fun SetupScreen(
} }
} }
} }
}
@Composable @Composable
private fun PulsingCirclesBackground( private fun PulsingCirclesBackground(

View file

@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.AccountManager 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.ApiClient
import com.swoosh.microblog.data.api.GhostJwtGenerator import com.swoosh.microblog.data.api.GhostJwtGenerator
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
@ -16,6 +17,7 @@ import kotlinx.coroutines.launch
class SetupViewModel(application: Application) : AndroidViewModel(application) { class SetupViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application) private val accountManager = AccountManager(application)
private val siteMetadataCache = SiteMetadataCache(application)
private val _uiState = MutableStateFlow(SetupUiState( private val _uiState = MutableStateFlow(SetupUiState(
isAddingAccount = accountManager.hasAnyAccount isAddingAccount = accountManager.hasAnyAccount
@ -34,6 +36,30 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(accountName = name) } _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() { fun save() {
val state = _uiState.value val state = _uiState.value
if (state.url.isBlank() || state.apiKey.isBlank()) { if (state.url.isBlank() || state.apiKey.isBlank()) {
@ -83,7 +109,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl) accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
} }
} catch (_: Exception) { /* avatar is best-effort */ } } catch (_: Exception) { /* avatar is best-effort */ }
// 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) } _uiState.update { it.copy(isTesting = false, isSuccess = true) }
}
}, },
onFailure = { e -> onFailure = { e ->
// Remove the account since connection failed // Remove the account since connection failed
@ -110,5 +168,12 @@ data class SetupUiState(
val isTesting: Boolean = false, val isTesting: Boolean = false,
val isSuccess: Boolean = false, val isSuccess: Boolean = false,
val isAddingAccount: 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
) )

View file

@ -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<Application>()
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"))
}
}

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff