feat: fetch site metadata on setup and show confirmation card

After successful connection test, fetch Ghost /site/ endpoint to get
blog name, description, icon, and version. Show a confirmation card
with site details before completing setup. Warn if Ghost version < 5.
Cache site metadata per account via SiteMetadataCache. Falls back to
existing behavior if site fetch fails.
This commit is contained in:
Paweł Orzech 2026-03-20 00:29:09 +01:00
parent 492ee1ca11
commit be37f6284f
2 changed files with 193 additions and 2 deletions

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