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.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextDecoration
@ -34,6 +40,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -102,6 +109,124 @@ fun SetupScreen(
)
// Content layered on top
if (state.showConfirmation) {
// Confirmation card
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + scaleIn(initialScale = 0.9f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Site icon
if (state.siteIcon != null) {
AsyncImage(
model = state.siteIcon,
contentDescription = "Site icon",
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
}
// Site title
Text(
text = state.siteName ?: "Ghost Blog",
style = MaterialTheme.typography.titleLarge
)
// Site description
if (!state.siteDescription.isNullOrBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = state.siteDescription!!,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Ghost version
if (state.siteVersion != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Ghost ${state.siteVersion}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Version warning
if (state.versionWarning) {
Spacer(modifier = Modifier.height(12.dp))
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Ghost version ${state.siteVersion} is older than v5. Some features may not work correctly.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// Buttons
Button(
onClick = viewModel::confirmConnection,
modifier = Modifier.fillMaxWidth()
) {
Text("Tak, po\u0142\u0105cz")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::cancelConfirmation,
modifier = Modifier.fillMaxWidth()
) {
Text("Wstecz")
}
}
}
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
@ -252,6 +377,7 @@ fun SetupScreen(
}
}
}
}
}
}
}

View file

@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostJwtGenerator
import com.swoosh.microblog.data.repository.PostRepository
@ -16,6 +17,7 @@ import kotlinx.coroutines.launch
class SetupViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application)
private val siteMetadataCache = SiteMetadataCache(application)
private val _uiState = MutableStateFlow(SetupUiState(
isAddingAccount = accountManager.hasAnyAccount
@ -34,6 +36,30 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(accountName = name) }
}
fun confirmConnection() {
_uiState.update { it.copy(isSuccess = true) }
}
fun cancelConfirmation() {
// Remove the account that was added during save()
val accountId = _uiState.value.pendingAccountId
if (accountId != null) {
accountManager.removeAccount(accountId)
siteMetadataCache.remove(accountId)
}
_uiState.update {
it.copy(
showConfirmation = false,
siteName = null,
siteDescription = null,
siteIcon = null,
siteVersion = null,
versionWarning = false,
pendingAccountId = null
)
}
}
fun save() {
val state = _uiState.value
if (state.url.isBlank() || state.apiKey.isBlank()) {
@ -83,7 +109,39 @@ class SetupViewModel(application: Application) : AndroidViewModel(application) {
accountManager.updateAccount(id = account.id, avatarUrl = avatarUrl)
}
} catch (_: Exception) { /* avatar is best-effort */ }
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
// Fetch site metadata
var siteLoaded = false
try {
val service = ApiClient.getService(
state.url,
apiKeyProvider = { state.apiKey }
)
val siteResponse = service.getSite()
val site = siteResponse.body()
if (site != null) {
siteMetadataCache.save(account.id, site)
val majorVersion = site.version?.split(".")?.firstOrNull()?.toIntOrNull()
_uiState.update {
it.copy(
isTesting = false,
showConfirmation = true,
siteName = site.title,
siteDescription = site.description,
siteIcon = site.icon ?: site.logo,
siteVersion = site.version,
versionWarning = majorVersion != null && majorVersion < 5,
pendingAccountId = account.id
)
}
siteLoaded = true
}
} catch (_: Exception) { /* site metadata is best-effort */ }
// Fallback: skip confirmation if site fetch failed
if (!siteLoaded) {
_uiState.update { it.copy(isTesting = false, isSuccess = true) }
}
},
onFailure = { e ->
// Remove the account since connection failed
@ -110,5 +168,12 @@ data class SetupUiState(
val isTesting: Boolean = false,
val isSuccess: Boolean = false,
val isAddingAccount: Boolean = false,
val error: String? = null
val error: String? = null,
val siteName: String? = null,
val siteDescription: String? = null,
val siteIcon: String? = null,
val siteVersion: String? = null,
val showConfirmation: Boolean = false,
val versionWarning: Boolean = false,
val pendingAccountId: String? = null
)