From b3a57ba9d0d854a2edd0f9c43db3db3f5b348424 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Fri, 21 Aug 2026 23:57:40 +0000 Subject: [PATCH 1/2] feat: persist app-wide appearance --- .../app/getarcane/android/MainActivity.kt | 24 ++++- .../app/getarcane/android/core/Prefs.kt | 102 +++++++++++++++++- .../settings/AppearanceSettingsScreen.kt | 54 ++++------ .../android/core/PrefsAppearanceTest.kt | 101 +++++++++++++++++ 4 files changed, 242 insertions(+), 39 deletions(-) create mode 100644 app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt diff --git a/app/src/main/kotlin/app/getarcane/android/MainActivity.kt b/app/src/main/kotlin/app/getarcane/android/MainActivity.kt index 4cb6de0..6881e26 100644 --- a/app/src/main/kotlin/app/getarcane/android/MainActivity.kt +++ b/app/src/main/kotlin/app/getarcane/android/MainActivity.kt @@ -5,16 +5,23 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.core.view.WindowCompat +import app.getarcane.android.core.AppearancePreferences import app.getarcane.android.core.ArcaneClientManager +import app.getarcane.android.core.AppThemeMode +import app.getarcane.android.core.LocalAppearancePreferences import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.LocalPinnedStore import app.getarcane.android.core.PinnedItemsStore @@ -35,15 +42,28 @@ class MainActivity : ComponentActivity() { val manager = remember { arcaneManager } val pinnedStore = remember { PinnedItemsStore(context) } val prefs = remember { Prefs(context) } - val accentHex by prefs.accentHex.collectAsState(initial = null) + val appearanceScope = rememberCoroutineScope() + val appearancePreferences = remember(prefs, appearanceScope) { + AppearancePreferences(prefs, appearanceScope) + } + val accentHex by appearancePreferences.accentHex.collectAsState() + val themeMode by appearancePreferences.themeMode.collectAsState() + val darkTheme = themeMode.resolvesToDark(isSystemInDarkTheme()) val accent = accentHex ?.let { hex -> runCatching { Color(android.graphics.Color.parseColor(if (hex.startsWith("#")) hex else "#$hex")) }.getOrNull() } ?: ArcaneBlue + SideEffect { + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } CompositionLocalProvider( LocalArcaneManager provides manager, LocalPinnedStore provides pinnedStore, + LocalAppearancePreferences provides appearancePreferences, ) { - ArcaneTheme(accent = accent) { + ArcaneTheme(darkTheme = darkTheme, accent = accent) { Surface(modifier = Modifier.fillMaxSize()) { ArcaneApp() } diff --git a/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt b/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt index af745f9..8b69b64 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/Prefs.kt @@ -1,22 +1,53 @@ package app.getarcane.android.core import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch private val Context.dataStore: DataStore by preferencesDataStore(name = "arcane_prefs") +internal val themeModePreferenceKey = stringPreferencesKey("theme_mode") -/** App preferences (server URL, accent, active environment). Mirrors iOS UserDefaults usage. */ -class Prefs(context: Context) { - private val store = context.applicationContext.dataStore +enum class AppThemeMode(val persistedValue: String) { + LIGHT("light"), + DARK("dark"), + AUTO("auto"), + ; + + fun resolvesToDark(systemInDarkTheme: Boolean): Boolean = when (this) { + LIGHT -> false + DARK -> true + AUTO -> systemInDarkTheme + } + + companion object { + fun fromPersistedValue(value: String?): AppThemeMode = + entries.firstOrNull { it.persistedValue == value } ?: AUTO + } +} + +/** App preferences (server URL, appearance, active environment). Mirrors iOS UserDefaults usage. */ +class Prefs internal constructor(private val store: DataStore) { + constructor(context: Context) : this(context.applicationContext.dataStore) val serverUrl: Flow = store.data.map { it[SERVER_URL] } val accentHex: Flow = store.data.map { it[ACCENT_HEX] } + val themeMode: Flow = store.data.map { + AppThemeMode.fromPersistedValue(it[themeModePreferenceKey]) + } val activeEnvId: Flow = store.data.map { it[ACTIVE_ENV_ID] } val activeEnvName: Flow = store.data.map { it[ACTIVE_ENV_NAME] } val credentialOrigin: Flow = store.data.map { it[CREDENTIAL_ORIGIN] } @@ -29,6 +60,9 @@ class Prefs(context: Context) { it[SERVER_URL] = value }.let {} suspend fun setAccentHex(value: String) = store.edit { it[ACCENT_HEX] = value }.let {} + suspend fun setThemeMode(value: AppThemeMode) = store.edit { + it[themeModePreferenceKey] = value.persistedValue + }.let {} suspend fun setActiveEnv(id: String, name: String) = store.edit { it[ACTIVE_ENV_ID] = id it[ACTIVE_ENV_NAME] = name @@ -61,3 +95,65 @@ class Prefs(context: Context) { private val CREDENTIAL_ORIGIN = stringPreferencesKey("credential_origin") } } + +/** + * App-owned appearance state. Its scope outlives the Appearance screen, so leaving that screen + * cannot cancel a pending preference write. State flows also retain the last persisted selection + * instead of briefly resetting a recreated screen to its default. + */ +class AppearancePreferences internal constructor( + private val prefs: Prefs, + private val scope: CoroutineScope, +) { + private val mutableThemeMode = MutableStateFlow(AppThemeMode.AUTO) + val themeMode: StateFlow = mutableThemeMode.asStateFlow() + + private val mutableAccentHex = MutableStateFlow("") + val accentHex: StateFlow = mutableAccentHex.asStateFlow() + + private var themeWriteJob: Job? = null + private var accentWriteJob: Job? = null + + init { + scope.launch { + prefs.themeMode.collect { mutableThemeMode.value = it } + } + scope.launch { + prefs.accentHex.collect { mutableAccentHex.value = it.orEmpty() } + } + } + + fun setThemeMode(value: AppThemeMode) { + mutableThemeMode.value = value + themeWriteJob?.cancel() + themeWriteJob = scope.launch { + try { + prefs.setThemeMode(value) + } catch (error: Exception) { + if (error is CancellationException) throw error + if (mutableThemeMode.value == value) { + mutableThemeMode.value = prefs.themeMode.first() + } + } + } + } + + fun setAccentHex(value: String) { + mutableAccentHex.value = value + accentWriteJob?.cancel() + accentWriteJob = scope.launch { + try { + prefs.setAccentHex(value) + } catch (error: Exception) { + if (error is CancellationException) throw error + if (mutableAccentHex.value == value) { + mutableAccentHex.value = prefs.accentHex.first().orEmpty() + } + } + } + } +} + +val LocalAppearancePreferences = staticCompositionLocalOf { + error("AppearancePreferences not provided") +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/AppearanceSettingsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/AppearanceSettingsScreen.kt index 62f11ba..0296e82 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/AppearanceSettingsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/AppearanceSettingsScreen.kt @@ -32,42 +32,27 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import app.getarcane.android.core.Prefs -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -/** Stored theme preference. Drives nothing here — persisting/applying it is the lead's job. */ -enum class ThemeOption(val label: String) { Light("Light"), Dark("Dark"), Auto("Auto") } +import app.getarcane.android.core.AppThemeMode +import app.getarcane.android.core.LocalAppearancePreferences /** * Appearance settings: a theme picker (Light/Dark/Auto) and the accent-color grid. Mirrors iOS * `AppearanceSettingsView` (the iOS-only alternate App Icon picker is omitted on Android). The - * selected accent hex is persisted via [Prefs.accentHex]; the theme selection is held locally. + * selected values are persisted through the app-owned preferences and applied at the theme root. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppearanceSettingsScreen(onBack: () -> Unit) { - val context = LocalContext.current - val prefs = remember { Prefs(context) } - val scope = rememberCoroutineScope() - - var theme by remember { mutableStateOf(ThemeOption.Auto) } - var accentHex by remember { mutableStateOf("") } + val appearancePreferences = LocalAppearancePreferences.current - LaunchedEffect(Unit) { - accentHex = prefs.accentHex.first() ?: "" - } + val theme by appearancePreferences.themeMode.collectAsState() + val accentHex by appearancePreferences.accentHex.collectAsState() val selected = AccentColorOption.fromHex(accentHex) Scaffold( @@ -95,12 +80,12 @@ fun AppearanceSettingsScreen(onBack: () -> Unit) { .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { - ThemeOption.entries.forEachIndexed { index, option -> + AppThemeMode.entries.forEachIndexed { index, option -> SegmentedButton( selected = theme == option, - onClick = { theme = option }, - shape = SegmentedButtonDefaults.itemShape(index, ThemeOption.entries.size), - ) { Text(option.label) } + onClick = { appearancePreferences.setThemeMode(option) }, + shape = SegmentedButtonDefaults.itemShape(index, AppThemeMode.entries.size), + ) { Text(option.displayName) } } } @@ -119,20 +104,14 @@ fun AppearanceSettingsScreen(onBack: () -> Unit) { AccentSwatch( option = option, isSelected = selected == option, - onClick = { - accentHex = option.hex - scope.launch { prefs.setAccentHex(option.hex) } - }, + onClick = { appearancePreferences.setAccentHex(option.hex) }, ) } } SettingsSectionFooter("Choose a color to customize the app's appearance.") TextButton( - onClick = { - accentHex = "" - scope.launch { prefs.setAccentHex("") } - }, + onClick = { appearancePreferences.setAccentHex("") }, modifier = Modifier.padding(start = 8.dp, top = 16.dp), ) { Text("Reset to Default", color = MaterialTheme.colorScheme.error) @@ -141,6 +120,13 @@ fun AppearanceSettingsScreen(onBack: () -> Unit) { } } +private val AppThemeMode.displayName: String + get() = when (this) { + AppThemeMode.LIGHT -> "Light" + AppThemeMode.DARK -> "Dark" + AppThemeMode.AUTO -> "Auto" + } + @Composable private fun AccentSwatch(option: AccentColorOption, isSelected: Boolean, onClick: () -> Unit) { Box( diff --git a/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt b/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt new file mode 100644 index 0000000..bda3cbd --- /dev/null +++ b/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt @@ -0,0 +1,101 @@ +package app.getarcane.android.core + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class PrefsAppearanceTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun persistedValueMappingFallsBackToAuto() { + assertEquals(AppThemeMode.LIGHT, AppThemeMode.fromPersistedValue("light")) + assertEquals(AppThemeMode.DARK, AppThemeMode.fromPersistedValue("dark")) + assertEquals(AppThemeMode.AUTO, AppThemeMode.fromPersistedValue("auto")) + assertEquals(AppThemeMode.AUTO, AppThemeMode.fromPersistedValue(null)) + assertEquals(AppThemeMode.AUTO, AppThemeMode.fromPersistedValue("sepia")) + } + + @Test + fun themeModeResolvesAgainstSystemMode() { + assertFalse(AppThemeMode.LIGHT.resolvesToDark(systemInDarkTheme = true)) + assertTrue(AppThemeMode.DARK.resolvesToDark(systemInDarkTheme = false)) + assertFalse(AppThemeMode.AUTO.resolvesToDark(systemInDarkTheme = false)) + assertTrue(AppThemeMode.AUTO.resolvesToDark(systemInDarkTheme = true)) + } + + @Test + fun allThemeModesPersistWithoutChangingAccent() = runBlocking { + val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val file = File(temp.root, "arcane_prefs.preferences_pb") + val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } + val prefs = Prefs(dataStore) + + prefs.setAccentHex("#AF52DE") + AppThemeMode.entries.forEach { mode -> + prefs.setThemeMode(mode) + + val anotherPrefsOwner = Prefs(dataStore) + assertEquals(mode, anotherPrefsOwner.themeMode.first()) + assertEquals(mode.persistedValue, dataStore.data.first()[themeModePreferenceKey]) + assertEquals("#AF52DE", anotherPrefsOwner.accentHex.first()) + } + + dataStoreScope.cancel() + } + + @Test + fun invalidStoredModeFallsBackWithoutChangingAccent() = runBlocking { + val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val file = File(temp.root, "invalid_arcane_prefs.preferences_pb") + val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } + val prefs = Prefs(dataStore) + + assertEquals(AppThemeMode.AUTO, prefs.themeMode.first()) + prefs.setAccentHex("#34C759") + dataStore.edit { it[themeModePreferenceKey] = "invalid" } + + assertEquals(AppThemeMode.AUTO, prefs.themeMode.first()) + assertEquals("#34C759", prefs.accentHex.first()) + + dataStoreScope.cancel() + } + + @Test + fun appOwnedAppearanceStatePersistsAfterSetterReturns() = runBlocking { + val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val appearanceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val file = File(temp.root, "owned_arcane_prefs.preferences_pb") + val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } + val appearancePreferences = AppearancePreferences(Prefs(dataStore), appearanceScope) + + appearancePreferences.setThemeMode(AppThemeMode.LIGHT) + appearancePreferences.setAccentHex("#5856D6") + + assertEquals(AppThemeMode.LIGHT, appearancePreferences.themeMode.value) + assertEquals("#5856D6", appearancePreferences.accentHex.value) + assertEquals( + AppThemeMode.LIGHT.persistedValue, + dataStore.data.map { it[themeModePreferenceKey] }.first { it != null }, + ) + assertEquals(AppThemeMode.LIGHT, appearancePreferences.themeMode.first { it == AppThemeMode.LIGHT }) + assertEquals("#5856D6", appearancePreferences.accentHex.first { it == "#5856D6" }) + + appearanceScope.cancel() + dataStoreScope.cancel() + } +} From a0b4fcb75510d3c3f4194e1b9c4044086f1c4fa4 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Sat, 22 Aug 2026 00:04:16 +0000 Subject: [PATCH 2/2] docs: complete PAR-009 verification --- docs/ios-parity-task-list.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index 616ae67..426ffdd 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -300,20 +300,32 @@ The standard checks are: switching without stale stream content, Activity Center refresh and screen departure, container Logs/Stats/Terminal departure and reopen, and recovery after force-stop. -- [ ] **PAR-009 — Persist and apply Light/Dark/Auto appearance** +- [x] **PAR-009 — Persist and apply Light/Dark/Auto appearance** -- **Status:** Ready +- **Status:** Complete - **Priority:** P0 - **Dependencies:** None - **Scope:** Replace the screen-local theme selection with one persisted preference owned at the app level and applied at the `ArcaneTheme` root. Preserve the existing accent-color behavior and system-theme default. - **Acceptance criteria:** - - [ ] Light, Dark, and Auto update the whole application immediately and survive process recreation. - - [ ] Auto follows system night-mode changes without reopening Settings. - - [ ] Invalid or missing persisted values fall back to Auto, and migration does not disturb accent. - - [ ] State mapping and persistence have focused tests; representative screens are device-checked in + - [x] Light, Dark, and Auto update the whole application immediately and survive process recreation. + - [x] Auto follows system night-mode changes without reopening Settings. + - [x] Invalid or missing persisted values fall back to Auto, and migration does not disturb accent. + - [x] State mapping and persistence have focused tests; representative screens are device-checked in light/dark mode. +- **Validation evidence (2026-08-22):** + - Source pins: Android base `519adca53d5e08d83133ef8ea6a77ccd94b9db71`, + libarcane-kotlin `991dfdc1ee747c171ebf1b5953fe5fb61ceadfb8`, and Arcane + `0fd8820822f49e2da25739306bc9bc401253fa9e` (no SDK or server changes were required). + - `PrefsAppearanceTest` passed 5 focused tests covering stable Light/Dark/Auto values, system-mode + resolution, missing and invalid fallback, app-owned asynchronous persistence, and isolation from + the existing accent preference. + - `./gradlew :app:testDebugUnitTest :app:assembleDebug` passed all 153 unit tests and assembled the + debug APK; `git diff --check` passed. + - Michael's physical-device check passed immediate whole-app Light/Dark switching, readable system + bars, Back/re-entry and rapid-departure persistence, force-stop restoration of theme and accent, + and Auto tracking Android system light/dark changes without reopening Appearance. ## Phase 1: Validate destructive behavior and complete daily workflows