feat: implement Settings page with Light/Dark/System theme selector (Closes #21)

Add ThemePreference enum (System/Light/Dark) persisted via
multiplatform-settings. SettingsPage now shows radio-button theme
chooser. Top-bar quick toggle persists its choice. AppTheme respects
saved preference on startup and reacts to preference changes via
LaunchedEffect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-13 14:10:24 +01:00
parent a9cf04de0e
commit 4acde8d194
5 changed files with 179 additions and 15 deletions

View File

@@ -33,7 +33,9 @@ import org.shahondin1624.lib.components.settings.SettingsPage
import org.shahondin1624.navigation.AppRoutes import org.shahondin1624.navigation.AppRoutes
import org.shahondin1624.theme.AppTheme import org.shahondin1624.theme.AppTheme
import org.shahondin1624.theme.LocalThemeIsDark import org.shahondin1624.theme.LocalThemeIsDark
import org.shahondin1624.theme.LocalThemePreference
import org.shahondin1624.theme.LocalWindowSizeClass import org.shahondin1624.theme.LocalWindowSizeClass
import org.shahondin1624.theme.ThemePreference
import org.shahondin1624.theme.WindowSizeClass import org.shahondin1624.theme.WindowSizeClass
import org.shahondin1624.viewmodel.CharacterViewModel import org.shahondin1624.viewmodel.CharacterViewModel
@@ -242,6 +244,7 @@ private fun MainScaffold(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isDark by LocalThemeIsDark.current var isDark by LocalThemeIsDark.current
var themePreference by LocalThemePreference.current
val currentRoute = currentRoute(navController) val currentRoute = currentRoute(navController)
val character by characterViewModel.character.collectAsState() val character by characterViewModel.character.collectAsState()
@@ -269,7 +272,11 @@ private fun MainScaffold(
}, },
actions = { actions = {
IconButton( IconButton(
onClick = { isDark = !isDark }, onClick = {
val newPref = if (isDark) ThemePreference.Light else ThemePreference.Dark
themePreference = newPref
ThemePreference.save(newPref)
},
modifier = Modifier.testTag(TestTags.THEME_TOGGLE) modifier = Modifier.testTag(TestTags.THEME_TOGGLE)
) { ) {
Icon( Icon(

View File

@@ -70,6 +70,12 @@ object TestTags {
const val DICE_ROLL_DISMISS_BUTTON = "dice_roll_dismiss_button" const val DICE_ROLL_DISMISS_BUTTON = "dice_roll_dismiss_button"
fun dieChip(index: Int): String = "die_chip_$index" fun dieChip(index: Int): String = "die_chip_$index"
// --- Settings page ---
const val SETTINGS_PAGE = "settings_page"
const val SETTINGS_THEME_SYSTEM = "settings_theme_system"
const val SETTINGS_THEME_LIGHT = "settings_theme_light"
const val SETTINGS_THEME_DARK = "settings_theme_dark"
// --- Top app bar --- // --- Top app bar ---
const val TOP_APP_BAR = "top_app_bar" const val TOP_APP_BAR = "top_app_bar"
const val THEME_TOGGLE = "theme_toggle" const val THEME_TOGGLE = "theme_toggle"

View File

@@ -1,32 +1,121 @@
package org.shahondin1624.lib.components.settings package org.shahondin1624.lib.components.settings
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.theme.LocalThemePreference
import org.shahondin1624.theme.ThemePreference
/** /**
* Placeholder settings page. * Settings page with theme selection (Light / Dark / System Default).
* Will be expanded in story 5.3 (Theme Selection).
*/ */
@Composable @Composable
fun SettingsPage() { fun SettingsPage() {
Box( var themePreference by LocalThemePreference.current
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(16.dp)
.testTag("settings_page"), .testTag(TestTags.SETTINGS_PAGE),
contentAlignment = Alignment.Center verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "Settings", text = "Appearance",
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.selectableGroup()
.padding(vertical = 8.dp)
) {
ThemeOption(
label = "System Default",
description = "Follow device theme setting",
selected = themePreference == ThemePreference.System,
testTag = TestTags.SETTINGS_THEME_SYSTEM,
onClick = {
themePreference = ThemePreference.System
ThemePreference.save(ThemePreference.System)
}
)
ThemeOption(
label = "Light",
description = "Always use light theme",
selected = themePreference == ThemePreference.Light,
testTag = TestTags.SETTINGS_THEME_LIGHT,
onClick = {
themePreference = ThemePreference.Light
ThemePreference.save(ThemePreference.Light)
}
)
ThemeOption(
label = "Dark",
description = "Always use dark theme",
selected = themePreference == ThemePreference.Dark,
testTag = TestTags.SETTINGS_THEME_DARK,
onClick = {
themePreference = ThemePreference.Dark
ThemePreference.save(ThemePreference.Dark)
}
) )
} }
} }
}
}
@Composable
private fun ThemeOption(
label: String,
description: String,
selected: Boolean,
testTag: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = selected,
onClick = onClick,
role = Role.RadioButton
)
.padding(horizontal = 16.dp, vertical = 12.dp)
.testTag(testTag),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = null // handled by Row.selectable
)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -85,15 +85,41 @@ private val DarkColorScheme = darkColorScheme(
internal val LocalThemeIsDark = compositionLocalOf { mutableStateOf(true) } internal val LocalThemeIsDark = compositionLocalOf { mutableStateOf(true) }
/**
* CompositionLocal providing the current [ThemePreference] as mutable state.
* Updated from the Settings page; the top-bar toggle also writes to this.
*/
val LocalThemePreference = compositionLocalOf { mutableStateOf(ThemePreference.System) }
@Composable @Composable
internal fun AppTheme( internal fun AppTheme(
onThemeChanged: @Composable (isDark: Boolean) -> Unit, onThemeChanged: @Composable (isDark: Boolean) -> Unit,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val systemIsDark = isSystemInDarkTheme() val systemIsDark = isSystemInDarkTheme()
val isDarkState = remember(systemIsDark) { mutableStateOf(systemIsDark) } val savedPreference = remember { ThemePreference.load() }
val preferenceState = remember { mutableStateOf(savedPreference) }
val initialDark = when (savedPreference) {
ThemePreference.System -> systemIsDark
ThemePreference.Light -> false
ThemePreference.Dark -> true
}
val isDarkState = remember(savedPreference) { mutableStateOf(initialDark) }
// React to preference changes (e.g., from Settings page)
val preference by preferenceState
LaunchedEffect(preference, systemIsDark) {
isDarkState.value = when (preference) {
ThemePreference.System -> systemIsDark
ThemePreference.Light -> false
ThemePreference.Dark -> true
}
}
CompositionLocalProvider( CompositionLocalProvider(
LocalThemeIsDark provides isDarkState LocalThemeIsDark provides isDarkState,
LocalThemePreference provides preferenceState
) { ) {
val isDark by isDarkState val isDark by isDarkState
onThemeChanged(!isDark) onThemeChanged(!isDark)

View File

@@ -0,0 +1,36 @@
package org.shahondin1624.theme
import com.russhwolf.settings.Settings
/**
* Three-way theme selection: follow system, force light, or force dark.
*/
enum class ThemePreference {
System,
Light,
Dark;
companion object {
private const val STORAGE_KEY = "theme_preference"
private val settings = Settings()
/**
* Load the persisted theme preference, defaulting to [System].
*/
fun load(): ThemePreference {
return try {
val stored = settings.getStringOrNull(STORAGE_KEY)
if (stored != null) valueOf(stored) else System
} catch (_: Exception) {
System
}
}
/**
* Persist the given theme preference.
*/
fun save(preference: ThemePreference) {
settings.putString(STORAGE_KEY, preference.name)
}
}
}