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.
This commit is contained in:
CrissZollo 2026-02-02 20:50:43 +01:00
parent e505570770
commit c82d94c71e
21 changed files with 624 additions and 75 deletions

View file

@ -8,9 +8,7 @@ plugins {
android { android {
namespace = "com.nuvio.tv" namespace = "com.nuvio.tv"
compileSdk { compileSdk = 36
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "com.nuvio.tv" applicationId = "com.nuvio.tv"

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp 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.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import coil.compose.AsyncImage 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.NuvioNavHost
import com.nuvio.tv.ui.navigation.Screen import com.nuvio.tv.ui.navigation.Screen
import com.nuvio.tv.ui.theme.NuvioTheme import com.nuvio.tv.ui.theme.NuvioTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var themeDataStore: ThemeDataStore
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
NuvioTheme { val currentTheme by themeDataStore.selectedTheme.collectAsState(initial = AppTheme.CRIMSON)
NuvioTheme(appTheme = currentTheme) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = RectangleShape shape = RectangleShape

View file

@ -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<Preferences> 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<AppTheme> = 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
}
}
}

View file

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

View file

@ -28,7 +28,6 @@ import androidx.tv.material3.Border
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Glow
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -79,18 +78,12 @@ fun ContentCard(
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = Border(
border = BorderStroke(3.dp, NuvioColors.FocusRing), border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) )
), ),
scale = CardDefaults.scale( scale = CardDefaults.scale(
focusedScale = 1.08f focusedScale = 1.05f
),
glow = CardDefaults.glow(
focusedGlow = Glow(
elevation = 8.dp,
elevationColor = NuvioColors.FocusRing.copy(alpha = 0.3f)
)
) )
) { ) {
Box( Box(

View file

@ -101,7 +101,7 @@ private fun ContinueWatchingCard(
), ),
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard focusedContainerColor = NuvioColors.FocusBackground
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = Border(

View file

@ -111,7 +111,7 @@ private fun SidebarNavItem(
label = "navItemBackground" label = "navItemBackground"
) )
val borderColor by animateColorAsState( val borderColor by animateColorAsState(
targetValue = if (isFocused) NuvioColors.BorderFocused else Color.Transparent, targetValue = if (isFocused) NuvioColors.FocusRing else Color.Transparent,
label = "navItemBorder" label = "navItemBorder"
) )
@ -121,7 +121,7 @@ private fun SidebarNavItem(
.height(56.dp) .height(56.dp)
.clip(shape) .clip(shape)
.background(backgroundColor) .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) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { state -> .onFocusChanged { state ->
isFocused = state.isFocused isFocused = state.isFocused

View file

@ -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.plugin.PluginScreen
import com.nuvio.tv.ui.screens.search.SearchScreen import com.nuvio.tv.ui.screens.search.SearchScreen
import com.nuvio.tv.ui.screens.settings.SettingsScreen 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.settings.TmdbSettingsScreen
import com.nuvio.tv.ui.screens.stream.StreamScreen import com.nuvio.tv.ui.screens.stream.StreamScreen
@ -240,7 +241,8 @@ fun NuvioNavHost(
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen( SettingsScreen(
onNavigateToPlugins = { navController.navigate(Screen.Plugins.route) }, 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) { composable(Screen.AddonManager.route) {
AddonManagerScreen() AddonManagerScreen()
} }

View file

@ -83,6 +83,7 @@ sealed class Screen(val route: String) {
data object Library : Screen("library") data object Library : Screen("library")
data object Settings : Screen("settings") data object Settings : Screen("settings")
data object TmdbSettings : Screen("tmdb_settings") data object TmdbSettings : Screen("tmdb_settings")
data object ThemeSettings : Screen("theme_settings")
data object AddonManager : Screen("addon_manager") data object AddonManager : Screen("addon_manager")
data object Plugins : Screen("plugins") data object Plugins : Screen("plugins")
} }

View file

@ -83,7 +83,7 @@ fun SeasonTabs(
), ),
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard, containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.Primary focusedContainerColor = NuvioColors.Secondary
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = Border(
@ -160,7 +160,7 @@ private fun EpisodeCard(
), ),
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard focusedContainerColor = NuvioColors.FocusBackground
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = Border(

View file

@ -1,5 +1,6 @@
package com.nuvio.tv.ui.screens.detail package com.nuvio.tv.ui.screens.detail
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Border
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -164,13 +166,19 @@ private fun PlayButton(
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
colors = ButtonDefaults.colors( colors = ButtonDefaults.colors(
containerColor = androidx.compose.ui.graphics.Color.White, 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, contentColor = androidx.compose.ui.graphics.Color.Black,
focusedContentColor = androidx.compose.ui.graphics.Color.Black focusedContentColor = androidx.compose.ui.graphics.Color.Black
), ),
shape = ButtonDefaults.shape( shape = ButtonDefaults.shape(
shape = RoundedCornerShape(32.dp) 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) contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
) { ) {
Row( Row(
@ -206,10 +214,16 @@ private fun ActionIconButton(
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
colors = IconButtonDefaults.colors( colors = IconButtonDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.Primary, focusedContainerColor = NuvioColors.Secondary,
contentColor = NuvioColors.TextPrimary, contentColor = NuvioColors.TextPrimary,
focusedContentColor = NuvioColors.OnPrimary focusedContentColor = NuvioColors.OnPrimary
), ),
border = IconButtonDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = CircleShape
)
),
shape = IconButtonDefaults.shape( shape = IconButtonDefaults.shape(
shape = CircleShape shape = CircleShape
) )

View file

@ -122,7 +122,7 @@ private fun LibraryTabs(
shape = CardDefaults.shape(shape = RoundedCornerShape(20.dp)), shape = CardDefaults.shape(shape = RoundedCornerShape(20.dp)),
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard, containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.Primary focusedContainerColor = NuvioColors.Secondary
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = Border(

View file

@ -6,6 +6,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -54,6 +55,7 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.Border
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults import androidx.tv.material3.ButtonDefaults
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
@ -265,8 +267,16 @@ private fun PluginHeader(
Button( Button(
onClick = onAddRepository, onClick = onAddRepository,
colors = ButtonDefaults.colors( colors = ButtonDefaults.colors(
containerColor = NuvioColors.Primary, containerColor = NuvioColors.Secondary,
contentColor = Color.White focusedContainerColor = NuvioColors.SecondaryVariant,
contentColor = Color.White,
focusedContentColor = Color.White
),
border = ButtonDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = RoundedCornerShape(50)
)
) )
) { ) {
Icon( Icon(
@ -296,9 +306,15 @@ private fun RepositoryCard(
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isFocused) NuvioColors.FocusBackground else NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.FocusBackground focusedContainerColor = NuvioColors.FocusBackground
), ),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = RoundedCornerShape(12.dp)
)
),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)) shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp))
) { ) {
Row( Row(

View file

@ -2,6 +2,7 @@
package com.nuvio.tv.ui.screens.settings package com.nuvio.tv.ui.screens.settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.ArrowForward
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Info 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.Settings
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.runtime.Composable 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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.material3.Border
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -43,7 +46,8 @@ import com.nuvio.tv.ui.theme.NuvioColors
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onNavigateToPlugins: () -> Unit = {}, onNavigateToPlugins: () -> Unit = {},
onNavigateToTmdb: () -> Unit = {} onNavigateToTmdb: () -> Unit = {},
onNavigateToTheme: () -> Unit = {}
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@ -73,6 +77,15 @@ fun SettingsScreen(
contentPadding = PaddingValues(bottom = 32.dp), contentPadding = PaddingValues(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
item {
SettingsItem(
icon = Icons.Default.Palette,
title = "Appearance",
subtitle = "Choose your color theme",
onClick = onNavigateToTheme
)
}
item { item {
SettingsItem( SettingsItem(
icon = Icons.Default.Build, icon = Icons.Default.Build,
@ -127,7 +140,14 @@ private fun SettingsItem(
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
colors = CardDefaults.colors( 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)) shape = CardDefaults.shape(RoundedCornerShape(12.dp))
) { ) {

View file

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

View file

@ -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> = 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<ThemeSettingsUiState> = _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)
}
}
}

View file

@ -3,6 +3,7 @@
package com.nuvio.tv.ui.screens.settings package com.nuvio.tv.ui.screens.settings
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,6 +29,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.material3.Border
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
@ -171,7 +173,14 @@ private fun ToggleCard(
.fillMaxWidth() .fillMaxWidth()
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
colors = CardDefaults.colors( 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)) shape = CardDefaults.shape(RoundedCornerShape(12.dp))
) { ) {

View file

@ -497,7 +497,13 @@ private fun ErrorState(
modifier = Modifier.onFocusChanged { isFocused = it.isFocused }, modifier = Modifier.onFocusChanged { isFocused = it.isFocused },
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, 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)) shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp))
) { ) {
@ -583,7 +589,7 @@ private fun StreamCard(
) )
), ),
shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)),
scale = CardDefaults.scale(focusedScale = 1.02f) scale = CardDefaults.scale(focusedScale = 1.05f)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View file

@ -1,42 +1,122 @@
package com.nuvio.tv.ui.theme package com.nuvio.tv.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.nuvio.tv.domain.model.AppTheme
object NuvioColors { /**
// Primary Background - Deep charcoal (NO gradients) * Dynamic color scheme that changes based on selected theme.
val Background = Color(0xFF0D0D0D) * Background colors have subtle theme tinting.
val BackgroundElevated = Color(0xFF1A1A1A) * Accent colors (secondary, focus) change per theme.
val BackgroundCard = Color(0xFF242424) */
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 Surface = Color(0xFF1E1E1E)
val SurfaceVariant = Color(0xFF2D2D2D) val SurfaceVariant = Color(0xFF2D2D2D)
// Primary accent - Neutral Grey // Primary accent - Neutral Grey (constant)
val Primary = Color(0xFF9E9E9E) val Primary = Color(0xFF9E9E9E)
val PrimaryVariant = Color(0xFF6F6F6F) val PrimaryVariant = Color(0xFF6F6F6F)
val OnPrimary = Color(0xFFFFFFFF) val OnPrimary = Color(0xFFFFFFFF)
// Secondary accent - Teal // Secondary accent - Theme dependent
val Secondary = Color(0xFF00BFA6) val Secondary = palette.secondary
val SecondaryVariant = Color(0xFF008F7A) val SecondaryVariant = palette.secondaryVariant
// Text colors // Text colors (constant)
val TextPrimary = Color(0xFFFFFFFF) val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB3B3B3) val TextSecondary = Color(0xFFB3B3B3)
val TextTertiary = Color(0xFF808080) val TextTertiary = Color(0xFF808080)
val TextDisabled = Color(0xFF4D4D4D) val TextDisabled = Color(0xFF4D4D4D)
// Focus states - Critical for TV navigation // Focus states - Theme dependent
val FocusRing = Color(0xFF00E5CC) // Bright teal for high visibility val FocusRing = palette.focusRing
val FocusBackground = Color(0xFF1A3D38) // Dark teal background val FocusBackground = palette.focusBackground
// Status colors // Status colors (constant)
val Rating = Color(0xFFFFD700) val Rating = Color(0xFFFFD700)
val Error = Color(0xFFCF6679) val Error = Color(0xFFCF6679)
val Success = Color(0xFF4CAF50) val Success = Color(0xFF4CAF50)
// Borders // Borders
val Border = Color(0xFF333333) 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
} }

View file

@ -2,11 +2,13 @@ package com.nuvio.tv.ui.theme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme import androidx.tv.material3.darkColorScheme
import com.nuvio.tv.domain.model.AppTheme
data class NuvioExtendedColors( data class NuvioExtendedColors(
val backgroundElevated: Color, val backgroundElevated: Color,
@ -19,48 +21,62 @@ data class NuvioExtendedColors(
) )
val LocalNuvioColors = staticCompositionLocalOf { val LocalNuvioColors = staticCompositionLocalOf {
NuvioColorScheme(ThemeColors.Crimson)
}
val LocalNuvioExtendedColors = staticCompositionLocalOf {
NuvioExtendedColors( NuvioExtendedColors(
backgroundElevated = NuvioColors.BackgroundElevated, backgroundElevated = Color(0xFF1A1A1A),
backgroundCard = NuvioColors.BackgroundCard, backgroundCard = Color(0xFF242424),
textSecondary = NuvioColors.TextSecondary, textSecondary = Color(0xFFB3B3B3),
textTertiary = NuvioColors.TextTertiary, textTertiary = Color(0xFF808080),
focusRing = NuvioColors.FocusRing, focusRing = ThemeColors.Crimson.focusRing,
focusBackground = NuvioColors.FocusBackground, focusBackground = ThemeColors.Crimson.focusBackground,
rating = NuvioColors.Rating rating = Color(0xFFFFD700)
) )
} }
val LocalAppTheme = staticCompositionLocalOf { AppTheme.CRIMSON }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun NuvioTheme( fun NuvioTheme(
appTheme: AppTheme = AppTheme.CRIMSON,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = darkColorScheme( val palette = ThemeColors.getColorPalette(appTheme)
primary = NuvioColors.Primary, val colorScheme = NuvioColorScheme(palette)
onPrimary = NuvioColors.OnPrimary,
secondary = NuvioColors.Secondary, val materialColorScheme = darkColorScheme(
background = NuvioColors.Background, primary = colorScheme.Primary,
surface = NuvioColors.Surface, onPrimary = colorScheme.OnPrimary,
surfaceVariant = NuvioColors.SurfaceVariant, secondary = colorScheme.Secondary,
onBackground = NuvioColors.TextPrimary, background = colorScheme.Background,
onSurface = NuvioColors.TextPrimary, surface = colorScheme.Surface,
onSurfaceVariant = NuvioColors.TextSecondary, surfaceVariant = colorScheme.SurfaceVariant,
error = NuvioColors.Error onBackground = colorScheme.TextPrimary,
onSurface = colorScheme.TextPrimary,
onSurfaceVariant = colorScheme.TextSecondary,
error = colorScheme.Error
) )
val extendedColors = NuvioExtendedColors( val extendedColors = NuvioExtendedColors(
backgroundElevated = NuvioColors.BackgroundElevated, backgroundElevated = colorScheme.BackgroundElevated,
backgroundCard = NuvioColors.BackgroundCard, backgroundCard = colorScheme.BackgroundCard,
textSecondary = NuvioColors.TextSecondary, textSecondary = colorScheme.TextSecondary,
textTertiary = NuvioColors.TextTertiary, textTertiary = colorScheme.TextTertiary,
focusRing = NuvioColors.FocusRing, focusRing = colorScheme.FocusRing,
focusBackground = NuvioColors.FocusBackground, focusBackground = colorScheme.FocusBackground,
rating = NuvioColors.Rating rating = colorScheme.Rating
) )
CompositionLocalProvider(LocalNuvioColors provides extendedColors) { CompositionLocalProvider(
LocalNuvioColors provides colorScheme,
LocalNuvioExtendedColors provides extendedColors,
LocalAppTheme provides appTheme
) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = materialColorScheme,
typography = NuvioTypography, typography = NuvioTypography,
content = content content = content
) )
@ -68,7 +84,18 @@ fun NuvioTheme(
} }
object NuvioTheme { object NuvioTheme {
val colors: NuvioColorScheme
@Composable
@ReadOnlyComposable
get() = LocalNuvioColors.current
val extendedColors: NuvioExtendedColors val extendedColors: NuvioExtendedColors
@Composable @Composable
get() = LocalNuvioColors.current @ReadOnlyComposable
get() = LocalNuvioExtendedColors.current
val currentTheme: AppTheme
@Composable
@ReadOnlyComposable
get() = LocalAppTheme.current
} }

View file

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