From d3fbfb1716192dedc214f54b992cb6951be1c23a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 29 Mar 2026 12:26:45 +0530 Subject: [PATCH] feat: Implement theme settings management; add ThemeSettingsStorage and ThemeSettingsRepository; update appearance settings UI --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../settings/ThemeSettingsStorage.android.kt | 38 +++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 9 +- .../kotlin/com/nuvio/app/core/ui/AppTheme.kt | 11 ++ .../com/nuvio/app/core/ui/NuvioTheme.kt | 34 +++- .../com/nuvio/app/core/ui/ThemeColors.kt | 100 ++++++++++++ .../nuvio/app/features/catalog/CatalogData.kt | 11 +- .../app/features/catalog/CatalogScreen.kt | 3 +- .../app/features/details/MetaDetailsParser.kt | 18 ++- .../app/features/home/HomeCatalogParser.kt | 15 +- .../com/nuvio/app/features/home/HomeModels.kt | 2 + .../home/components/HomeCatalogSection.kt | 3 +- .../settings/AppearanceSettingsPage.kt | 148 ++++++++++++++++++ .../app/features/settings/SettingsScreen.kt | 31 ++++ .../settings/ThemeSettingsRepository.kt | 47 ++++++ .../features/settings/ThemeSettingsStorage.kt | 8 + .../features/details/MetaDetailsParserTest.kt | 18 +++ .../features/home/HomeCatalogParserTest.kt | 29 ++++ .../settings/ThemeSettingsStorage.ios.kt | 28 ++++ 19 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 818204e19..68d43a43e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -11,6 +11,7 @@ import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.profiles.ProfileStorage +import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage @@ -33,6 +34,7 @@ class MainActivity : ComponentActivity() { HomeCatalogSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext) + ThemeSettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt new file mode 100644 index 000000000..c24329e5f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.settings + +import android.content.Context +import android.content.SharedPreferences + +actual object ThemeSettingsStorage { + private const val preferencesName = "nuvio_theme_settings" + private const val selectedThemeKey = "selected_theme" + private const val amoledEnabledKey = "amoled_enabled" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadSelectedTheme(): String? = + preferences?.getString(selectedThemeKey, null) + + actual fun saveSelectedTheme(themeName: String) { + preferences + ?.edit() + ?.putString(selectedThemeKey, themeName) + ?.apply() + } + + actual fun loadAmoledEnabled(): Boolean? = + preferences?.let { prefs -> + if (prefs.contains(amoledEnabledKey)) prefs.getBoolean(amoledEnabledKey, false) else null + } + + actual fun saveAmoledEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(amoledEnabledKey, enabled) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 1d922b0d1..035104c2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -82,6 +82,7 @@ import com.nuvio.app.features.settings.HomescreenSettingsScreen import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen +import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.watched.WatchedRepository @@ -162,7 +163,13 @@ fun App() { .memoryCachePolicy(CachePolicy.ENABLED) .build() } - NuvioTheme { + val selectedTheme by remember { + ThemeSettingsRepository.ensureLoaded() + ThemeSettingsRepository.selectedTheme + }.collectAsStateWithLifecycle() + val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() + + NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { AuthRepository.initialize() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt new file mode 100644 index 000000000..23321cf8c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.core.ui + +enum class AppTheme(val displayName: String) { + CRIMSON("Crimson"), + OCEAN("Ocean"), + VIOLET("Violet"), + EMERALD("Emerald"), + AMBER("Amber"), + ROSE("Rose"), + WHITE("White"), +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index eee0c6a9f..7ac076ffe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -6,6 +6,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RippleConfiguration import androidx.compose.material3.Typography import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalDensity @@ -15,6 +17,31 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.sp +val LocalAppTheme = staticCompositionLocalOf { AppTheme.WHITE } + +val MaterialTheme.appTheme: AppTheme + @Composable + @ReadOnlyComposable + get() = LocalAppTheme.current + +private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false) = darkColorScheme( + primary = palette.secondary, + onPrimary = palette.onSecondary, + primaryContainer = palette.focusBackground, + onPrimaryContainer = palette.onSecondary, + secondary = palette.secondaryVariant, + onSecondary = palette.onSecondaryVariant, + background = if (amoled) Color.Black else palette.background, + onBackground = Color(0xFFF5F7F8), + surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated, + onSurface = Color(0xFFF5F7F8), + surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard, + onSurfaceVariant = Color(0xFF969CA3), + outline = Color(0xFF252A2A), + error = Color(0xFFE36A8A), + onError = Color(0xFFFCE5EC), +) + private val NuvioDarkColors = darkColorScheme( primary = Color(0xFF2E86B8), onPrimary = Color(0xFFD2E8F7), @@ -139,8 +166,12 @@ private val NuvioRippleConfiguration = RippleConfiguration( @Composable fun NuvioTheme( darkTheme: Boolean = isSystemInDarkTheme(), + appTheme: AppTheme = AppTheme.WHITE, + amoled: Boolean = false, content: @Composable () -> Unit, ) { + val colorScheme = buildColorScheme(ThemeColors.getColorPalette(appTheme), amoled = amoled) + val density = LocalDensity.current CompositionLocalProvider( LocalDensity provides Density( @@ -149,9 +180,10 @@ fun NuvioTheme( ), LocalNuvioTypeScale provides NuvioTypeTokens, LocalRippleConfiguration provides NuvioRippleConfiguration, + LocalAppTheme provides appTheme, ) { MaterialTheme( - colorScheme = if (darkTheme) NuvioDarkColors else NuvioDarkColors, + colorScheme = colorScheme, typography = NuvioTypography, content = content, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt new file mode 100644 index 000000000..5ee217bfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt @@ -0,0 +1,100 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.graphics.Color + +data class ThemeColorPalette( + val secondary: Color, + val secondaryVariant: Color, + val onSecondary: Color = Color.White, + val onSecondaryVariant: Color = Color.White, + val focusRing: Color, + val focusBackground: Color, + val background: Color = Color(0xFF0D0D0D), + val backgroundElevated: Color = Color(0xFF1A1A1A), + val backgroundCard: Color = Color(0xFF242424), +) + +object ThemeColors { + + val Crimson = ThemeColorPalette( + secondary = Color(0xFFE53935), + secondaryVariant = Color(0xFFC62828), + focusRing = Color(0xFFFF5252), + focusBackground = Color(0xFF3D1A1A), + background = Color(0xFF0D0D0D), + backgroundElevated = Color(0xFF1A1A1A), + backgroundCard = Color(0xFF241A1A), + ) + + val Ocean = ThemeColorPalette( + secondary = Color(0xFF1E88E5), + secondaryVariant = Color(0xFF1565C0), + focusRing = Color(0xFF42A5F5), + focusBackground = Color(0xFF1A2D3D), + background = Color(0xFF0D0D0F), + backgroundElevated = Color(0xFF1A1A1E), + backgroundCard = Color(0xFF1A1F24), + ) + + val Violet = ThemeColorPalette( + secondary = Color(0xFF8E24AA), + secondaryVariant = Color(0xFF6A1B9A), + focusRing = Color(0xFFAB47BC), + focusBackground = Color(0xFF2D1A3D), + background = Color(0xFF0D0D0F), + backgroundElevated = Color(0xFF1A1A1E), + backgroundCard = Color(0xFF1F1A24), + ) + + val Emerald = ThemeColorPalette( + secondary = Color(0xFF43A047), + secondaryVariant = Color(0xFF2E7D32), + focusRing = Color(0xFF66BB6A), + focusBackground = Color(0xFF1A3D1E), + background = Color(0xFF0D0D0D), + backgroundElevated = Color(0xFF1A1A1A), + backgroundCard = Color(0xFF1A241A), + ) + + val Amber = ThemeColorPalette( + secondary = Color(0xFFFB8C00), + secondaryVariant = Color(0xFFEF6C00), + focusRing = Color(0xFFFFA726), + focusBackground = Color(0xFF3D2D1A), + background = Color(0xFF0F0D0D), + backgroundElevated = Color(0xFF1E1A1A), + backgroundCard = Color(0xFF24201A), + ) + + val Rose = ThemeColorPalette( + secondary = Color(0xFFD81B60), + secondaryVariant = Color(0xFFC2185B), + focusRing = Color(0xFFEC407A), + focusBackground = Color(0xFF3D1A2D), + background = Color(0xFF0D0D0D), + backgroundElevated = Color(0xFF1A1A1A), + backgroundCard = Color(0xFF241A1F), + ) + + val White = ThemeColorPalette( + secondary = Color(0xFFF5F5F5), + secondaryVariant = Color(0xFFE0E0E0), + onSecondary = Color(0xFF111111), + onSecondaryVariant = Color(0xFF111111), + focusRing = Color(0xFFFFFFFF), + focusBackground = Color(0xFF303030), + background = Color(0xFF0D0D0D), + backgroundElevated = Color(0xFF1A1A1A), + backgroundCard = Color(0xFF222222), + ) + + fun getColorPalette(theme: AppTheme): ThemeColorPalette = when (theme) { + AppTheme.CRIMSON -> Crimson + AppTheme.OCEAN -> Ocean + AppTheme.VIOLET -> Violet + AppTheme.EMERALD -> Emerald + AppTheme.AMBER -> Amber + AppTheme.ROSE -> Rose + AppTheme.WHITE -> White + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt index 5109d75e6..ace9fc985 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.home.HomeCatalogParser import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.stableKey const val CATALOG_PAGE_SIZE = 100 @@ -30,14 +31,14 @@ suspend fun fetchCatalogPage( skip = skip, ), ) - val items = HomeCatalogParser.parseCatalog(payload) - val nextSkip = if (items.size >= CATALOG_PAGE_SIZE) { + val parsed = HomeCatalogParser.parseCatalogResponse(payload) + val nextSkip = if (parsed.rawItemCount >= CATALOG_PAGE_SIZE) { (skip ?: 0) + CATALOG_PAGE_SIZE } else { null } return CatalogPage( - items = items, + items = parsed.items, nextSkip = nextSkip, ) } @@ -50,11 +51,11 @@ fun mergeCatalogItems( incoming: List, ): List { if (incoming.isEmpty()) return existing - val seen = existing.mapTo(mutableSetOf()) { item -> "${item.type}:${item.id}" } + val seen = existing.mapTo(mutableSetOf()) { item -> item.stableKey() } return buildList(existing.size + incoming.size) { addAll(existing) incoming.forEach { item -> - val key = "${item.type}:${item.id}" + val key = item.stableKey() if (seen.add(key)) { add(item) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index e1b06d1a1..78e341cf5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -44,6 +44,7 @@ import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.stableKey import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -116,7 +117,7 @@ fun CatalogScreen( } else { items( items = uiState.items, - key = { item -> "${item.type}:${item.id}" }, + key = { item -> item.stableKey() }, ) { item -> CatalogPosterTile( item = item, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index dba082bf9..0e42ab8c0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -16,8 +16,8 @@ internal object MetaDetailsParser { fun parse(payload: String): MetaDetails { val root = json.parseToJsonElement(payload).asJsonObjectOrNull() ?: error("Expected top-level JSON object in response") - val meta = root["meta"].asJsonObjectOrNull() - ?: error("Missing or invalid 'meta' object in response") + val meta = root.extractMetaObject() + ?: error("Response did not contain a valid meta object") val links = meta.links() return MetaDetails( @@ -88,6 +88,20 @@ internal object MetaDetailsParser { private fun JsonObject.behaviorHints(): JsonObject = this["behaviorHints"] as? JsonObject ?: JsonObject(emptyMap()) + private fun JsonObject.extractMetaObject(): JsonObject? { + val data = this["data"].asJsonObjectOrNull() + val candidates = listOfNotNull( + this["meta"].asJsonObjectOrNull(), + data?.get("meta").asJsonObjectOrNull(), + data?.takeIf { it.looksLikeMetaObject() }, + this.takeIf { it.looksLikeMetaObject() }, + ) + return candidates.firstOrNull() + } + + private fun JsonObject.looksLikeMetaObject(): Boolean = + string("id") != null && string("type") != null && string("name") != null + private fun JsonObject.directors(links: List): List { val appExtras = this["app_extras"] as? JsonObject val topLevel = stringListOrCsv("director") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 1d8885ab1..9949e88e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -13,8 +13,12 @@ internal object HomeCatalogParser { } fun parseCatalog(payload: String): List { + return parseCatalogResponse(payload).items + } + + fun parseCatalogResponse(payload: String): ParsedCatalogResponse { val root = json.parseToJsonElement(payload).jsonObject - return root.array("metas") + val parsedItems = root.array("metas") .mapNotNull { element -> val meta = element as? JsonObject ?: return@mapNotNull null val id = meta.string("id") @@ -41,6 +45,10 @@ internal object HomeCatalogParser { }, ) } + return ParsedCatalogResponse( + items = parsedItems.distinctBy { it.stableKey() }, + rawItemCount = root.array("metas").size, + ) } private fun JsonObject.string(name: String): String? = @@ -56,3 +64,8 @@ internal object HomeCatalogParser { else -> PosterShape.Poster } } + +data class ParsedCatalogResponse( + val items: List, + val rawItemCount: Int, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 49aee223f..8013d2e0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -17,6 +17,8 @@ data class MetaPreview( val genres: List = emptyList(), ) +fun MetaPreview.stableKey(): String = "$type:$id" + enum class PosterShape { Poster, Square, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index e98b405f9..d87f05baa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -7,6 +7,7 @@ import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.stableKey @Composable fun HomeCatalogRowSection( @@ -26,7 +27,7 @@ fun HomeCatalogRowSection( rowContentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), onViewAllClick = onViewAllClick, viewAllPillSize = NuvioViewAllPillSize.Compact, - key = { item -> item.id }, + key = { item -> item.stableKey() }, ) { item -> HomePosterCard( item = item, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index e1ac11b24..bf452f933 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -1,13 +1,93 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.rounded.Style +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.ThemeColors +@OptIn(ExperimentalLayoutApi::class) internal fun LazyListScope.appearanceSettingsContent( isTablet: Boolean, + selectedTheme: AppTheme, + onThemeSelected: (AppTheme) -> Unit, + amoledEnabled: Boolean, + onAmoledToggle: (Boolean) -> Unit, onContinueWatchingClick: () -> Unit, ) { + item { + SettingsSection( + title = "THEME", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + val themes = listOf(AppTheme.WHITE) + AppTheme.entries.filterNot { it == AppTheme.WHITE } + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isTablet) 20.dp else 16.dp, + vertical = if (isTablet) 18.dp else 14.dp, + ), + horizontalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), + ) { + themes.forEach { theme -> + ThemeChip( + theme = theme, + isSelected = theme == selectedTheme, + onClick = { onThemeSelected(theme) }, + ) + } + } + } + } + } + + item { + SettingsSection( + title = "DISPLAY", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = "AMOLED Black", + description = "Use pure black backgrounds for OLED screens.", + checked = amoledEnabled, + isTablet = isTablet, + onCheckedChange = onAmoledToggle, + ) + } + } + } + item { SettingsSection( title = "HOME", @@ -25,3 +105,71 @@ internal fun LazyListScope.appearanceSettingsContent( } } } + +@Composable +private fun ThemeChip( + theme: AppTheme, + isSelected: Boolean, + onClick: () -> Unit, +) { + val palette = ThemeColors.getColorPalette(theme) + + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .then( + if (isSelected) { + Modifier.border( + width = 1.5.dp, + color = palette.focusRing, + shape = RoundedCornerShape(12.dp), + ) + } else { + Modifier + }, + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(palette.secondary), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = palette.onSecondary, + modifier = Modifier.size(22.dp), + ) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = theme.displayName, + style = MaterialTheme.typography.labelMedium, + color = if (isSelected) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Box( + modifier = Modifier + .size(width = 36.dp, height = 3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(palette.focusRing), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index ae36fc99f..8f69a62e6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader @@ -56,6 +57,12 @@ fun SettingsScreen( PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val selectedTheme by remember { + ThemeSettingsRepository.ensureLoaded() + ThemeSettingsRepository.selectedTheme + }.collectAsStateWithLifecycle() + val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() + var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() @@ -70,6 +77,10 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + selectedTheme = selectedTheme, + onThemeSelected = ThemeSettingsRepository::setTheme, + amoledEnabled = amoledEnabled, + onAmoledToggle = ThemeSettingsRepository::setAmoled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, onContinueWatchingClick = onContinueWatchingClick, @@ -81,6 +92,10 @@ fun SettingsScreen( page = page, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, + selectedTheme = selectedTheme, + onThemeSelected = ThemeSettingsRepository::setTheme, + amoledEnabled = amoledEnabled, + onAmoledToggle = ThemeSettingsRepository::setAmoled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenClick, onContinueWatchingClick = onContinueWatchingClick, @@ -96,6 +111,10 @@ private fun MobileSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + selectedTheme: AppTheme, + onThemeSelected: (AppTheme) -> Unit, + amoledEnabled: Boolean, + onAmoledToggle: (Boolean) -> Unit, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, @@ -126,6 +145,10 @@ private fun MobileSettingsScreen( ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = false, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, onContinueWatchingClick = onContinueWatchingClick, ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( @@ -142,6 +165,10 @@ private fun TabletSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, + selectedTheme: AppTheme, + onThemeSelected: (AppTheme) -> Unit, + amoledEnabled: Boolean, + onAmoledToggle: (Boolean) -> Unit, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, @@ -221,6 +248,10 @@ private fun TabletSettingsScreen( ) SettingsPage.Appearance -> appearanceSettingsContent( isTablet = true, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, onContinueWatchingClick = onContinueWatchingClick, ) SettingsPage.ContentDiscovery -> contentDiscoveryContent( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt new file mode 100644 index 000000000..2af59e9df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -0,0 +1,47 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.core.ui.AppTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object ThemeSettingsRepository { + private val _selectedTheme = MutableStateFlow(AppTheme.WHITE) + val selectedTheme: StateFlow = _selectedTheme.asStateFlow() + + private val _amoledEnabled = MutableStateFlow(false) + val amoledEnabled: StateFlow = _amoledEnabled.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + hasLoaded = true + val stored = ThemeSettingsStorage.loadSelectedTheme() + val theme = if (stored != null) { + try { + AppTheme.valueOf(stored) + } catch (_: IllegalArgumentException) { + AppTheme.WHITE + } + } else { + AppTheme.WHITE + } + _selectedTheme.value = theme + _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false + } + + fun setTheme(theme: AppTheme) { + ensureLoaded() + if (_selectedTheme.value == theme) return + _selectedTheme.value = theme + ThemeSettingsStorage.saveSelectedTheme(theme.name) + } + + fun setAmoled(enabled: Boolean) { + ensureLoaded() + if (_amoledEnabled.value == enabled) return + _amoledEnabled.value = enabled + ThemeSettingsStorage.saveAmoledEnabled(enabled) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt new file mode 100644 index 000000000..136168ce4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +internal expect object ThemeSettingsStorage { + fun loadSelectedTheme(): String? + fun saveSelectedTheme(themeName: String) + fun loadAmoledEnabled(): Boolean? + fun saveAmoledEnabled(enabled: Boolean) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 98f01a9a1..7a30963ea 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.details import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith class MetaDetailsParserTest { @@ -11,4 +12,21 @@ class MetaDetailsParserTest { MetaDetailsParser.parse("""{"meta":null}""") } } + + @Test + fun `parse accepts bare meta object response`() { + val result = MetaDetailsParser.parse( + """ + { + "id": "mal:62516", + "type": "series", + "name": "The Fragrant Flower Blooms with Dignity" + } + """.trimIndent(), + ) + + assertEquals("mal:62516", result.id) + assertEquals("series", result.type) + assertEquals("The Fragrant Flower Blooms with Dignity", result.name) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt new file mode 100644 index 000000000..5c4deda16 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals + +class HomeCatalogParserTest { + + @Test + fun `parse catalog response de-duplicates repeated metas but preserves raw count`() { + val result = HomeCatalogParser.parseCatalogResponse( + """ + { + "metas": [ + { "id": "mal:62516", "type": "series", "name": "A" }, + { "id": "mal:62516", "type": "series", "name": "A duplicate" }, + { "id": "mal:1", "type": "movie", "name": "B" } + ] + } + """.trimIndent(), + ) + + assertEquals(3, result.rawItemCount) + assertEquals( + listOf("series:mal:62516", "movie:mal:1"), + result.items.map { it.stableKey() }, + ) + assertEquals("A", result.items.first().name) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt new file mode 100644 index 000000000..82f3e5bd8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.settings + +import platform.Foundation.NSUserDefaults + +actual object ThemeSettingsStorage { + private const val selectedThemeKey = "selected_theme" + private const val amoledEnabledKey = "amoled_enabled" + + actual fun loadSelectedTheme(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(selectedThemeKey) + + actual fun saveSelectedTheme(themeName: String) { + NSUserDefaults.standardUserDefaults.setObject(themeName, forKey = selectedThemeKey) + } + + actual fun loadAmoledEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + return if (defaults.objectForKey(amoledEnabledKey) != null) { + defaults.boolForKey(amoledEnabledKey) + } else { + null + } + } + + actual fun saveAmoledEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = amoledEnabledKey) + } +}