From 37b223c96f77120bc794b7cf5dd0ec40ddb8a618 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Fri, 13 Mar 2026 14:10:41 +0100 Subject: [PATCH] feat: implement Settings page with theme selector (Closes #21) (#70) --- .../kotlin/org/shahondin1624/App.kt | 9 +- .../shahondin1624/lib/components/TestTags.kt | 6 + .../lib/components/settings/Settings.kt | 113 ++++++++++++++++-- .../kotlin/org/shahondin1624/theme/Theme.kt | 30 ++++- .../shahondin1624/theme/ThemePreference.kt | 36 ++++++ 5 files changed, 179 insertions(+), 15 deletions(-) create mode 100644 sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ThemePreference.kt diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 4852c53..53d4a8b 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -33,7 +33,9 @@ import org.shahondin1624.lib.components.settings.SettingsPage import org.shahondin1624.navigation.AppRoutes import org.shahondin1624.theme.AppTheme import org.shahondin1624.theme.LocalThemeIsDark +import org.shahondin1624.theme.LocalThemePreference import org.shahondin1624.theme.LocalWindowSizeClass +import org.shahondin1624.theme.ThemePreference import org.shahondin1624.theme.WindowSizeClass import org.shahondin1624.viewmodel.CharacterViewModel @@ -242,6 +244,7 @@ private fun MainScaffold( modifier: Modifier = Modifier ) { var isDark by LocalThemeIsDark.current + var themePreference by LocalThemePreference.current val currentRoute = currentRoute(navController) val character by characterViewModel.character.collectAsState() @@ -269,7 +272,11 @@ private fun MainScaffold( }, actions = { 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) ) { Icon( diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index 7ea7b44..b341fc0 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -70,6 +70,12 @@ object TestTags { const val DICE_ROLL_DISMISS_BUTTON = "dice_roll_dismiss_button" 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 --- const val TOP_APP_BAR = "top_app_bar" const val THEME_TOGGLE = "theme_toggle" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt index 9f18e25..f40964b 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/settings/Settings.kt @@ -1,32 +1,121 @@ package org.shahondin1624.lib.components.settings -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 org.shahondin1624.lib.components.TestTags +import org.shahondin1624.theme.LocalThemePreference +import org.shahondin1624.theme.ThemePreference /** - * Placeholder settings page. - * Will be expanded in story 5.3 (Theme Selection). + * Settings page with theme selection (Light / Dark / System Default). */ @Composable fun SettingsPage() { - Box( + var themePreference by LocalThemePreference.current + + Column( modifier = Modifier .fillMaxSize() .padding(16.dp) - .testTag("settings_page"), - contentAlignment = Alignment.Center + .testTag(TestTags.SETTINGS_PAGE), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Settings", - style = MaterialTheme.typography.headlineMedium + text = "Appearance", + 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 + ) + } } } diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/Theme.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/Theme.kt index cd3fa71..cb8bf41 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/Theme.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/Theme.kt @@ -85,15 +85,41 @@ private val DarkColorScheme = darkColorScheme( 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 internal fun AppTheme( onThemeChanged: @Composable (isDark: Boolean) -> Unit, content: @Composable () -> Unit ) { 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( - LocalThemeIsDark provides isDarkState + LocalThemeIsDark provides isDarkState, + LocalThemePreference provides preferenceState ) { val isDark by isDarkState onThemeChanged(!isDark) diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ThemePreference.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ThemePreference.kt new file mode 100644 index 0000000..6d15351 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/theme/ThemePreference.kt @@ -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) + } + } +}