mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +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
|
||||
|
||||
import com.swoosh.microblog.data.model.GhostSite
|
||||
import com.swoosh.microblog.data.model.PageWrapper
|
||||
import com.swoosh.microblog.data.model.PagesResponse
|
||||
import com.swoosh.microblog.data.model.PostWrapper
|
||||
|
|
@ -39,6 +40,9 @@ interface GhostApiService {
|
|||
@Path("id") id: String
|
||||
): Response<Unit>
|
||||
|
||||
@GET("ghost/api/admin/site/")
|
||||
suspend fun getSite(): Response<GhostSite>
|
||||
|
||||
@GET("ghost/api/admin/users/me/")
|
||||
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.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) {
|
||||
|
|
|
|||
|
|
@ -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<FeedPost?>(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
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
package com.swoosh.microblog.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.model.GhostAccount
|
||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||
import com.swoosh.microblog.ui.components.ConfirmationDialog
|
||||
import com.swoosh.microblog.ui.feed.AccountAvatar
|
||||
|
|
@ -37,6 +50,8 @@ fun SettingsScreen(
|
|||
val context = LocalContext.current
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
val activeAccount = remember { accountManager.getActiveAccount() }
|
||||
val siteMetadataCache = remember { SiteMetadataCache(context) }
|
||||
val siteData = remember { activeAccount?.let { siteMetadataCache.get(it.id) } }
|
||||
|
||||
val currentThemeMode = themeViewModel?.themeMode?.collectAsStateWithLifecycle()
|
||||
|
||||
|
|
@ -77,6 +92,162 @@ fun SettingsScreen(
|
|||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// --- Blog Info section ---
|
||||
if (siteData != null) {
|
||||
Text("Blog", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Site logo/icon or fallback initial
|
||||
val siteImageUrl = siteData.logo ?: siteData.icon
|
||||
if (siteImageUrl != null) {
|
||||
AsyncImage(
|
||||
model = siteImageUrl,
|
||||
contentDescription = "Site icon",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
val initial = siteData.title?.firstOrNull()?.uppercase() ?: "?"
|
||||
val color = activeAccount?.let {
|
||||
Color(GhostAccount.colorForIndex(it.colorIndex))
|
||||
} ?: MaterialTheme.colorScheme.primary
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = initial,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = siteData.title ?: "Ghost Blog",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (!siteData.description.isNullOrBlank()) {
|
||||
Text(
|
||||
text = siteData.description!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// URL
|
||||
val siteUrl = siteData.url
|
||||
if (!siteUrl.isNullOrBlank()) {
|
||||
Text(
|
||||
text = siteUrl
|
||||
.removePrefix("https://")
|
||||
.removePrefix("http://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Version + locale
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (siteData.version != null) {
|
||||
Text(
|
||||
text = "Ghost ${siteData.version}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (siteData.locale != null) {
|
||||
Text(
|
||||
text = "Locale: ${siteData.locale}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Version warning
|
||||
val majorVersion = siteData.version?.split(".")?.firstOrNull()?.toIntOrNull()
|
||||
if (majorVersion != null && majorVersion < 5) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Ghost ${siteData.version} is older than v5. Some features may not work.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Open Ghost Admin button
|
||||
val ghostAdminUrl = remember(activeAccount) {
|
||||
activeAccount?.blogUrl?.trimEnd('/') + "/ghost/"
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ghostAdminUrl))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Open Ghost Admin")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// --- Current Account section ---
|
||||
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -254,6 +379,7 @@ fun SetupScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
||||
// 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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