feat(android): Phase 20 — Settings theme picker (System / Light / Dark)
User-controllable theme override. Persists across cold restart;
default is SYSTEM (follows the device setting via isSystemInDarkTheme).
Schema bump: AppDatabase v2→v3 to add themeMode column on
auth_session. fallbackToDestructiveMigration is still in place so
the upgrade wipes local cache + cookie + user JSON on first launch
after update — destructive but acceptable pre-v1, since the sync
controller refills the cache from the server on next sign-in.
New:
- theme/ThemeMode.kt — SYSTEM / LIGHT / DARK enum with wire
(string) + toDarkOverride() (Boolean?) conversions.
Stored as the wire string; null persisted = SYSTEM.
- theme/ThemePreferenceViewModel.kt — surfaces AuthStore.themeMode
as a typed StateFlow + setter. Lives in the theme package so
MainActivity and SettingsScreen can both share it.
Modified:
- cache/db/entities/AuthSessionEntity.kt — adds themeMode column.
Comment updated to call out that the auth_session table is the
de-facto app-prefs row at this point, not strictly auth-only.
- cache/db/AppDatabase.kt — version 2 → 3.
- cache/db/dao/AuthSessionDao.kt — adds setThemeMode partial-update.
- auth/AuthStore.kt — adds themeMode StateFlow + setter +
persistThemeMode following the existing per-field pattern.
- MainActivity.kt — moves MinstrelTheme wrap from setContent into
the App() composable so it can read the theme preference.
BootSplash also wrapped in Surface(background) so the boot
flash uses the right background color.
- settings/ui/SettingsScreen.kt — Appearance ElevatedCard between
Account and About with a SingleChoiceSegmentedButtonRow of the
three options. Picks fire ThemePreferenceViewModel.setThemeMode
and the whole tree recomposes against the new MinstrelTheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.fabledsword.minstrel.theme
|
||||
|
||||
/**
|
||||
* User-selectable theme override. SYSTEM defers to
|
||||
* `isSystemInDarkTheme()`; LIGHT and DARK pin the app to that mode
|
||||
* regardless of the device setting.
|
||||
*
|
||||
* Stored as the wire string in `AuthSessionEntity.themeMode`. Null
|
||||
* persistence value means "never set" which we treat as SYSTEM.
|
||||
*/
|
||||
enum class ThemeMode {
|
||||
SYSTEM, LIGHT, DARK;
|
||||
|
||||
/** Stable string identifier used for persistence. */
|
||||
val wire: String get() = name.lowercase()
|
||||
|
||||
/**
|
||||
* Maps to MinstrelTheme's `darkOverride: Boolean?` parameter:
|
||||
* - SYSTEM → null (let isSystemInDarkTheme decide)
|
||||
* - LIGHT → false
|
||||
* - DARK → true
|
||||
*/
|
||||
fun toDarkOverride(): Boolean? = when (this) {
|
||||
SYSTEM -> null
|
||||
LIGHT -> false
|
||||
DARK -> true
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromWire(wire: String?): ThemeMode = when (wire) {
|
||||
"light" -> LIGHT
|
||||
"dark" -> DARK
|
||||
else -> SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.fabledsword.minstrel.theme
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Surfaces AuthStore.themeMode as a typed [ThemeMode] StateFlow and
|
||||
* lets callers write through. Used by:
|
||||
* - MainActivity's App() to pick the darkOverride to wrap the
|
||||
* NavGraph in.
|
||||
* - SettingsScreen's Appearance section as the 3-way picker.
|
||||
*
|
||||
* Lives in the theme package rather than a UI package because it's
|
||||
* tightly coupled to the theme tokens; SettingsScreen just consumes it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ThemePreferenceViewModel @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
val themeMode: StateFlow<ThemeMode> =
|
||||
authStore.themeMode
|
||||
.map { ThemeMode.fromWire(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ThemeMode.fromWire(authStore.themeMode.value),
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
// Persist null for SYSTEM so a "never set" row reads identically
|
||||
// to an explicit "follow system" pick — keeps the wire/storage
|
||||
// round-trip clean.
|
||||
authStore.setThemeMode(if (mode == ThemeMode.SYSTEM) null else mode.wire)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user