From c82d94c71e8d7ba05a7989ccd93ae42f9725126a Mon Sep 17 00:00:00 2001 From: CrissZollo Date: Mon, 2 Feb 2026 20:50:43 +0100 Subject: [PATCH] Implement theme selection system with 6 color themes Add settings screen for users to choose between Crimson, Ocean, Violet, Emerald, Amber, and Rose themes. Each theme customizes accent colors (focus states, buttons) and includes subtle background tinting for a cohesive visual experience. Theme selection persists via DataStore. --- app/build.gradle.kts | 4 +- .../main/java/com/nuvio/tv/MainActivity.kt | 12 +- .../com/nuvio/tv/data/local/ThemeDataStore.kt | 40 ++++ .../com/nuvio/tv/domain/model/AppTheme.kt | 10 + .../com/nuvio/tv/ui/components/ContentCard.kt | 11 +- .../ui/components/ContinueWatchingSection.kt | 2 +- .../tv/ui/components/SidebarNavigation.kt | 4 +- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 10 +- .../java/com/nuvio/tv/ui/navigation/Screen.kt | 1 + .../tv/ui/screens/detail/EpisodesSection.kt | 4 +- .../nuvio/tv/ui/screens/detail/HeroSection.kt | 18 +- .../tv/ui/screens/library/LibraryScreen.kt | 2 +- .../tv/ui/screens/plugin/PluginScreen.kt | 22 ++- .../tv/ui/screens/settings/SettingsScreen.kt | 26 ++- .../screens/settings/ThemeSettingsScreen.kt | 172 ++++++++++++++++++ .../settings/ThemeSettingsViewModel.kt | 52 ++++++ .../ui/screens/settings/TmdbSettingsScreen.kt | 11 +- .../tv/ui/screens/stream/StreamScreen.kt | 10 +- .../main/java/com/nuvio/tv/ui/theme/Color.kt | 112 ++++++++++-- .../main/java/com/nuvio/tv/ui/theme/Theme.kt | 83 ++++++--- .../java/com/nuvio/tv/ui/theme/ThemeColors.kt | 93 ++++++++++ 21 files changed, 624 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/nuvio/tv/data/local/ThemeDataStore.kt create mode 100644 app/src/main/java/com/nuvio/tv/domain/model/AppTheme.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsViewModel.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/theme/ThemeColors.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 590ad89d..2d58fa4c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,9 +8,7 @@ plugins { android { namespace = "com.nuvio.tv" - compileSdk { - version = release(36) - } + compileSdk = 36 defaultConfig { applicationId = "com.nuvio.tv" diff --git a/app/src/main/java/com/nuvio/tv/MainActivity.kt b/app/src/main/java/com/nuvio/tv/MainActivity.kt index 0c7b75e2..bd5fa482 100644 --- a/app/src/main/java/com/nuvio/tv/MainActivity.kt +++ b/app/src/main/java/com/nuvio/tv/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.unit.dp @@ -44,18 +45,27 @@ import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import coil.compose.AsyncImage +import com.nuvio.tv.data.local.ThemeDataStore +import com.nuvio.tv.domain.model.AppTheme import com.nuvio.tv.ui.navigation.NuvioNavHost import com.nuvio.tv.ui.navigation.Screen import com.nuvio.tv.ui.theme.NuvioTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + + @Inject + lateinit var themeDataStore: ThemeDataStore + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - NuvioTheme { + val currentTheme by themeDataStore.selectedTheme.collectAsState(initial = AppTheme.CRIMSON) + + NuvioTheme(appTheme = currentTheme) { Surface( modifier = Modifier.fillMaxSize(), shape = RectangleShape diff --git a/app/src/main/java/com/nuvio/tv/data/local/ThemeDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/ThemeDataStore.kt new file mode 100644 index 00000000..5d1231bc --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/data/local/ThemeDataStore.kt @@ -0,0 +1,40 @@ +package com.nuvio.tv.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.nuvio.tv.domain.model.AppTheme +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.themeDataStore: DataStore by preferencesDataStore(name = "theme_settings") + +@Singleton +class ThemeDataStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private val dataStore = context.themeDataStore + + private val themeKey = stringPreferencesKey("selected_theme") + + val selectedTheme: Flow = dataStore.data.map { prefs -> + val themeName = prefs[themeKey] ?: AppTheme.CRIMSON.name + try { + AppTheme.valueOf(themeName) + } catch (e: IllegalArgumentException) { + AppTheme.CRIMSON + } + } + + suspend fun setTheme(theme: AppTheme) { + dataStore.edit { prefs -> + prefs[themeKey] = theme.name + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/domain/model/AppTheme.kt b/app/src/main/java/com/nuvio/tv/domain/model/AppTheme.kt new file mode 100644 index 00000000..b7dbea15 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/domain/model/AppTheme.kt @@ -0,0 +1,10 @@ +package com.nuvio.tv.domain.model + +enum class AppTheme(val displayName: String) { + CRIMSON("Crimson"), + OCEAN("Ocean"), + VIOLET("Violet"), + EMERALD("Emerald"), + AMBER("Amber"), + ROSE("Rose") +} diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt index fde3bfd7..ce4d8a1a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt @@ -28,7 +28,6 @@ import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.Glow import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil.compose.AsyncImage @@ -79,18 +78,12 @@ fun ContentCard( ), border = CardDefaults.border( focusedBorder = Border( - border = BorderStroke(3.dp, NuvioColors.FocusRing), + border = BorderStroke(2.dp, NuvioColors.FocusRing), shape = RoundedCornerShape(8.dp) ) ), scale = CardDefaults.scale( - focusedScale = 1.08f - ), - glow = CardDefaults.glow( - focusedGlow = Glow( - elevation = 8.dp, - elevationColor = NuvioColors.FocusRing.copy(alpha = 0.3f) - ) + focusedScale = 1.05f ) ) { Box( diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt index 95a08a4a..e0fac566 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt @@ -101,7 +101,7 @@ private fun ContinueWatchingCard( ), colors = CardDefaults.colors( containerColor = NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.BackgroundCard + focusedContainerColor = NuvioColors.FocusBackground ), border = CardDefaults.border( focusedBorder = Border( diff --git a/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt b/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt index 87846506..671082c1 100644 --- a/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt +++ b/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt @@ -111,7 +111,7 @@ private fun SidebarNavItem( label = "navItemBackground" ) val borderColor by animateColorAsState( - targetValue = if (isFocused) NuvioColors.BorderFocused else Color.Transparent, + targetValue = if (isFocused) NuvioColors.FocusRing else Color.Transparent, label = "navItemBorder" ) @@ -121,7 +121,7 @@ private fun SidebarNavItem( .height(56.dp) .clip(shape) .background(backgroundColor) - .border(width = 1.dp, color = borderColor, shape = shape) + .border(width = 2.dp, color = borderColor, shape = shape) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .onFocusChanged { state -> isFocused = state.isFocused diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 62d5517f..a1558d34 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -14,6 +14,7 @@ import com.nuvio.tv.ui.screens.player.PlayerScreen import com.nuvio.tv.ui.screens.plugin.PluginScreen import com.nuvio.tv.ui.screens.search.SearchScreen import com.nuvio.tv.ui.screens.settings.SettingsScreen +import com.nuvio.tv.ui.screens.settings.ThemeSettingsScreen import com.nuvio.tv.ui.screens.settings.TmdbSettingsScreen import com.nuvio.tv.ui.screens.stream.StreamScreen @@ -240,7 +241,8 @@ fun NuvioNavHost( composable(Screen.Settings.route) { SettingsScreen( onNavigateToPlugins = { navController.navigate(Screen.Plugins.route) }, - onNavigateToTmdb = { navController.navigate(Screen.TmdbSettings.route) } + onNavigateToTmdb = { navController.navigate(Screen.TmdbSettings.route) }, + onNavigateToTheme = { navController.navigate(Screen.ThemeSettings.route) } ) } @@ -250,6 +252,12 @@ fun NuvioNavHost( ) } + composable(Screen.ThemeSettings.route) { + ThemeSettingsScreen( + onBackPress = { navController.popBackStack() } + ) + } + composable(Screen.AddonManager.route) { AddonManagerScreen() } diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt index 56b91299..31d231dd 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt @@ -83,6 +83,7 @@ sealed class Screen(val route: String) { data object Library : Screen("library") data object Settings : Screen("settings") data object TmdbSettings : Screen("tmdb_settings") + data object ThemeSettings : Screen("theme_settings") data object AddonManager : Screen("addon_manager") data object Plugins : Screen("plugins") } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt index f69b2ed5..c28e2143 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt @@ -83,7 +83,7 @@ fun SeasonTabs( ), colors = CardDefaults.colors( containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.Primary + focusedContainerColor = NuvioColors.Secondary ), border = CardDefaults.border( focusedBorder = Border( @@ -160,7 +160,7 @@ private fun EpisodeCard( ), colors = CardDefaults.colors( containerColor = NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.BackgroundCard + focusedContainerColor = NuvioColors.FocusBackground ), border = CardDefaults.border( focusedBorder = Border( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt index e37d0519..05378bd9 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt @@ -1,5 +1,6 @@ package com.nuvio.tv.ui.screens.detail +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -23,6 +24,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -164,13 +166,19 @@ private fun PlayButton( .onFocusChanged { isFocused = it.isFocused }, colors = ButtonDefaults.colors( containerColor = androidx.compose.ui.graphics.Color.White, - focusedContainerColor = androidx.compose.ui.graphics.Color(0xFFD0D0D0), + focusedContainerColor = androidx.compose.ui.graphics.Color.White, contentColor = androidx.compose.ui.graphics.Color.Black, focusedContentColor = androidx.compose.ui.graphics.Color.Black ), shape = ButtonDefaults.shape( shape = RoundedCornerShape(32.dp) ), + border = ButtonDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(32.dp) + ) + ), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp) ) { Row( @@ -206,10 +214,16 @@ private fun ActionIconButton( .onFocusChanged { isFocused = it.isFocused }, colors = IconButtonDefaults.colors( containerColor = NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.Primary, + focusedContainerColor = NuvioColors.Secondary, contentColor = NuvioColors.TextPrimary, focusedContentColor = NuvioColors.OnPrimary ), + border = IconButtonDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = CircleShape + ) + ), shape = IconButtonDefaults.shape( shape = CircleShape ) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt index d9e63f90..4235b8a5 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt @@ -122,7 +122,7 @@ private fun LibraryTabs( shape = CardDefaults.shape(shape = RoundedCornerShape(20.dp)), colors = CardDefaults.colors( containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.Primary + focusedContainerColor = NuvioColors.Secondary ), border = CardDefaults.border( focusedBorder = Border( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt index ece113ad..5f455ce2 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Border import androidx.tv.material3.Button import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ClickableSurfaceDefaults @@ -265,8 +267,16 @@ private fun PluginHeader( Button( onClick = onAddRepository, colors = ButtonDefaults.colors( - containerColor = NuvioColors.Primary, - contentColor = Color.White + containerColor = NuvioColors.Secondary, + focusedContainerColor = NuvioColors.SecondaryVariant, + contentColor = Color.White, + focusedContentColor = Color.White + ), + border = ButtonDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(50) + ) ) ) { Icon( @@ -296,9 +306,15 @@ private fun RepositoryCard( .fillMaxWidth() .onFocusChanged { isFocused = it.isFocused }, colors = ClickableSurfaceDefaults.colors( - containerColor = if (isFocused) NuvioColors.FocusBackground else NuvioColors.BackgroundCard, + containerColor = NuvioColors.BackgroundCard, focusedContainerColor = NuvioColors.FocusBackground ), + border = ClickableSurfaceDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(12.dp) + ) + ), shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)) ) { Row( diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt index 1d761f6a..2e141991 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/SettingsScreen.kt @@ -2,6 +2,7 @@ package com.nuvio.tv.ui.screens.settings +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable @@ -32,6 +34,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -43,7 +46,8 @@ import com.nuvio.tv.ui.theme.NuvioColors @Composable fun SettingsScreen( onNavigateToPlugins: () -> Unit = {}, - onNavigateToTmdb: () -> Unit = {} + onNavigateToTmdb: () -> Unit = {}, + onNavigateToTheme: () -> Unit = {} ) { Column( modifier = Modifier @@ -73,6 +77,15 @@ fun SettingsScreen( contentPadding = PaddingValues(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + item { + SettingsItem( + icon = Icons.Default.Palette, + title = "Appearance", + subtitle = "Choose your color theme", + onClick = onNavigateToTheme + ) + } + item { SettingsItem( icon = Icons.Default.Build, @@ -90,7 +103,7 @@ fun SettingsScreen( onClick = onNavigateToTmdb ) } - + item { SettingsItem( icon = Icons.Default.Settings, @@ -127,7 +140,14 @@ private fun SettingsItem( .fillMaxWidth() .onFocusChanged { isFocused = it.isFocused }, colors = CardDefaults.colors( - containerColor = if (isFocused) NuvioColors.FocusBackground else NuvioColors.BackgroundCard + containerColor = NuvioColors.BackgroundCard, + focusedContainerColor = NuvioColors.FocusBackground + ), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(12.dp) + ) ), shape = CardDefaults.shape(RoundedCornerShape(12.dp)) ) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt new file mode 100644 index 00000000..5967e039 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsScreen.kt @@ -0,0 +1,172 @@ +@file:OptIn(ExperimentalTvMaterial3Api::class) + +package com.nuvio.tv.ui.screens.settings + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.tv.foundation.lazy.grid.TvGridCells +import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid +import androidx.tv.foundation.lazy.grid.items +import androidx.tv.material3.Border +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.nuvio.tv.domain.model.AppTheme +import com.nuvio.tv.ui.theme.NuvioColors +import com.nuvio.tv.ui.theme.ThemeColors + +@Composable +fun ThemeSettingsScreen( + viewModel: ThemeSettingsViewModel = hiltViewModel(), + onBackPress: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + BackHandler { onBackPress() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(NuvioColors.Background) + .padding(horizontal = 48.dp, vertical = 24.dp) + ) { + Text( + text = "Appearance", + style = MaterialTheme.typography.headlineLarge, + color = NuvioColors.TextPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Choose your color theme", + style = MaterialTheme.typography.bodyMedium, + color = NuvioColors.TextSecondary + ) + + Spacer(modifier = Modifier.height(32.dp)) + + TvLazyVerticalGrid( + columns = TvGridCells.Fixed(3), + contentPadding = PaddingValues(bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + items(uiState.availableThemes) { theme -> + ThemeCard( + theme = theme, + isSelected = theme == uiState.selectedTheme, + onClick = { viewModel.onEvent(ThemeSettingsEvent.SelectTheme(theme)) } + ) + } + } + } +} + +@Composable +private fun ThemeCard( + theme: AppTheme, + isSelected: Boolean, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val palette = ThemeColors.getColorPalette(theme) + + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { isFocused = it.isFocused }, + colors = CardDefaults.colors( + containerColor = NuvioColors.BackgroundCard, + focusedContainerColor = palette.focusBackground + ), + border = CardDefaults.border( + border = if (isSelected) Border( + border = BorderStroke(2.dp, palette.focusRing), + shape = RoundedCornerShape(16.dp) + ) else Border.None, + focusedBorder = Border( + border = BorderStroke(2.dp, palette.focusRing), + shape = RoundedCornerShape(16.dp) + ) + ), + shape = CardDefaults.shape(RoundedCornerShape(16.dp)), + scale = CardDefaults.scale(focusedScale = 1.05f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Color preview circle + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(palette.secondary), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = theme.displayName, + style = MaterialTheme.typography.titleMedium, + color = if (isFocused || isSelected) NuvioColors.TextPrimary else NuvioColors.TextSecondary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Color bar showing the theme colors + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(palette.focusRing) + ) + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsViewModel.kt new file mode 100644 index 00000000..f6963933 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/ThemeSettingsViewModel.kt @@ -0,0 +1,52 @@ +package com.nuvio.tv.ui.screens.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nuvio.tv.data.local.ThemeDataStore +import com.nuvio.tv.domain.model.AppTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ThemeSettingsUiState( + val selectedTheme: AppTheme = AppTheme.CRIMSON, + val availableThemes: List = AppTheme.entries +) + +sealed class ThemeSettingsEvent { + data class SelectTheme(val theme: AppTheme) : ThemeSettingsEvent() +} + +@HiltViewModel +class ThemeSettingsViewModel @Inject constructor( + private val themeDataStore: ThemeDataStore +) : ViewModel() { + + private val _uiState = MutableStateFlow(ThemeSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + themeDataStore.selectedTheme.collectLatest { theme -> + _uiState.update { it.copy(selectedTheme = theme) } + } + } + } + + fun onEvent(event: ThemeSettingsEvent) { + when (event) { + is ThemeSettingsEvent.SelectTheme -> selectTheme(event.theme) + } + } + + private fun selectTheme(theme: AppTheme) { + viewModelScope.launch { + themeDataStore.setTheme(theme) + } + } +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/TmdbSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TmdbSettingsScreen.kt index 2ee47fac..8a9a4b29 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/TmdbSettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/TmdbSettingsScreen.kt @@ -3,6 +3,7 @@ package com.nuvio.tv.ui.screens.settings import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -28,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -171,7 +173,14 @@ private fun ToggleCard( .fillMaxWidth() .onFocusChanged { isFocused = it.isFocused }, colors = CardDefaults.colors( - containerColor = if (isFocused) NuvioColors.FocusBackground else NuvioColors.BackgroundCard + containerColor = NuvioColors.BackgroundCard, + focusedContainerColor = NuvioColors.FocusBackground + ), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(12.dp) + ) ), shape = CardDefaults.shape(RoundedCornerShape(12.dp)) ) { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt index 8b343fa2..c7b81d3e 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/stream/StreamScreen.kt @@ -497,7 +497,13 @@ private fun ErrorState( modifier = Modifier.onFocusChanged { isFocused = it.isFocused }, colors = CardDefaults.colors( containerColor = NuvioColors.BackgroundCard, - focusedContainerColor = NuvioColors.Primary + focusedContainerColor = NuvioColors.Secondary + ), + border = CardDefaults.border( + focusedBorder = Border( + border = BorderStroke(2.dp, NuvioColors.FocusRing), + shape = RoundedCornerShape(8.dp) + ) ), shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp)) ) { @@ -583,7 +589,7 @@ private fun StreamCard( ) ), shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), - scale = CardDefaults.scale(focusedScale = 1.02f) + scale = CardDefaults.scale(focusedScale = 1.05f) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/nuvio/tv/ui/theme/Color.kt b/app/src/main/java/com/nuvio/tv/ui/theme/Color.kt index dd857309..11fadd8a 100644 --- a/app/src/main/java/com/nuvio/tv/ui/theme/Color.kt +++ b/app/src/main/java/com/nuvio/tv/ui/theme/Color.kt @@ -1,42 +1,122 @@ package com.nuvio.tv.ui.theme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color +import com.nuvio.tv.domain.model.AppTheme -object NuvioColors { - // Primary Background - Deep charcoal (NO gradients) - val Background = Color(0xFF0D0D0D) - val BackgroundElevated = Color(0xFF1A1A1A) - val BackgroundCard = Color(0xFF242424) +/** + * Dynamic color scheme that changes based on selected theme. + * Background colors have subtle theme tinting. + * Accent colors (secondary, focus) change per theme. + */ +class NuvioColorScheme(palette: ThemeColorPalette) { + // Primary Background - Theme dependent with subtle tinting + val Background = palette.background + val BackgroundElevated = palette.backgroundElevated + val BackgroundCard = palette.backgroundCard - // Surface colors + // Surface colors (constant) val Surface = Color(0xFF1E1E1E) val SurfaceVariant = Color(0xFF2D2D2D) - // Primary accent - Neutral Grey + // Primary accent - Neutral Grey (constant) val Primary = Color(0xFF9E9E9E) val PrimaryVariant = Color(0xFF6F6F6F) val OnPrimary = Color(0xFFFFFFFF) - // Secondary accent - Teal - val Secondary = Color(0xFF00BFA6) - val SecondaryVariant = Color(0xFF008F7A) + // Secondary accent - Theme dependent + val Secondary = palette.secondary + val SecondaryVariant = palette.secondaryVariant - // Text colors + // Text colors (constant) val TextPrimary = Color(0xFFFFFFFF) val TextSecondary = Color(0xFFB3B3B3) val TextTertiary = Color(0xFF808080) val TextDisabled = Color(0xFF4D4D4D) - // Focus states - Critical for TV navigation - val FocusRing = Color(0xFF00E5CC) // Bright teal for high visibility - val FocusBackground = Color(0xFF1A3D38) // Dark teal background + // Focus states - Theme dependent + val FocusRing = palette.focusRing + val FocusBackground = palette.focusBackground - // Status colors + // Status colors (constant) val Rating = Color(0xFFFFD700) val Error = Color(0xFFCF6679) val Success = Color(0xFF4CAF50) // Borders val Border = Color(0xFF333333) - val BorderFocused = Color(0xFF00E5CC) // Bright teal for high visibility + val BorderFocused = palette.focusRing +} + +/** + * Legacy NuvioColors object for backwards compatibility. + * Components should migrate to using NuvioTheme.colors instead. + * This object provides the current theme's colors via composition local. + */ +object NuvioColors { + // Dynamic background colors - Theme dependent with subtle tinting + val Background: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.Background + + val BackgroundElevated: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.BackgroundElevated + + val BackgroundCard: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.BackgroundCard + + // Surface colors (constant) + val Surface = Color(0xFF1E1E1E) + val SurfaceVariant = Color(0xFF2D2D2D) + + // Primary accent - Neutral Grey (constant) + val Primary = Color(0xFF9E9E9E) + val PrimaryVariant = Color(0xFF6F6F6F) + val OnPrimary = Color(0xFFFFFFFF) + + // Text colors (constant) + val TextPrimary = Color(0xFFFFFFFF) + val TextSecondary = Color(0xFFB3B3B3) + val TextTertiary = Color(0xFF808080) + val TextDisabled = Color(0xFF4D4D4D) + + // Status colors (constant) + val Rating = Color(0xFFFFD700) + val Error = Color(0xFFCF6679) + val Success = Color(0xFF4CAF50) + + // Borders (non-focus constant) + val Border = Color(0xFF333333) + + // Dynamic accent colors - Theme dependent + val Secondary: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.Secondary + + val SecondaryVariant: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.SecondaryVariant + + val FocusRing: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.FocusRing + + val FocusBackground: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.FocusBackground + + val BorderFocused: Color + @Composable + @ReadOnlyComposable + get() = NuvioTheme.colors.BorderFocused } diff --git a/app/src/main/java/com/nuvio/tv/ui/theme/Theme.kt b/app/src/main/java/com/nuvio/tv/ui/theme/Theme.kt index 2741f44d..91960026 100644 --- a/app/src/main/java/com/nuvio/tv/ui/theme/Theme.kt +++ b/app/src/main/java/com/nuvio/tv/ui/theme/Theme.kt @@ -2,11 +2,13 @@ package com.nuvio.tv.ui.theme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme +import com.nuvio.tv.domain.model.AppTheme data class NuvioExtendedColors( val backgroundElevated: Color, @@ -19,48 +21,62 @@ data class NuvioExtendedColors( ) val LocalNuvioColors = staticCompositionLocalOf { + NuvioColorScheme(ThemeColors.Crimson) +} + +val LocalNuvioExtendedColors = staticCompositionLocalOf { NuvioExtendedColors( - backgroundElevated = NuvioColors.BackgroundElevated, - backgroundCard = NuvioColors.BackgroundCard, - textSecondary = NuvioColors.TextSecondary, - textTertiary = NuvioColors.TextTertiary, - focusRing = NuvioColors.FocusRing, - focusBackground = NuvioColors.FocusBackground, - rating = NuvioColors.Rating + backgroundElevated = Color(0xFF1A1A1A), + backgroundCard = Color(0xFF242424), + textSecondary = Color(0xFFB3B3B3), + textTertiary = Color(0xFF808080), + focusRing = ThemeColors.Crimson.focusRing, + focusBackground = ThemeColors.Crimson.focusBackground, + rating = Color(0xFFFFD700) ) } +val LocalAppTheme = staticCompositionLocalOf { AppTheme.CRIMSON } + @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun NuvioTheme( + appTheme: AppTheme = AppTheme.CRIMSON, content: @Composable () -> Unit ) { - val colorScheme = darkColorScheme( - primary = NuvioColors.Primary, - onPrimary = NuvioColors.OnPrimary, - secondary = NuvioColors.Secondary, - background = NuvioColors.Background, - surface = NuvioColors.Surface, - surfaceVariant = NuvioColors.SurfaceVariant, - onBackground = NuvioColors.TextPrimary, - onSurface = NuvioColors.TextPrimary, - onSurfaceVariant = NuvioColors.TextSecondary, - error = NuvioColors.Error + val palette = ThemeColors.getColorPalette(appTheme) + val colorScheme = NuvioColorScheme(palette) + + val materialColorScheme = darkColorScheme( + primary = colorScheme.Primary, + onPrimary = colorScheme.OnPrimary, + secondary = colorScheme.Secondary, + background = colorScheme.Background, + surface = colorScheme.Surface, + surfaceVariant = colorScheme.SurfaceVariant, + onBackground = colorScheme.TextPrimary, + onSurface = colorScheme.TextPrimary, + onSurfaceVariant = colorScheme.TextSecondary, + error = colorScheme.Error ) val extendedColors = NuvioExtendedColors( - backgroundElevated = NuvioColors.BackgroundElevated, - backgroundCard = NuvioColors.BackgroundCard, - textSecondary = NuvioColors.TextSecondary, - textTertiary = NuvioColors.TextTertiary, - focusRing = NuvioColors.FocusRing, - focusBackground = NuvioColors.FocusBackground, - rating = NuvioColors.Rating + backgroundElevated = colorScheme.BackgroundElevated, + backgroundCard = colorScheme.BackgroundCard, + textSecondary = colorScheme.TextSecondary, + textTertiary = colorScheme.TextTertiary, + focusRing = colorScheme.FocusRing, + focusBackground = colorScheme.FocusBackground, + rating = colorScheme.Rating ) - CompositionLocalProvider(LocalNuvioColors provides extendedColors) { + CompositionLocalProvider( + LocalNuvioColors provides colorScheme, + LocalNuvioExtendedColors provides extendedColors, + LocalAppTheme provides appTheme + ) { MaterialTheme( - colorScheme = colorScheme, + colorScheme = materialColorScheme, typography = NuvioTypography, content = content ) @@ -68,7 +84,18 @@ fun NuvioTheme( } object NuvioTheme { + val colors: NuvioColorScheme + @Composable + @ReadOnlyComposable + get() = LocalNuvioColors.current + val extendedColors: NuvioExtendedColors @Composable - get() = LocalNuvioColors.current + @ReadOnlyComposable + get() = LocalNuvioExtendedColors.current + + val currentTheme: AppTheme + @Composable + @ReadOnlyComposable + get() = LocalAppTheme.current } diff --git a/app/src/main/java/com/nuvio/tv/ui/theme/ThemeColors.kt b/app/src/main/java/com/nuvio/tv/ui/theme/ThemeColors.kt new file mode 100644 index 00000000..568f65c1 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/theme/ThemeColors.kt @@ -0,0 +1,93 @@ +package com.nuvio.tv.ui.theme + +import androidx.compose.ui.graphics.Color +import com.nuvio.tv.domain.model.AppTheme + +/** + * Color palette for each theme. + * Includes both accent colors and background tints for full theme customization. + */ +data class ThemeColorPalette( + val secondary: Color, + val secondaryVariant: Color, + val focusRing: Color, + val focusBackground: Color, + // Background colors with subtle theme tinting + 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) // Warm red tint + ) + + val Ocean = ThemeColorPalette( + secondary = Color(0xFF1E88E5), + secondaryVariant = Color(0xFF1565C0), + focusRing = Color(0xFF42A5F5), + focusBackground = Color(0xFF1A2D3D), + background = Color(0xFF0D0D0F), // Cool blue tint + backgroundElevated = Color(0xFF1A1A1E), + backgroundCard = Color(0xFF1A1F24) + ) + + val Violet = ThemeColorPalette( + secondary = Color(0xFF8E24AA), + secondaryVariant = Color(0xFF6A1B9A), + focusRing = Color(0xFFAB47BC), + focusBackground = Color(0xFF2D1A3D), + background = Color(0xFF0D0D0F), // Purple tint + 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) // Green tint + ) + + val Amber = ThemeColorPalette( + secondary = Color(0xFFFB8C00), + secondaryVariant = Color(0xFFEF6C00), + focusRing = Color(0xFFFFA726), + focusBackground = Color(0xFF3D2D1A), + background = Color(0xFF0F0D0D), // Warm amber tint + 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) // Pink tint + ) + + fun getColorPalette(theme: AppTheme): ThemeColorPalette { + return when (theme) { + AppTheme.CRIMSON -> Crimson + AppTheme.OCEAN -> Ocean + AppTheme.VIOLET -> Violet + AppTheme.EMERALD -> Emerald + AppTheme.AMBER -> Amber + AppTheme.ROSE -> Rose + } + } +}