From be37f6284ffe0ab110307abc41ec6b50bf662b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:29:09 +0100 Subject: [PATCH] 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. --- .../swoosh/microblog/ui/setup/SetupScreen.kt | 126 ++++++++++++++++++ .../microblog/ui/setup/SetupViewModel.kt | 69 +++++++++- 2 files changed, 193 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt index 941379d..83b5aa7 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupScreen.kt @@ -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( } } } + } } } } diff --git a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt index f3d42d7..7db9c95 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/setup/SetupViewModel.kt @@ -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 )