diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 7fc621b7..a110d6da 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -25,6 +25,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.plugins.PluginStorage +import com.nuvio.app.features.profiles.AvatarStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.search.SearchHistoryStorage @@ -59,6 +60,7 @@ class MainActivity : ComponentActivity() { HomeCatalogSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext) + AvatarStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 3d097f88..2f2932ee 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -9,6 +9,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_home_catalog_settings", "nuvio_player_settings", "nuvio_profile_cache", + "nuvio_avatar_cache", "nuvio_theme_settings", "nuvio_poster_card_style", "nuvio_mdblist_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.android.kt new file mode 100644 index 00000000..ccf2d606 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.android.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.features.profiles + +import android.content.Context +import android.content.SharedPreferences + +actual object AvatarStorage { + private const val preferencesName = "nuvio_avatar_cache" + private const val payloadKey = "avatar_catalog_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(payloadKey, null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(payloadKey, payload) + ?.apply() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 255b8a6f..106b6191 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -278,40 +278,81 @@ fun App() { AuthRepository.initialize() } + LaunchedEffect(Unit) { + NetworkStatusRepository.ensureStarted() + ProfileRepository.loadCachedProfiles() + } + val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val networkStatusUiState by remember { + NetworkStatusRepository.uiState + }.collectAsStateWithLifecycle() var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(authState) { + fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { + if (profiles.isEmpty()) { + autoSkipProfileSelection = true + gateScreen = AppGateScreen.ProfileSelection.name + return + } + + autoSkipProfileSelection = true + if (profiles.size == 1) { + val onlyProfile = profiles.first() + ProfileRepository.selectProfile(onlyProfile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(onlyProfile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false + } else { + gateScreen = AppGateScreen.ProfileSelection.name + } + } + + LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { + val cachedProfiles = profileState.profiles + val allowOfflineProfileAccess = + cachedProfiles.isNotEmpty() && + authState !is AuthState.Authenticated && + networkStatusUiState.condition != NetworkCondition.Online + when (authState) { - is AuthState.Loading -> gateScreen = AppGateScreen.Loading.name + is AuthState.Loading -> { + if (allowOfflineProfileAccess) { + enterProfileGate(cachedProfiles, syncOnEnter = false) + } else { + gateScreen = AppGateScreen.Loading.name + } + } is AuthState.Unauthenticated -> { - ProfileRepository.clearInMemory() - gateScreen = AppGateScreen.Auth.name + if (allowOfflineProfileAccess) { + enterProfileGate(cachedProfiles, syncOnEnter = false) + } else { + ProfileRepository.clearInMemory() + gateScreen = AppGateScreen.Auth.name + } } is AuthState.Authenticated -> { val authenticatedState = authState as AuthState.Authenticated ProfileRepository.ensureLoaded(authenticatedState.userId) if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) { - autoSkipProfileSelection = true - val cachedProfiles = ProfileRepository.state.value.profiles - if (cachedProfiles.size == 1) { - val onlyProfile = cachedProfiles.first() - ProfileRepository.selectProfile(onlyProfile.profileIndex) - SyncManager.pullAllForProfile(onlyProfile.profileIndex) - gateScreen = AppGateScreen.Main.name - autoSkipProfileSelection = false - } else { - gateScreen = AppGateScreen.ProfileSelection.name - } + enterProfileGate(ProfileRepository.state.value.profiles, syncOnEnter = true) } } } } + LaunchedEffect((authState as? AuthState.Authenticated)?.userId) { + val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect + ProfileRepository.ensureLoaded(authenticatedState.userId) + ProfileRepository.pullProfiles() + } + LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) { if ( autoSkipProfileSelection && @@ -352,7 +393,9 @@ fun App() { ProfileSelectionScreen( onProfileSelected = { profile -> ProfileRepository.selectProfile(profile.profileIndex) - SyncManager.pullAllForProfile(profile.profileIndex) + if (authState is AuthState.Authenticated) { + SyncManager.pullAllForProfile(profile.profileIndex) + } gateScreen = AppGateScreen.Main.name }, onEditProfile = { profile -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarRepository.kt index 16afe389..9598c428 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarRepository.kt @@ -7,34 +7,78 @@ import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +private data class StoredAvatarCatalogPayload( + val items: List = emptyList(), +) object AvatarRepository { private val log = Logger.withTag("AvatarRepository") + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val _avatars = MutableStateFlow>(emptyList()) val avatars: StateFlow> = _avatars.asStateFlow() private var loaded = false + private var cacheHydrated = false + private var fetchInFlight = false suspend fun fetchAvatars() { + hydrateFromCacheIfNeeded() if (loaded && _avatars.value.isNotEmpty()) return doFetch() } suspend fun refreshAvatars() { + hydrateFromCacheIfNeeded() doFetch() } + private fun hydrateFromCacheIfNeeded() { + if (cacheHydrated) return + cacheHydrated = true + + val payload = AvatarStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) return + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() ?: return + + val items = stored.items + .filter { it.isActive } + .sortedWith(compareBy({ it.category }, { it.sortOrder })) + if (items.isEmpty()) return + + _avatars.value = items + loaded = true + } + private suspend fun doFetch() { + if (fetchInFlight) return + fetchInFlight = true runCatching { val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog") val items = result.decodeList() - _avatars.value = items.filter { it.isActive }.sortedWith( + val activeItems = items.filter { it.isActive }.sortedWith( compareBy({ it.category }, { it.sortOrder }), ) + _avatars.value = activeItems loaded = true + AvatarStorage.savePayload( + json.encodeToString( + StoredAvatarCatalogPayload(items = activeItems), + ), + ) }.onFailure { e -> log.e(e) { "Failed to fetch avatar catalog" } + }.also { + fetchInFlight = false } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.kt new file mode 100644 index 00000000..97e5c6a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.profiles + +internal expect object AvatarStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 13ec3685..4a0e7142 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioScreen @@ -79,9 +81,13 @@ fun ProfileEditScreen( var showDeleteConfirm by remember { mutableStateOf(false) } var showPinSetup by remember { mutableStateOf(false) } var showPinClear by remember { mutableStateOf(false) } + val authState by AuthRepository.state.collectAsStateWithLifecycle() val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() - LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() } + LaunchedEffect(Unit) { + AvatarRepository.fetchAvatars() + AvatarRepository.refreshAvatars() + } LaunchedEffect(isNew, avatars, selectedAvatarId) { if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { selectedAvatarId = avatars.first().id @@ -272,7 +278,11 @@ fun ProfileEditScreen( hasExistingPin = currentProfile.pinEnabled, onDone = { showPinSetup = false - scope.launch { ProfileRepository.pullProfiles() } + scope.launch { + if (authState is AuthState.Authenticated) { + ProfileRepository.pullProfiles() + } + } }, onDismiss = { showPinSetup = false }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index b9871947..0e49bd1a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -60,35 +60,31 @@ object ProfileRepository { val activeProfileId: Int get() = activeProfileIndex + fun loadCachedProfiles(): Boolean { + val stored = decodeStoredPayload() ?: return false + loadedCacheForUserId = stored.userId + applyStoredPayload(stored) + return _state.value.profiles.isNotEmpty() + } + fun ensureLoaded(userId: String) { if (loadedCacheForUserId == userId && _state.value.isLoaded) return + val stored = decodeStoredPayload() loadedCacheForUserId = userId - val payload = ProfileStorage.loadPayload().orEmpty().trim() - if (payload.isEmpty()) { + if (stored == null) { _state.value = ProfileState() activeProfileIndex = 1 return } - val stored = runCatching { - json.decodeFromString(payload) - }.getOrNull() ?: return - if (stored.userId != userId) { _state.value = ProfileState() activeProfileIndex = 1 return } - val profiles = stored.profiles.sortedBy { it.profileIndex } - activeProfileIndex = stored.activeProfileIndex - _state.value = ProfileState( - profiles = profiles, - activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(), - isLoaded = profiles.isNotEmpty(), - ) - _state.value.activeProfile?.let { activeProfileIndex = it.profileIndex } + applyStoredPayload(stored) } fun clearInMemory() { @@ -346,6 +342,26 @@ object ProfileRepository { persist() } + private fun decodeStoredPayload(): StoredProfilePayload? { + val payload = ProfileStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) return null + + return runCatching { + json.decodeFromString(payload) + }.getOrNull() + } + + private fun applyStoredPayload(stored: StoredProfilePayload) { + val profiles = stored.profiles.sortedBy { it.profileIndex } + activeProfileIndex = stored.activeProfileIndex + _state.value = ProfileState( + profiles = profiles, + activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(), + isLoaded = profiles.isNotEmpty(), + ) + _state.value.activeProfile?.let { activeProfileIndex = it.profileIndex } + } + private fun persist() { val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return ProfileStorage.savePayload( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index dcb12fd9..4b4f165c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -67,6 +69,7 @@ fun ProfileSelectionScreen( onAddProfile: () -> Unit, modifier: Modifier = Modifier, ) { + val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var pinDialogProfile by remember { mutableStateOf(null) } @@ -77,11 +80,16 @@ fun ProfileSelectionScreen( val manageAlpha = remember { Animatable(0f) } LaunchedEffect(Unit) { - ProfileRepository.pullProfiles() AvatarRepository.fetchAvatars() AvatarRepository.refreshAvatars() } + LaunchedEffect(authState) { + if (authState is AuthState.Authenticated) { + ProfileRepository.pullProfiles() + } + } + LaunchedEffect(Unit) { launch { titleAlpha.animateTo(1f, tween(600, easing = FastOutSlowInEasing)) } launch { titleOffset.animateTo(0f, tween(600, easing = FastOutSlowInEasing)) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 0c21f16b..198956be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -83,6 +83,7 @@ fun ProfileSwitcherTab( LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() + AvatarRepository.refreshAvatars() } val haptic = LocalHapticFeedback.current diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index ce228ea5..1da8d0a2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -3,7 +3,10 @@ package com.nuvio.app.core.storage import platform.Foundation.NSUserDefaults internal actual object PlatformLocalAccountDataCleaner { - private val plainKeys = listOf("profile_payload") + private val plainKeys = listOf( + "profile_payload", + "avatar_catalog_payload", + ) private val profileIndexedPrefixes = listOf( "installed_manifest_urls_", "plugins_state_", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.ios.kt new file mode 100644 index 00000000..e5888091 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/AvatarStorage.ios.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.profiles + +import platform.Foundation.NSUserDefaults + +actual object AvatarStorage { + private const val payloadKey = "avatar_catalog_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(payloadKey) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey) + } +} \ No newline at end of file