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 {
namespace = "com.nuvio.tv"
compileSdk {
version = release(36)
}
compileSdk = 36
defaultConfig {
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.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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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