mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
492ee1ca11
commit
be37f6284f
2 changed files with 193 additions and 2 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue