Merge branch 'switchdb' into cmp-rewrite

This commit is contained in:
tapframe 2026-06-18 14:15:12 +05:30
commit e883d89031
15 changed files with 584 additions and 74 deletions

1
.gitignore vendored
View file

@ -28,3 +28,4 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
server

View file

@ -26,6 +26,21 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
@get:Input
abstract val appVersionCode: Property<Int>
@get:Input
abstract val supabaseUrl: Property<String>
@get:Input
abstract val supabaseAnonKey: Property<String>
@get:Input
abstract val nuvioSupabaseUrl: Property<String>
@get:Input
abstract val nuvioSupabaseAnonKey: Property<String>
@get:Input
abstract val syncBackendManifestUrl: Property<String>
@TaskAction
fun generate() {
val props = Properties()
@ -39,8 +54,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|package com.nuvio.app.core.network
|
|object SupabaseConfig {
| const val URL = "${props.getProperty("SUPABASE_URL", "")}"
| const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}"
| const val URL = "${supabaseUrl.get()}"
| const val ANON_KEY = "${supabaseAnonKey.get()}"
| const val NUVIO_URL = "${nuvioSupabaseUrl.get()}"
| const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}"
|}
""".trimMargin()
)
resolve("SyncBackendBootstrapConfig.kt").writeText(
"""
|package com.nuvio.app.core.network
|
|object SyncBackendBootstrapConfig {
| const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}"
|}
""".trimMargin()
)
@ -199,12 +225,28 @@ val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName ->
taskName.startsWith("bundlefull") ||
taskName.endsWith("bundle")
}
val runtimeLocalProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use(::load)
}
}
fun runtimeConfigValue(key: String, fallback: String = ""): String =
runtimeLocalProperties.getProperty(key)?.trim()?.takeIf { it.isNotBlank() }
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
?: fallback
val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
appVersionName.set(releaseAppVersionName)
appVersionCode.set(releaseAppVersionCode)
supabaseUrl.set(runtimeConfigValue("SUPABASE_URL"))
supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY"))
nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL"))
}
tasks.withType<KotlinCompilationTask<*>>().configureEach {

View file

@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.nuvio.app.core.auth.AuthStorage
import com.nuvio.app.core.deeplink.handleAppUrl
import com.nuvio.app.core.network.SyncBackendStorage
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
window.setBackgroundDrawableResource(R.color.nuvio_background)
AddonStorage.initialize(applicationContext)
AuthStorage.initialize(applicationContext)
SyncBackendStorage.initialize(applicationContext)
LibraryStorage.initialize(applicationContext)
WatchedStorage.initialize(applicationContext)
MetaScreenSettingsStorage.initialize(applicationContext)

View file

@ -0,0 +1,58 @@
package com.nuvio.app.core.network
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.Proxy
import java.util.concurrent.TimeUnit
internal actual object SyncBackendStorage {
private const val PREFS_NAME = "nuvio_sync_backend"
private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
actual fun loadSelectionPayload(): String? =
preferences?.getString(KEY_SELECTION_PAYLOAD, null)
actual fun saveSelectionPayload(payload: String) {
preferences
?.edit()
?.putString(KEY_SELECTION_PAYLOAD, payload)
?.apply()
}
}
private val syncBackendHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.proxy(Proxy.NO_PROXY)
.build()
internal actual suspend fun fetchSyncBackendManifestText(url: String): String =
withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(url)
.get()
.header("Accept", "application/json")
.build()
syncBackendHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Sync backend manifest request failed with HTTP ${response.code}")
}
response.body?.string()?.takeIf { it.isNotBlank() }
?: error("Sync backend manifest response was empty")
}
}

View file

@ -532,6 +532,7 @@
<string name="settings_account_status">Status</string>
<string name="settings_account_status_anonymous">Anonymous</string>
<string name="settings_account_status_signed_in">Signed in</string>
<string name="settings_account_sync_backend">Sync backend</string>
<string name="settings_appearance_amoled_black">AMOLED Black</string>
<string name="settings_appearance_amoled_description">Use pure black backgrounds for OLED screens.</string>
<string name="settings_appearance_app_language">App Language</string>

View file

@ -81,6 +81,9 @@ import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.network.SyncBackendRefreshResult
import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.sync.AppForegroundMonitor
import com.nuvio.app.core.sync.ProfileSettingsSync
import com.nuvio.app.core.sync.SyncManager
@ -408,6 +411,32 @@ private enum class AppGateScreen {
Main,
}
private suspend fun refreshSyncBackendSelection() {
SyncBackendRepository.ensureLoaded()
when (val result = SyncBackendRepository.refreshFromManifest()) {
SyncBackendRefreshResult.NotConfigured,
is SyncBackendRefreshResult.Failed,
SyncBackendRefreshResult.Unchanged,
-> Unit
is SyncBackendRefreshResult.Applied -> {
SupabaseProvider.rebuildClient()
NetworkStatusRepository.requestRefresh(force = true)
}
is SyncBackendRefreshResult.RequiresLogout -> {
AuthRepository.resetForSyncBackendChange()
.onSuccess {
SyncBackendRepository.applyBackendAfterLogout(
backend = result.targetBackend,
revision = result.revision,
)
SupabaseProvider.rebuildClient()
NetworkStatusRepository.requestRefresh(force = true)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
@ -430,9 +459,16 @@ fun App() {
val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle()
NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) {
LaunchedEffect(Unit) {
refreshSyncBackendSelection()
AuthRepository.initialize()
}
LaunchedEffect(Unit) {
AppForegroundMonitor.events().collect {
refreshSyncBackendSelection()
}
}
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()

View file

@ -2,6 +2,7 @@ package com.nuvio.app.core.auth
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.storage.LocalAccountDataCleaner
import io.github.jan.supabase.auth.auth
import io.github.jan.supabase.auth.providers.builtin.Email
@ -15,6 +16,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@ -35,35 +37,42 @@ object AuthRepository {
if (initialized) return
initialized = true
val savedAnonId = AuthStorage.loadAnonymousUserId()
if (savedAnonId != null) {
_state.value = AuthState.Authenticated(
userId = savedAnonId,
email = null,
isAnonymous = true,
)
}
scope.launch {
SupabaseProvider.client.auth.sessionStatus.collect { status ->
if (AuthStorage.loadAnonymousUserId() != null) return@collect
when (status) {
is SessionStatus.Authenticated -> {
val user = status.session.user
_state.value = AuthState.Authenticated(
userId = user?.id ?: "",
email = user?.email,
isAnonymous = false,
)
}
is SessionStatus.NotAuthenticated -> {
_state.value = AuthState.Unauthenticated
}
is SessionStatus.Initializing -> {
if (savedAnonId == null) _state.value = AuthState.Loading
}
is SessionStatus.RefreshFailure -> {
_state.value = AuthState.Unauthenticated
SyncBackendRepository.state.collectLatest { backendState ->
if (!backendState.isLoaded) return@collectLatest
AuthStorage.loadAnonymousUserId()?.let { savedAnonId ->
_state.value = AuthState.Authenticated(
userId = savedAnonId,
email = null,
isAnonymous = true,
)
} ?: run {
_state.value = AuthState.Loading
}
SupabaseProvider.client.auth.sessionStatus.collect { status ->
if (AuthStorage.loadAnonymousUserId() != null) return@collect
when (status) {
is SessionStatus.Authenticated -> {
val user = status.session.user
_state.value = AuthState.Authenticated(
userId = user?.id ?: "",
email = user?.email,
isAnonymous = false,
)
}
is SessionStatus.NotAuthenticated -> {
_state.value = AuthState.Unauthenticated
}
is SessionStatus.Initializing -> {
if (AuthStorage.loadAnonymousUserId() == null) {
_state.value = AuthState.Loading
}
}
is SessionStatus.RefreshFailure -> {
_state.value = AuthState.Unauthenticated
}
}
}
}
@ -119,6 +128,26 @@ object AuthRepository {
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun resetForSyncBackendChange(): Result<Unit> = runCatching {
_error.value = null
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
AuthStorage.clearAnonymousUserId()
if (!wasAnonymous) {
runCatching {
SupabaseProvider.client.auth.signOut()
}.onFailure { e ->
log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" }
}
}
_state.value = AuthState.Unauthenticated
LocalAccountDataCleaner.wipe()
}.onFailure { e ->
log.e(e) { "Sync backend auth reset failed" }
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
}
suspend fun deleteAccount(): Result<Unit> = runCatching {
_error.value = null
SupabaseProvider.client.functions.invoke("delete-account")

View file

@ -136,9 +136,10 @@ object NetworkStatusRepository {
return NetworkCondition.NoInternet
}
val backendConfig = SyncBackendRepository.selectedBackend
val supabaseReachable = probeReachable(
url = "${SupabaseConfig.URL.trimEnd('/')}/rest/v1/",
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/",
headers = mapOf("apikey" to backendConfig.anonKey),
)
if (!supabaseReachable) {
return NetworkCondition.ServersUnreachable

View file

@ -1,6 +1,7 @@
package com.nuvio.app.core.network
import com.nuvio.app.core.build.AppVersionConfig
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.annotations.SupabaseInternal
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.createSupabaseClient
@ -10,12 +11,34 @@ import io.ktor.client.plugins.defaultRequest
import io.ktor.http.HttpHeaders
object SupabaseProvider {
private data class ClientHolder(
val backend: SyncBackendConfig,
val client: SupabaseClient,
)
private var holder: ClientHolder? = null
val selectedBackend: SyncBackendConfig
get() = SyncBackendRepository.selectedBackend
@OptIn(SupabaseInternal::class)
val client by lazy {
val client: SupabaseClient
get() = clientFor(selectedBackend)
fun rebuildClient() {
holder = null
}
@OptIn(SupabaseInternal::class)
private fun clientFor(config: SyncBackendConfig): SupabaseClient {
holder
?.takeIf { it.backend.hasSameConnectionIdentity(config) }
?.let { return it.client }
val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}"
createSupabaseClient(
supabaseUrl = SupabaseConfig.URL,
supabaseKey = SupabaseConfig.ANON_KEY,
val nextClient = createSupabaseClient(
supabaseUrl = config.normalizedSupabaseUrl,
supabaseKey = config.anonKey,
) {
httpConfig {
defaultRequest {
@ -26,5 +49,7 @@ object SupabaseProvider {
install(Postgrest)
install(Functions)
}
holder = ClientHolder(backend = config, client = nextClient)
return nextClient
}
}

View file

@ -0,0 +1,121 @@
package com.nuvio.app.core.network
import kotlinx.serialization.Serializable
internal const val SYNC_BACKEND_HOSTED_ID = "hosted"
internal const val SYNC_BACKEND_NUVIO_ID = "nuvio"
@Serializable
data class SyncBackendConfig(
val id: String,
val displayName: String,
val supabaseUrl: String,
val anonKey: String,
val avatarPublicBaseUrl: String,
val schemaVersion: Int = 1,
) {
val normalizedSupabaseUrl: String
get() = supabaseUrl.trim().trimEnd('/')
val normalizedAvatarPublicBaseUrl: String
get() = avatarPublicBaseUrl.trim().trimEnd('/')
fun avatarStorageUrl(storagePath: String): String =
"${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}"
fun normalized(): SyncBackendConfig =
copy(
id = id.trim().lowercase(),
supabaseUrl = normalizedSupabaseUrl,
anonKey = anonKey.trim(),
avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl,
)
}
@Serializable
data class SyncBackendManifest(
val version: Int = 1,
val activeBackend: String,
val revision: String = "",
val forceLogoutOnChange: Boolean = true,
)
data class SyncBackendState(
val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(),
val appliedRevision: String = "",
val isLoaded: Boolean = false,
val lastManifestError: String? = null,
)
sealed interface SyncBackendRefreshResult {
data object NotConfigured : SyncBackendRefreshResult
data object Unchanged : SyncBackendRefreshResult
data class Applied(
val backend: SyncBackendConfig,
val revision: String,
) : SyncBackendRefreshResult
data class RequiresLogout(
val currentBackend: SyncBackendConfig,
val targetBackend: SyncBackendConfig,
val revision: String,
val forceLogout: Boolean,
) : SyncBackendRefreshResult
data class Failed(val message: String) : SyncBackendRefreshResult
}
@Serializable
internal data class StoredSyncBackendSelection(
val backend: SyncBackendConfig? = null,
val backendId: String = "",
val appliedRevision: String = "",
)
object SyncBackendDefaults {
fun hosted(): SyncBackendConfig =
SyncBackendConfig(
id = SYNC_BACKEND_HOSTED_ID,
displayName = "Hosted",
supabaseUrl = SupabaseConfig.URL,
anonKey = SupabaseConfig.ANON_KEY,
avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
schemaVersion = 1,
).normalized()
fun nuvio(): SyncBackendConfig =
SyncBackendConfig(
id = SYNC_BACKEND_NUVIO_ID,
displayName = "Nuvio",
supabaseUrl = SupabaseConfig.NUVIO_URL,
anonKey = SupabaseConfig.NUVIO_ANON_KEY,
avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
schemaVersion = 1,
).normalized()
fun byId(id: String): SyncBackendConfig? =
when (id.trim().lowercase()) {
SYNC_BACKEND_HOSTED_ID -> hosted()
SYNC_BACKEND_NUVIO_ID -> nuvio()
else -> null
}
}
internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean =
id == other.id &&
normalizedSupabaseUrl == other.normalizedSupabaseUrl &&
anonKey.trim() == other.anonKey.trim() &&
schemaVersion == other.schemaVersion
internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? {
if (version != 1) return null
val activeId = activeBackend.trim().lowercase()
return SyncBackendDefaults.byId(activeId)
?.takeIf { it.isUsableClientConfig() }
}
private fun SyncBackendConfig.isUsableClientConfig(): Boolean =
id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) &&
normalizedSupabaseUrl.startsWith("https://") &&
anonKey.isNotBlank() &&
!anonKey.startsWith("<") &&
normalizedAvatarPublicBaseUrl.startsWith("https://")

View file

@ -0,0 +1,122 @@
package com.nuvio.app.core.network
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object SyncBackendRepository {
private val log = Logger.withTag("SyncBackendRepository")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _state = MutableStateFlow(SyncBackendState())
val state: StateFlow<SyncBackendState> = _state.asStateFlow()
val selectedBackend: SyncBackendConfig
get() {
ensureLoaded()
return _state.value.selectedBackend
}
fun ensureLoaded() {
if (_state.value.isLoaded) return
val storedSelection = SyncBackendStorage.loadSelectionPayload()
?.takeIf { it.isNotBlank() }
?.let { payload ->
runCatching { json.decodeFromString<StoredSyncBackendSelection>(payload) }
.onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } }
.getOrNull()
}
val backend = storedSelection
?.let { selection ->
selection.backendId.ifBlank { selection.backend?.id.orEmpty() }
}
?.let(SyncBackendDefaults::byId)
?: SyncBackendDefaults.hosted()
_state.value = SyncBackendState(
selectedBackend = backend,
appliedRevision = storedSelection?.appliedRevision.orEmpty(),
isLoaded = true,
)
}
suspend fun refreshFromManifest(): SyncBackendRefreshResult {
ensureLoaded()
val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim()
if (manifestUrl.isBlank()) {
return SyncBackendRefreshResult.NotConfigured
}
val manifest = runCatching {
json.decodeFromString<SyncBackendManifest>(
fetchSyncBackendManifestText(manifestUrl),
)
}.onFailure { error ->
val message = error.message ?: "Failed to fetch sync backend manifest"
log.w(error) { message }
_state.value = _state.value.copy(lastManifestError = message)
}.getOrNull() ?: return SyncBackendRefreshResult.Failed(
_state.value.lastManifestError ?: "Failed to fetch sync backend manifest",
)
val targetBackend = manifest.backendConfigForActiveBackend()
?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid")
val revision = manifest.revision.trim()
val currentBackend = _state.value.selectedBackend
if (currentBackend.hasSameConnectionIdentity(targetBackend)) {
saveSelection(targetBackend, revision)
return SyncBackendRefreshResult.Unchanged
}
if (!manifest.forceLogoutOnChange) {
saveSelection(targetBackend, revision)
return SyncBackendRefreshResult.Applied(targetBackend, revision)
}
return SyncBackendRefreshResult.RequiresLogout(
currentBackend = currentBackend,
targetBackend = targetBackend,
revision = revision,
forceLogout = true,
)
}
fun applyBackendAfterLogout(
backend: SyncBackendConfig,
revision: String,
): SyncBackendConfig {
val normalizedBackend = backend.normalized()
saveSelection(normalizedBackend, revision)
return normalizedBackend
}
private fun saveSelection(
backend: SyncBackendConfig,
revision: String,
) {
val normalizedBackend = backend.normalized()
val payload = json.encodeToString(
StoredSyncBackendSelection(
backendId = normalizedBackend.id,
appliedRevision = revision,
),
)
SyncBackendStorage.saveSelectionPayload(payload)
_state.value = SyncBackendState(
selectedBackend = normalizedBackend,
appliedRevision = revision,
isLoaded = true,
)
}
}

View file

@ -0,0 +1,8 @@
package com.nuvio.app.core.network
internal expect object SyncBackendStorage {
fun loadSelectionPayload(): String?
fun saveSelectionPayload(payload: String)
}
internal expect suspend fun fetchSyncBackendManifestText(url: String): String

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.profiles
import androidx.compose.ui.graphics.Color
import com.nuvio.app.core.network.SyncBackendRepository
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -77,7 +78,7 @@ val PROFILE_COLORS = listOf(
)
fun avatarStorageUrl(storagePath: String): String =
"${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath"
SyncBackendRepository.selectedBackend.avatarStorageUrl(storagePath)
fun normalizedAvatarUrl(url: String?): String? =
url?.trim()?.takeIf { it.isValidAvatarUrl() }

View file

@ -15,12 +15,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SyncBackendRepository
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.NuvioSurfaceCard
@ -36,6 +41,7 @@ import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_ti
import nuvio.composeapp.generated.resources.settings_account_status
import nuvio.composeapp.generated.resources.settings_account_status_anonymous
import nuvio.composeapp.generated.resources.settings_account_status_signed_in
import nuvio.composeapp.generated.resources.settings_account_sync_backend
import org.jetbrains.compose.resources.stringResource
internal fun LazyListScope.accountSettingsContent(
@ -51,8 +57,10 @@ private fun AccountSettingsBody(
isTablet: Boolean,
) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val syncBackendState by SyncBackendRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var showSignOutConfirm by remember { mutableStateOf(false) }
val syncBackendLabel = syncBackendState.selectedBackend.displayName
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
NuvioSurfaceCard {
@ -66,44 +74,21 @@ private fun AccountSettingsBody(
when (val state = authState) {
is AuthState.Authenticated -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(Res.string.settings_account_status),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (state.isAnonymous) {
stringResource(Res.string.settings_account_status_anonymous)
} else {
stringResource(Res.string.settings_account_status_signed_in)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
}
if (!state.isAnonymous && state.email != null) {
AccountInfoRow(
label = stringResource(Res.string.settings_account_status),
value = if (state.isAnonymous) {
stringResource(Res.string.settings_account_status_anonymous)
} else {
stringResource(Res.string.settings_account_status_signed_in)
},
valueColor = MaterialTheme.colorScheme.primary,
)
state.email?.takeUnless { state.isAnonymous }?.let { email ->
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(Res.string.settings_account_email),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = state.email,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
}
AccountInfoRow(
label = stringResource(Res.string.settings_account_email),
value = email,
)
}
}
else -> {
@ -114,6 +99,12 @@ private fun AccountSettingsBody(
)
}
}
Spacer(modifier = Modifier.height(8.dp))
AccountInfoRow(
label = stringResource(Res.string.settings_account_sync_backend),
value = syncBackendLabel,
)
}
NuvioPrimaryButton(
@ -135,3 +126,32 @@ private fun AccountSettingsBody(
onDismiss = { showSignOutConfirm = false },
)
}
@Composable
private fun AccountInfoRow(
label: String,
value: String,
valueColor: Color = MaterialTheme.colorScheme.onSurface,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
modifier = Modifier.weight(1f),
text = value,
style = MaterialTheme.typography.bodyLarge,
color = valueColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
}

View file

@ -0,0 +1,43 @@
package com.nuvio.app.core.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.isSuccess
import platform.Foundation.NSUserDefaults
internal actual object SyncBackendStorage {
private const val KEY_SELECTION_PAYLOAD = "nuvio_sync_backend_selection_payload_v1"
actual fun loadSelectionPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(KEY_SELECTION_PAYLOAD)
actual fun saveSelectionPayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = KEY_SELECTION_PAYLOAD)
}
}
private val syncBackendHttpClient = HttpClient(Darwin) {
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 10_000
}
expectSuccess = false
}
internal actual suspend fun fetchSyncBackendManifestText(url: String): String {
val response = syncBackendHttpClient.get(url) {
accept(ContentType.Application.Json)
}
val payload = response.bodyAsText()
if (!response.status.isSuccess()) {
error("Sync backend manifest request failed with HTTP ${response.status.value}")
}
return payload.takeIf { it.isNotBlank() }
?: error("Sync backend manifest response was empty")
}