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:
2026-05-26 00:43:47 -04:00
parent 2851f8c694
commit 45e2248970
2 changed files with 81 additions and 0 deletions
@@ -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)
}
}