mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
merge: integrate Phase 1 (Site Metadata) with Phase 7 (Pages)
This commit is contained in:
commit
0752238578
11 changed files with 2215 additions and 5 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
)
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1443
docs/superpowers/plans/2026-03-19-ghost-api-features.md
Normal file
1443
docs/superpowers/plans/2026-03-19-ghost-api-features.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue