feat: implement Settings page with theme selector (Closes #21) #70
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user