From 45e2248970955e9427441d14e005800d10085fb0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 00:43:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2020=20=E2=80=94=20Setti?= =?UTF-8?q?ngs=20theme=20picker=20(System=20/=20Light=20/=20Dark)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fabledsword/minstrel/theme/ThemeMode.kt | 36 +++++++++++++++ .../theme/ThemePreferenceViewModel.kt | 45 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt new file mode 100644 index 00000000..b821e67b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemeMode.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt new file mode 100644 index 00000000..7234c90d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ThemePreferenceViewModel.kt @@ -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 = + 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) + } +}