Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions app/src/main/kotlin/app/getarcane/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
Expand Down
102 changes: 99 additions & 3 deletions app/src/main/kotlin/app/getarcane/android/core/Prefs.kt
Original file line number Diff line number Diff line change
@@ -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<Preferences> 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<Preferences>) {
constructor(context: Context) : this(context.applicationContext.dataStore)

val serverUrl: Flow<String?> = store.data.map { it[SERVER_URL] }
val accentHex: Flow<String?> = store.data.map { it[ACCENT_HEX] }
val themeMode: Flow<AppThemeMode> = store.data.map {
AppThemeMode.fromPersistedValue(it[themeModePreferenceKey])
}
val activeEnvId: Flow<String?> = store.data.map { it[ACTIVE_ENV_ID] }
val activeEnvName: Flow<String?> = store.data.map { it[ACTIVE_ENV_NAME] }
val credentialOrigin: Flow<String?> = store.data.map { it[CREDENTIAL_ORIGIN] }
Expand All @@ -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
Expand Down Expand Up @@ -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<AppThemeMode> = mutableThemeMode.asStateFlow()

private val mutableAccentHex = MutableStateFlow("")
val accentHex: StateFlow<String> = 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<AppearancePreferences> {
error("AppearancePreferences not provided")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) }
}
}

Expand All @@ -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)
Expand All @@ -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(
Expand Down
101 changes: 101 additions & 0 deletions app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading