feat: Implement theme settings management; add ThemeSettingsStorage and ThemeSettingsRepository; update appearance settings UI

This commit is contained in:
tapframe 2026-03-29 12:26:45 +05:30
parent f3c854751e
commit d3fbfb1716
19 changed files with 543 additions and 12 deletions

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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"),
}

View file

@ -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,
)

View file

@ -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
}
}

View file

@ -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<MetaPreview>,
): List<MetaPreview> {
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)
}

View file

@ -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,

View file

@ -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<MetaLink>): List<String> {
val appExtras = this["app_extras"] as? JsonObject
val topLevel = stringListOrCsv("director")

View file

@ -13,8 +13,12 @@ internal object HomeCatalogParser {
}
fun parseCatalog(payload: String): List<MetaPreview> {
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<MetaPreview>,
val rawItemCount: Int,
)

View file

@ -17,6 +17,8 @@ data class MetaPreview(
val genres: List<String> = emptyList(),
)
fun MetaPreview.stableKey(): String = "$type:$id"
enum class PosterShape {
Poster,
Square,

View file

@ -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,

View file

@ -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),
)
}
}

View file

@ -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(

View file

@ -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<AppTheme> = _selectedTheme.asStateFlow()
private val _amoledEnabled = MutableStateFlow(false)
val amoledEnabled: StateFlow<Boolean> = _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)
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}