feat: implement Settings page with theme selector (Closes #21) (#70)

This commit was merged in pull request #70.
This commit is contained in:
2026-03-13 14:10:41 +01:00
parent a9cf04de0e
commit 37b223c96f
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.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(

View File

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

View File

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

View File

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

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