diff --git a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt index baa811cb..3ca686bb 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt @@ -87,6 +87,7 @@ class MainActivity : AppCompatActivity() { }, dynamicColor = theme.dynamicColor, oled = theme.mode == ThemeMode.OLED, + preset = theme.preset, ) { CompositionLocalProvider(LocalAppGraph provides appGraph) { HapiNavigation() diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ChoiceDialog.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ChoiceDialog.kt index ded0bddb..1b97c06e 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ChoiceDialog.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ChoiceDialog.kt @@ -9,12 +9,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog @@ -30,6 +34,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.theme.ColorThemePreset +import app.hapi.companion.ui.theme.preview /** * One entry of a [ChoiceDialog]: the value to hand back on selection, its @@ -50,13 +56,16 @@ data class ChoiceOption( * derived from the active color scheme, mirroring how the web color-theme * cards hardcode their preview colors: a "light" chip must stay light even * when the dialog itself is dark. [outline] adds a hairline border for - * chips whose halves would otherwise disappear against the dialog surface. + * chips whose halves would otherwise disappear against the dialog surface; + * [accentColor] paints an accent strip along the bottom edge, the way the + * web color-theme cards carry their accent below the light/dark split. */ @Immutable data class ChoiceSwatch( val startColor: Color, val endColor: Color, val outline: Boolean = false, + val accentColor: Color? = null, ) /** Fixed light preview: the light theme's canvas. */ @@ -78,6 +87,25 @@ internal fun themeModeSwatch(mode: ThemeMode): ChoiceSwatch = when (mode) { ThemeMode.OLED -> ChoiceSwatch(startColor = Color(0xFF000000), endColor = Color(0xFF000000), outline = true) } +/** + * Preview chip for a color theme preset: the left half is the preset's light + * canvas, the right half its dark canvas, and the accent strip shows the + * preset's signature color (web parity — the web cards hardcode these three + * values rather than deriving them). Every preset card carries the hairline: + * a preset's canvases are near-white and near-black by design, so the chip + * would partially vanish against a same-toned dialog — the web cards are + * bordered for the same reason. + */ +internal fun colorThemePresetSwatch(preset: ColorThemePreset): ChoiceSwatch { + val preview = preset.preview() + return ChoiceSwatch( + startColor = preview.light, + endColor = preview.dark, + outline = true, + accentColor = preview.accent, + ) +} + /** * Expressive single-choice dialog for small exclusive pickers (the theme and * language settings). Each option renders as a full-width choice card: the @@ -90,7 +118,9 @@ internal fun themeModeSwatch(mode: ThemeMode): ChoiceSwatch = when (mode) { * target instead of two (row + radio with its own click handler). * * Choosing an option is the only commit gesture: tapping one invokes - * [onSelect] immediately, exactly like the previous dialogs. + * [onSelect] immediately, exactly like the previous dialogs. Longer + * catalogs (e.g. the color themes) scroll inside a capped height so the + * dialog stays on screen on small displays. */ @Composable internal fun ChoiceDialog( @@ -106,7 +136,10 @@ internal fun ChoiceDialog( title = { Text(title) }, text = { Column( - modifier = Modifier.selectableGroup(), + modifier = Modifier + .selectableGroup() + .heightIn(max = 384.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { options.forEach { option -> @@ -171,11 +204,15 @@ private fun ChoiceRow( } } -/** Rounded two-tone chip: left half [ChoiceSwatch.startColor], right half [ChoiceSwatch.endColor]. */ +/** + * Rounded two-tone chip: left half [ChoiceSwatch.startColor], right half + * [ChoiceSwatch.endColor], with the optional [ChoiceSwatch.accentColor] strip + * running along the bottom edge. + */ @Composable private fun SwatchChip(swatch: ChoiceSwatch) { val shape = RoundedCornerShape(6.dp) - Row( + Box( modifier = Modifier .size(width = 36.dp, height = 20.dp) .clip(shape) @@ -191,17 +228,30 @@ private fun SwatchChip(swatch: ChoiceSwatch) { }, ), ) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(swatch.startColor), - ) - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(swatch.endColor), - ) + Row( + modifier = Modifier.matchParentSize(), + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(swatch.startColor), + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(swatch.endColor), + ) + } + swatch.accentColor?.let { accent -> + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(4.dp) + .background(accent), + ) + } } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt index 4212b0cf..0b442e19 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsPreviews.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.theme.ColorThemePreset import app.hapi.companion.ui.theme.HapiTheme import app.hapi.protocol.wire.HubHealthResponse import app.hapi.protocol.wire.SqliteStorageUsageResponse @@ -19,8 +20,9 @@ import java.time.LocalDate * Compile-checked previews doubling as visual regressions for the B-M4e * dashboards: stat tiles, the daily bar chart, the ranked bar list, and the * storage donut — light, dark, and OLED — plus the P2 settings hub (owner and - * non-owner gating), the General subpage it drills into, and the expressive - * choice dialogs that subpage opens (theme swatches in light and dark, + * non-owner gating), the General subpage it drills into (stock and under a + * color theme preset), and the expressive choice dialogs that subpage opens + * (theme swatches in light and dark, color-theme cards with accent strips, * language cards without swatches). */ @@ -188,6 +190,7 @@ private fun SettingsGeneralPreview() { ), onSetThemeMode = {}, onSetDynamicColor = {}, + onSetPreset = {}, onSetLanguage = {}, onRetryHubInfo = {}, onBack = {}, @@ -196,6 +199,54 @@ private fun SettingsGeneralPreview() { } } +@Preview(name = "Settings general · color theme", showBackground = true, heightDp = 1200) +@Composable +private fun SettingsGeneralPresetPreview() { + HapiTheme(darkTheme = true, dynamicColor = false, preset = ColorThemePreset.NORD) { + Surface { + SettingsGeneralContent( + theme = ThemeSettings( + mode = ThemeMode.DARK, + dynamicColor = false, + preset = ColorThemePreset.NORD, + ), + language = AppLanguage.ENGLISH, + hubUrl = "https://hub.example", + hubInfo = HubInfoState.Loaded( + HubHealthResponse(status = "ok", protocolVersion = 1), + ), + onSetThemeMode = {}, + onSetDynamicColor = {}, + onSetPreset = {}, + onSetLanguage = {}, + onRetryHubInfo = {}, + onBack = {}, + ) + } + } +} + +@Preview(name = "Color theme choice dialog", showBackground = true) +@Composable +private fun ColorThemeChoiceDialogPreview() { + HapiTheme(darkTheme = false, dynamicColor = false) { + ChoiceDialog( + title = "Color theme", + options = ColorThemePreset.entries.map { preset -> + ChoiceOption( + value = preset, + label = preset.name.lowercase().replaceFirstChar { it.uppercase() }, + swatch = colorThemePresetSwatch(preset), + ) + }, + selected = ColorThemePreset.NORD, + onSelect = {}, + onDismiss = {}, + dismissLabel = "Cancel", + ) + } +} + @Preview(name = "Theme choice dialog", showBackground = true) @Composable private fun ThemeChoiceDialogPreview() { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt index ff2f1aa2..e1d757b3 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsScreen.kt @@ -38,14 +38,15 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import app.hapi.companion.BuildConfig import app.hapi.companion.R +import app.hapi.companion.ui.theme.ColorThemePreset import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION /** * General subpage of the settings hub (P2 IA): the catch-all category for * everything without a dedicated category on this branch — Appearance (theme - * mode + Material You), Language, and About (app/protocol versions + hub - * health). The former owner-only Usage/Storage rows moved hub-level (see - * [SettingsHubCatalog]); their owner gate is unchanged. + * mode + color theme + Material You), Language, and About (app/protocol + * versions + hub health). The former owner-only Usage/Storage rows moved + * hub-level (see [SettingsHubCatalog]); their owner gate is unchanged. */ @Composable fun SettingsGeneralScreen( @@ -63,6 +64,7 @@ fun SettingsGeneralScreen( hubInfo = hubInfo, onSetThemeMode = viewModel::setThemeMode, onSetDynamicColor = viewModel::setDynamicColor, + onSetPreset = viewModel::setPreset, onSetLanguage = viewModel::setLanguage, onRetryHubInfo = viewModel::retryHubInfo, onBack = onBack, @@ -79,11 +81,13 @@ internal fun SettingsGeneralContent( hubInfo: HubInfoState, onSetThemeMode: (ThemeMode) -> Unit, onSetDynamicColor: (Boolean) -> Unit, + onSetPreset: (ColorThemePreset) -> Unit, onSetLanguage: (AppLanguage) -> Unit, onRetryHubInfo: () -> Unit, onBack: () -> Unit, ) { var showThemeDialog by rememberSaveable { mutableStateOf(false) } + var showColorThemeDialog by rememberSaveable { mutableStateOf(false) } var showLanguageDialog by rememberSaveable { mutableStateOf(false) } Scaffold( @@ -115,16 +119,31 @@ internal fun SettingsGeneralContent( value = stringResource(themeModeLabelRes(theme.mode)), onClick = { showThemeDialog = true }, ) + SettingsDivider() + SettingsRow( + label = stringResource(R.string.settings_color_theme), + value = stringResource(colorThemePresetLabelRes(theme.preset)), + onClick = { showColorThemeDialog = true }, + ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Material You cannot run under the OLED scheme or a color + // theme preset (both pin their own palettes), so the switch + // reflects that and explains why it is off. + val dynamicColorAvailable = + theme.mode != ThemeMode.OLED && theme.preset == ColorThemePreset.DEFAULT SettingsDivider() SwitchRow( label = stringResource(R.string.settings_dynamic_color), description = stringResource( - if (theme.mode == ThemeMode.OLED) R.string.settings_dynamic_color_oled_note - else R.string.settings_dynamic_color_description + when { + theme.mode == ThemeMode.OLED -> R.string.settings_dynamic_color_oled_note + theme.preset != ColorThemePreset.DEFAULT -> + R.string.settings_dynamic_color_preset_note + else -> R.string.settings_dynamic_color_description + } ), - checked = theme.dynamicColor && theme.mode != ThemeMode.OLED, - enabled = theme.mode != ThemeMode.OLED, + checked = theme.dynamicColor && dynamicColorAvailable, + enabled = dynamicColorAvailable, onCheckedChange = onSetDynamicColor, ) } @@ -180,6 +199,17 @@ internal fun SettingsGeneralContent( ) } + if (showColorThemeDialog) { + ColorThemeDialog( + selected = theme.preset, + onSelect = { preset -> + showColorThemeDialog = false + onSetPreset(preset) + }, + onDismiss = { showColorThemeDialog = false }, + ) + } + if (showLanguageDialog) { LanguageDialog( selected = language, @@ -341,6 +371,34 @@ private fun LanguageDialog( ) } +/** + * Expressive color-theme picker: each card carries the preset's light/dark + * canvas split plus its accent strip, so the options are recognizable at a + * glance. Proper nouns stay untranslated on every locale (web parity); only + * the default entry localizes. + */ +@Composable +private fun ColorThemeDialog( + selected: ColorThemePreset, + onSelect: (ColorThemePreset) -> Unit, + onDismiss: () -> Unit, +) { + ChoiceDialog( + title = stringResource(R.string.settings_color_theme), + options = ColorThemePreset.entries.map { preset -> + ChoiceOption( + value = preset, + label = stringResource(colorThemePresetLabelRes(preset)), + swatch = colorThemePresetSwatch(preset), + ) + }, + selected = selected, + onSelect = onSelect, + onDismiss = onDismiss, + dismissLabel = stringResource(R.string.settings_cancel), + ) +} + private fun themeModeLabelRes(mode: ThemeMode): Int = when (mode) { ThemeMode.SYSTEM -> R.string.settings_theme_system ThemeMode.LIGHT -> R.string.settings_theme_light @@ -348,6 +406,26 @@ private fun themeModeLabelRes(mode: ThemeMode): Int = when (mode) { ThemeMode.OLED -> R.string.settings_theme_oled } +private fun colorThemePresetLabelRes(preset: ColorThemePreset): Int = when (preset) { + ColorThemePreset.DEFAULT -> R.string.settings_color_theme_default + ColorThemePreset.NOTION -> R.string.settings_color_theme_notion + ColorThemePreset.ONE -> R.string.settings_color_theme_one + ColorThemePreset.PROOF -> R.string.settings_color_theme_proof + ColorThemePreset.RAYCAST -> R.string.settings_color_theme_raycast + ColorThemePreset.ROSE_PINE -> R.string.settings_color_theme_rose_pine + ColorThemePreset.SOLARIZED -> R.string.settings_color_theme_solarized + ColorThemePreset.VERCEL -> R.string.settings_color_theme_vercel + ColorThemePreset.VS_CODE_PLUS -> R.string.settings_color_theme_vs_code_plus + ColorThemePreset.XCODE -> R.string.settings_color_theme_xcode + ColorThemePreset.LINEAR -> R.string.settings_color_theme_linear + ColorThemePreset.LOBSTER -> R.string.settings_color_theme_lobster + ColorThemePreset.MATERIAL -> R.string.settings_color_theme_material + ColorThemePreset.MATRIX -> R.string.settings_color_theme_matrix + ColorThemePreset.MONOKAI -> R.string.settings_color_theme_monokai + ColorThemePreset.NIGHT_OWL -> R.string.settings_color_theme_night_owl + ColorThemePreset.NORD -> R.string.settings_color_theme_nord +} + /** * Language names are shown in their own language (standard picker * convention), so they are string literals, not resources; only the diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt index a103eb05..e36eef09 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/SettingsViewModel.kt @@ -2,6 +2,7 @@ package app.hapi.companion.feature.settings import app.hapi.data.auth.JwtPeek import app.hapi.protocol.wire.HubHealthResponse +import app.hapi.companion.ui.theme.ColorThemePreset import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -80,6 +81,10 @@ class SettingsViewModel( scope.launch { themePrefs.setDynamicColor(enabled) } } + fun setPreset(preset: ColorThemePreset) { + scope.launch { themePrefs.setPreset(preset) } + } + fun setLanguage(language: AppLanguage) { scope.launch { languagePrefs.setLanguage(language) } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt index 1f8cbbf1..715677e5 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/settings/ThemePrefs.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey +import app.hapi.companion.ui.theme.ColorThemePreset import java.io.IOException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -30,17 +31,26 @@ enum class ThemeMode(val storageKey: String) { } } -/** The persisted appearance choice, defaults = follow system + Material You. */ +/** + * The persisted appearance choice, defaults = follow system + Material You. + * [preset] is the web-parity color theme; the default keeps the built-in + * schemes in charge. + */ data class ThemeSettings( val mode: ThemeMode = ThemeMode.SYSTEM, - /** Material You wallpaper color (effective only on API 31+, non-OLED). */ + /** Material You wallpaper color (effective only on API 31+, non-OLED, no preset). */ val dynamicColor: Boolean = true, + val preset: ColorThemePreset = ColorThemePreset.DEFAULT, ) /** * Appearance persistence over the app-wide `hapi_prefs` DataStore. Read at * `MainActivity.setContent` to drive `HapiTheme`, written by the settings * screen; unreadable prefs degrade to defaults (roster storage precedent). + * The preset key is stored explicitly (including `"default"`) so "never + * configured" and "deliberately back on default" persist identically — + * the web client deletes its key instead, which DataStore keys cannot + * express without conflating the two states. */ class ThemePrefs(private val dataStore: DataStore) { @@ -51,6 +61,7 @@ class ThemePrefs(private val dataStore: DataStore) { ThemeSettings( mode = ThemeMode.fromStorageKey(prefs[MODE_KEY]), dynamicColor = prefs[DYNAMIC_COLOR_KEY] ?: true, + preset = ColorThemePreset.fromStorageKey(prefs[PRESET_KEY]), ) } .distinctUntilChanged() @@ -63,8 +74,13 @@ class ThemePrefs(private val dataStore: DataStore) { dataStore.edit { it[DYNAMIC_COLOR_KEY] = enabled } } + suspend fun setPreset(preset: ColorThemePreset) { + dataStore.edit { it[PRESET_KEY] = preset.storageKey } + } + companion object { val MODE_KEY: Preferences.Key = stringPreferencesKey("theme_mode") val DYNAMIC_COLOR_KEY: Preferences.Key = booleanPreferencesKey("theme_dynamic_color") + val PRESET_KEY: Preferences.Key = stringPreferencesKey("theme_preset") } } diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt index 6e4b39ba..d38968c0 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt @@ -60,32 +60,105 @@ private val OledColorScheme = darkColorScheme( * * [oled] selects the pure-black variant (implies dark, disables dynamic * color); the settings screen wires it up later -- callers default to the - * regular schemes. [typography] defaults to the app-wide scale derived from + * regular schemes. [preset] selects a named color theme (web parity, see + * [ColorThemePreset]): a preset overrides the dynamic and OLED schemes -- + * it never derives from the wallpaper -- and OLED mode renders its dark + * palette, exactly how the web client layers `data-color-theme` over + * `data-theme`. [typography] defaults to the app-wide scale derived from * [HapiTypography]'s reading tokens, so every Material widget and every * direct token consumer share one type system. */ +/** + * Which color scheme [HapiTheme] should paint, resolved from the settings + * before any Android framework type (context, Build constants) enters the + * picture so the ordering itself stays JVM-testable. + */ +internal sealed interface ThemeSchemeSource { + /** + * A named preset's palette. A preset wins over OLED, dynamic color, and + * the static schemes — it never derives from the wallpaper — and under + * OLED it resolves to its *dark* seed set, exactly how the web client + * layers `data-color-theme` over `data-theme` (oled maps onto the dark + * palette there too). + */ + data class Preset(val palette: ThemePresetPalette) : ThemeSchemeSource + + /** The built-in pure-black OLED scheme (never combined with dynamic color). */ + data object Oled : ThemeSchemeSource + + /** Material You; [dark] picks the tone set. */ + data class Dynamic(val dark: Boolean) : ThemeSchemeSource + + /** The built-in static schemes. */ + data object StaticDark : ThemeSchemeSource + data object StaticLight : ThemeSchemeSource +} + +/** + * The theme resolution order: `preset > oled > dynamic color > static + * light/dark`. [supportsDynamic] stands in for + * `Build.VERSION.SDK_INT >= Build.VERSION_CODES.S` so tests can drive the + * full settings matrix on the JVM. + */ +internal fun resolveThemeSchemeSource( + preset: ColorThemePreset, + darkTheme: Boolean, + dynamicColor: Boolean, + oled: Boolean, + supportsDynamic: Boolean, +): ThemeSchemeSource = when { + preset != ColorThemePreset.DEFAULT -> ThemeSchemeSource.Preset( + preset.palette(dark = darkTheme || oled), + ) + oled -> ThemeSchemeSource.Oled + dynamicColor && supportsDynamic -> ThemeSchemeSource.Dynamic(darkTheme) + darkTheme -> ThemeSchemeSource.StaticDark + else -> ThemeSchemeSource.StaticLight +} + @Composable fun HapiTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, oled: Boolean = false, + preset: ColorThemePreset = ColorThemePreset.DEFAULT, typography: Typography = HapiTypography.scale, content: @Composable () -> Unit, ) { - val colorScheme = when { - oled -> OledColorScheme - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> DarkColorScheme - else -> LightColorScheme + val schemeSource = resolveThemeSchemeSource( + preset = preset, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + oled = oled, + supportsDynamic = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + ) + val presetPalette = (schemeSource as? ThemeSchemeSource.Preset)?.palette + val colorScheme = when (val source = schemeSource) { + is ThemeSchemeSource.Preset -> source.palette.colorScheme() + ThemeSchemeSource.Oled -> OledColorScheme + is ThemeSchemeSource.Dynamic -> + // The resolver only emits Dynamic when the passed SDK gate says + // the device runs Android 12+, but that guard crosses a function + // boundary lint cannot track — keep the check local to the branch. + // The static arms are unreachable today and fail safe if the two + // ever disagree. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val context = LocalContext.current + if (source.dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else if (source.dark) { + DarkColorScheme + } else { + LightColorScheme + } + ThemeSchemeSource.StaticDark -> DarkColorScheme + ThemeSchemeSource.StaticLight -> LightColorScheme } - val extendedColors = when { + val baseExtendedColors = when { oled -> HapiOledExtendedColors darkTheme -> HapiDarkExtendedColors else -> HapiLightExtendedColors } + val extendedColors = presetPalette?.extendedColors(baseExtendedColors) ?: baseExtendedColors CompositionLocalProvider(LocalHapiExtendedColors provides extendedColors) { MaterialTheme( diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/ThemePresets.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/ThemePresets.kt new file mode 100644 index 00000000..37e063cf --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/ThemePresets.kt @@ -0,0 +1,347 @@ +package app.hapi.companion.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import kotlin.math.floor +import kotlin.math.pow + +/** + * Color theme presets — the Android twin of the web client's color-theme + * picker (`web/src/hooks/useColorTheme.ts`). Each named preset carries five + * seed colors per scheme (accent, background, foreground, hint, secondary); + * every other tone derives from them deterministically with the same math + * the web client uses, so a preset reads identically on both clients. + * + * [DEFAULT] is the absence of a preset: the built-in light/dark/OLED schemes + * (plus Material You) stay in charge, exactly like the web client letting its + * inline palette variables go so the base CSS tokens apply. + */ +enum class ColorThemePreset(val storageKey: String) { + DEFAULT("default"), + NOTION("notion"), + ONE("one"), + PROOF("proof"), + RAYCAST("raycast"), + ROSE_PINE("rose-pine"), + SOLARIZED("solarized"), + VERCEL("vercel"), + VS_CODE_PLUS("vs-code-plus"), + XCODE("xcode"), + LINEAR("linear"), + LOBSTER("lobster"), + MATERIAL("material"), + MATRIX("matrix"), + MONOKAI("monokai"), + NIGHT_OWL("night-owl"), + NORD("nord"); + + companion object { + /** Unknown/corrupt stored values degrade to [DEFAULT]. */ + fun fromStorageKey(raw: String?): ColorThemePreset = + entries.firstOrNull { it.storageKey == raw } ?: DEFAULT + } +} + +/** + * The five seed colors a preset defines per scheme, in the web client's + * order. Everything else the UI needs derives from these (see + * [ThemePresetPalette]). + */ +@Immutable +data class ThemePresetSeeds( + val accent: Color, + val background: Color, + val foreground: Color, + val hint: Color, + val secondary: Color, +) + +/** A preset's two seed sets; the dark one also serves OLED (web parity). */ +@Immutable +data class ThemePresetSeedPair(val light: ThemePresetSeeds, val dark: ThemePresetSeeds) + +/** Light-theme hairline base the web client tints for borders/subtles. */ +private val LightInk = Color(0xFF0F172A) + +/** Text the web client puts on accent-colored surfaces when the accent is light. */ +private val InkOnBrightAccent = Color(0xFF111827) + +/** The 16 named presets' seeds, transcribed from the web `PALETTES` table. */ +internal val themePresetSeeds: Map = mapOf( + ColorThemePreset.NOTION to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF3183D8), Color(0xFFFAFAFA), Color(0xFF37352F), Color(0xFF787774), Color(0xFFF1F1EF)), + dark = ThemePresetSeeds(Color(0xFF3183D8), Color(0xFF191919), Color(0xFFD9D9D8), Color(0xFF9B9A97), Color(0xFF252525)), + ), + ColorThemePreset.ONE to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF526FFF), Color(0xFFFBFBFF), Color(0xFF383A42), Color(0xFF696C77), Color(0xFFF0F2FF)), + dark = ThemePresetSeeds(Color(0xFF7B8CFF), Color(0xFF1F2433), Color(0xFFD8DEE9), Color(0xFFAAB2C0), Color(0xFF2C3245)), + ), + ColorThemePreset.PROOF to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF2F7D5B), Color(0xFFF8F7F2), Color(0xFF26352F), Color(0xFF64706B), Color(0xFFEEF1EA)), + dark = ThemePresetSeeds(Color(0xFF7FC8A6), Color(0xFF18231F), Color(0xFFD9E4DC), Color(0xFF9DAFAA), Color(0xFF23332D)), + ), + ColorThemePreset.RAYCAST to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFFFF4D4D), Color(0xFFFFFFFF), Color(0xFF2B2B31), Color(0xFF72727A), Color(0xFFFFF0F0)), + dark = ThemePresetSeeds(Color(0xFFFF6363), Color(0xFF201617), Color(0xFFF3EDED), Color(0xFFC9BFC0), Color(0xFF321F21)), + ), + ColorThemePreset.ROSE_PINE to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFFD7827E), Color(0xFFFFFAF3), Color(0xFF575279), Color(0xFF797593), Color(0xFFF2E9E1)), + dark = ThemePresetSeeds(Color(0xFFEBBCBA), Color(0xFF191724), Color(0xFFE0DEF4), Color(0xFF908CAA), Color(0xFF26233A)), + ), + ColorThemePreset.SOLARIZED to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFFB58900), Color(0xFFFDF6E3), Color(0xFF586E75), Color(0xFF839496), Color(0xFFEEE8D5)), + dark = ThemePresetSeeds(Color(0xFF268BD2), Color(0xFF002B36), Color(0xFF93A1A1), Color(0xFF839496), Color(0xFF073642)), + ), + ColorThemePreset.VERCEL to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF0070F3), Color(0xFFFFFFFF), Color(0xFF111111), Color(0xFF666666), Color(0xFFF5F5F5)), + dark = ThemePresetSeeds(Color(0xFF3291FF), Color(0xFF000000), Color(0xFFEDEDED), Color(0xFFA1A1A1), Color(0xFF111111)), + ), + ColorThemePreset.VS_CODE_PLUS to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF007ACC), Color(0xFFFFFFFF), Color(0xFF24292F), Color(0xFF6E7781), Color(0xFFF3F6F8)), + dark = ThemePresetSeeds(Color(0xFF3794FF), Color(0xFF1E1E1E), Color(0xFFD4D4D4), Color(0xFF9CDCFE), Color(0xFF252526)), + ), + ColorThemePreset.XCODE to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF0A84FF), Color(0xFFF7F9FF), Color(0xFF1F2328), Color(0xFF59636E), Color(0xFFEEF4FF)), + dark = ThemePresetSeeds(Color(0xFF409CFF), Color(0xFF1F2024), Color(0xFFEEF2FF), Color(0xFFA9B0BD), Color(0xFF2A2C33)), + ), + ColorThemePreset.LINEAR to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF5E6AD2), Color(0xFFF7F7FB), Color(0xFF25262F), Color(0xFF70717D), Color(0xFFEEEEF8)), + dark = ThemePresetSeeds(Color(0xFF8A91F6), Color(0xFF08090D), Color(0xFFF7F8FF), Color(0xFFA6A8B5), Color(0xFF151722)), + ), + ColorThemePreset.LOBSTER to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFFE84D68), Color(0xFFFFF7F4), Color(0xFF33262B), Color(0xFF7D636A), Color(0xFFFFE9E2)), + dark = ThemePresetSeeds(Color(0xFFFF5B6E), Color(0xFF161821), Color(0xFFF5D7DC), Color(0xFFBE9CA5), Color(0xFF242333)), + ), + ColorThemePreset.MATERIAL to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF6750A4), Color(0xFFFFFBFE), Color(0xFF1C1B1F), Color(0xFF6F6A73), Color(0xFFF2EDF7)), + dark = ThemePresetSeeds(Color(0xFFD0BCFF), Color(0xFF1C1B1F), Color(0xFFE6E1E5), Color(0xFFCAC4CF), Color(0xFF2B2930)), + ), + ColorThemePreset.MATRIX to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF10883A), Color(0xFFF5FFF7), Color(0xFF102016), Color(0xFF55735F), Color(0xFFE8F8EC)), + dark = ThemePresetSeeds(Color(0xFF00FF66), Color(0xFF030806), Color(0xFFD8FFE4), Color(0xFF6AFF9B), Color(0xFF07150D)), + ), + ColorThemePreset.MONOKAI to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF7A8F00), Color(0xFFF8F7EF), Color(0xFF3B3A32), Color(0xFF747065), Color(0xFFEFEEE4)), + dark = ThemePresetSeeds(Color(0xFFA6E22E), Color(0xFF272822), Color(0xFFF8F8F2), Color(0xFFCFCFC2), Color(0xFF33342D)), + ), + ColorThemePreset.NIGHT_OWL to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF4876D9), Color(0xFFFBFDFF), Color(0xFF25354A), Color(0xFF60708A), Color(0xFFEEF6FF)), + dark = ThemePresetSeeds(Color(0xFF82AAFF), Color(0xFF011627), Color(0xFFD6DEEB), Color(0xFF7FDBCA), Color(0xFF0B2942)), + ), + ColorThemePreset.NORD to ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF5E81AC), Color(0xFFECEFF4), Color(0xFF2E3440), Color(0xFF667085), Color(0xFFE5E9F0)), + dark = ThemePresetSeeds(Color(0xFF88C0D0), Color(0xFF2E3440), Color(0xFFECEFF4), Color(0xFFD8DEE9), Color(0xFF3B4252)), + ), +) + +/** + * One preset's full palette for a single scheme: the five seeds plus the + * derived tones, with the same names and formulas as the web client's + * `palette()` so the two stay in lock-step. [isDark] follows the *palette's + * own* background luminance (not the caller's scheme flag) — that is how the + * web client decides which direction the elevation mixes go, which keeps the + * derivation correct even for hypothetical palettes whose seeds disagree + * with the scheme they are filed under. + */ +@Immutable +data class ThemePresetPalette( + val isDark: Boolean, + val accent: Color, + val background: Color, + val foreground: Color, + val hint: Color, + val secondary: Color, + /** One elevation step up from the canvas — web `dialog` (5% / 1.5% mix). */ + val dialog: Color, + /** Card/tool/quote surfaces — web `surface` (9% / 3.5% mix). */ + val surface: Color, + /** Hovered surfaces and table/code headers — web `surfaceHover` (14% / 6.5%). */ + val surfaceHover: Color, + /** Code blocks — web `code` (11% / 4.5% mix). */ + val code: Color, + /** Hairline stroke — web `border` (white or slate ink at ~10% alpha). */ + val border: Color, + /** Faint wash — web `subtle` (white or slate ink at ~5% alpha). */ + val subtle: Color, + /** Text that stays readable on accent-filled surfaces — web `buttonText`. */ + val buttonText: Color, +) + +/** + * Derives the full palette from a seed set with the web client's exact math: + * per-channel linear mixes (no gamma), WCAG relative luminance for the + * dark/light call and for button-text contrast. + */ +internal fun ThemePresetSeedPair.palette(dark: Boolean): ThemePresetPalette { + // `dark` (the scheme flag) shadows the receiver's `dark` seed set, so the + // seed picks are explicitly qualified. + val seeds = if (dark) this.dark else this.light + val isDark = relativeLuminance(seeds.background) < 0.5 + val contrast = if (isDark) Color.White else Color.Black + val ink = if (isDark) Color.White else LightInk + return ThemePresetPalette( + isDark = isDark, + accent = seeds.accent, + background = seeds.background, + foreground = seeds.foreground, + hint = seeds.hint, + secondary = seeds.secondary, + dialog = mix(seeds.background, contrast, if (isDark) 0.05 else 0.015), + surface = mix(seeds.background, contrast, if (isDark) 0.09 else 0.035), + surfaceHover = mix(seeds.background, contrast, if (isDark) 0.14 else 0.065), + code = mix(seeds.background, contrast, if (isDark) 0.11 else 0.045), + border = ink.copy(alpha = if (isDark) 0.11f else 0.10f), + subtle = ink.copy(alpha = if (isDark) 0.06f else 0.045f), + buttonText = if (relativeLuminance(seeds.accent) > 0.45) InkOnBrightAccent else Color.White, + ) +} + +/** + * Resolves [this] preset into a palette. OLED resolves to the dark seeds — + * the web client maps oled onto the dark palette the same way — so the + * caller passes `dark = darkTheme || oled`. + */ +fun ColorThemePreset.palette(dark: Boolean): ThemePresetPalette { + val seeds = themePresetSeeds[this] + ?: error("preset '$storageKey' has no palette; DEFAULT resolves through the built-in schemes") + return seeds.palette(dark) +} + +/** + * Bridge from the web palette to Material's color roles. The canvas, + * surfaces, and accent roles map 1:1 onto the web tokens; Material's + * container ladder walks the web elevation steps (dialog → surface → + * surfaceHover, then one further 19% step so `surfaceContainerHighest` keeps + * its place in the ladder). Roles the web palette has no opinion about + * (error tones and their containers) keep Material's scheme defaults. + */ +fun ThemePresetPalette.colorScheme(): ColorScheme { + val base = if (isDark) darkColorScheme() else lightColorScheme() + val contrast = if (isDark) Color.White else Color.Black + return base.copy( + primary = accent, + onPrimary = buttonText, + primaryContainer = accent, + onPrimaryContainer = buttonText, + secondary = hint, + onSecondary = background, + secondaryContainer = secondary, + onSecondaryContainer = foreground, + tertiary = hint, + onTertiary = background, + background = background, + onBackground = foreground, + surface = background, + onSurface = foreground, + surfaceVariant = surfaceHover, + onSurfaceVariant = hint, + surfaceContainerLowest = background, + surfaceContainerLow = dialog, + surfaceContainer = surface, + surfaceContainerHigh = surfaceHover, + surfaceContainerHighest = mix(background, contrast, 0.19), + outline = hint, + outlineVariant = border, + inverseSurface = foreground, + inverseOnSurface = background, + inversePrimary = accent, + ) +} + +/** + * Overlays the tokens the web client re-skins per preset onto the base + * extended set: code/quote/table surfaces, dividers, links, and hints. + * Diff surfaces are *not* preset-tinted on the web client either — they + * inherit from [base], along with [HapiExtendedColors.isDark], which keeps + * driving syntax-highlight theme selection. + */ +fun ThemePresetPalette.extendedColors(base: HapiExtendedColors): HapiExtendedColors = base.copy( + codeBackground = code, + codeHeaderBackground = surfaceHover, + codeHeaderForeground = hint, + inlineCodeBackground = code, + inlineCodeForeground = foreground, + blockquoteBackground = surface, + blockquoteBar = accent.copy(alpha = 0.35f), + blockquoteForeground = foreground, + tableBackground = surface, + tableHeaderBackground = surfaceHover, + divider = border, + link = accent, + hint = hint, +) + +/** Card-face preview colors for the preset picker (web `COLOR_THEME_OPTIONS`). */ +@Immutable +data class ThemePresetPreview(val light: Color, val dark: Color, val accent: Color) + +private val DefaultPresetPreview = ThemePresetPreview( + light = Color(0xFFFFFFFF), + dark = Color(0xFF1C1C1E), + accent = Color(0xFF111827), +) + +/** + * Fixed preview colors for the picker cards. Transcribed from the web + * client's option table rather than derived from the seeds — a few accents + * there deliberately differ from the seed values so every card reads well. + */ +fun ColorThemePreset.preview(): ThemePresetPreview = when (this) { + ColorThemePreset.DEFAULT -> DefaultPresetPreview + ColorThemePreset.NOTION -> ThemePresetPreview(Color(0xFFFAFAFA), Color(0xFF191919), Color(0xFF3183D8)) + ColorThemePreset.ONE -> ThemePresetPreview(Color(0xFFFBFBFF), Color(0xFF1F2433), Color(0xFF526FFF)) + ColorThemePreset.PROOF -> ThemePresetPreview(Color(0xFFF8F7F2), Color(0xFF18231F), Color(0xFF2F7D5B)) + ColorThemePreset.RAYCAST -> ThemePresetPreview(Color(0xFFFFFFFF), Color(0xFF201617), Color(0xFFFF5555)) + ColorThemePreset.ROSE_PINE -> ThemePresetPreview(Color(0xFFFFFAF3), Color(0xFF191724), Color(0xFFC65F7B)) + ColorThemePreset.SOLARIZED -> ThemePresetPreview(Color(0xFFFDF6E3), Color(0xFF002B36), Color(0xFFB58900)) + ColorThemePreset.VERCEL -> ThemePresetPreview(Color(0xFFFFFFFF), Color(0xFF000000), Color(0xFF0070F3)) + ColorThemePreset.VS_CODE_PLUS -> ThemePresetPreview(Color(0xFFFFFFFF), Color(0xFF1E1E1E), Color(0xFF007ACC)) + ColorThemePreset.XCODE -> ThemePresetPreview(Color(0xFFF7F9FF), Color(0xFF1F2024), Color(0xFF0A84FF)) + ColorThemePreset.LINEAR -> ThemePresetPreview(Color(0xFFF7F7FB), Color(0xFF08090D), Color(0xFF5E6AD2)) + ColorThemePreset.LOBSTER -> ThemePresetPreview(Color(0xFFFFF7F4), Color(0xFF161821), Color(0xFFFF5B6E)) + ColorThemePreset.MATERIAL -> ThemePresetPreview(Color(0xFFFFFBFE), Color(0xFF1C1B1F), Color(0xFF6750A4)) + ColorThemePreset.MATRIX -> ThemePresetPreview(Color(0xFFF5FFF7), Color(0xFF030806), Color(0xFF00FF66)) + ColorThemePreset.MONOKAI -> ThemePresetPreview(Color(0xFFF8F7EF), Color(0xFF272822), Color(0xFFA6E22E)) + ColorThemePreset.NIGHT_OWL -> ThemePresetPreview(Color(0xFFFBFDFF), Color(0xFF011627), Color(0xFF82AAFF)) + ColorThemePreset.NORD -> ThemePresetPreview(Color(0xFFECEFF4), Color(0xFF2E3440), Color(0xFF88C0D0)) +} + +// --------------------------------------------------------------- color math -- +// Channel math mirrors the web client's `mix`/`relativeLuminance` helpers +// byte for byte, including its rounding: JS `Math.round` resolves exact .5 +// ties upward, and at least one shipped seed hits such a tie (Notion dark +// background #191919 → dialog #252525), so plain banker's rounding would +// visibly drift from the web rendering. + +private fun jsRound(x: Double): Int = floor(x + 0.5).toInt() + +private fun mix(a: Color, b: Color, weight: Double): Color { + val from = a.toArgbChannels() + val to = b.toArgbChannels() + fun channel(x: Int, y: Int): Int = jsRound(x * (1 - weight) + y * weight) + return argb(channel(from[0], to[0]), channel(from[1], to[1]), channel(from[2], to[2])) +} + +private fun argb(r: Int, g: Int, b: Int): Color { + val packed = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + return Color(packed) +} + +private fun Color.toArgbChannels(): IntArray { + val value = toArgb() + return intArrayOf((value shr 16) and 0xFF, (value shr 8) and 0xFF, value and 0xFF) +} + +/** WCAG relative luminance over the 8-bit channels — web parity. */ +internal fun relativeLuminance(color: Color): Double { + fun convert(value: Int): Double { + val next = value / 255.0 + return if (next <= 0.03928) next / 12.92 else ((next + 0.055) / 1.055).pow(2.4) + } + val (r, g, b) = color.toArgbChannels() + return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b) +} diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 300a6e74..c30edb66 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -126,6 +126,25 @@ Цвета Material You Брать цвета приложения из обоев. Недоступно с чёрной OLED. + Цветовая тема + По умолчанию + Notion + One + Proof + Raycast + Rose Pine + Solarized + Vercel + VS Code Plus + Xcode + Linear + Lobster + Material + Matrix + Monokai + Night Owl + Nord + Недоступна при выбранной цветовой теме. Язык Язык приложения diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 69dce03f..84c7110c 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -133,6 +133,25 @@ Material You 配色 根据壁纸生成应用配色。 在 OLED 纯黑下不可用。 + 颜色主题 + 默认 + Notion + One + Proof + Raycast + Rose Pine + Solarized + Vercel + VS Code Plus + Xcode + Linear + Lobster + Material + Matrix + Monokai + Night Owl + Nord + 使用颜色主题时不可用。 语言 应用语言 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cebad046..bc09a324 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -126,6 +126,25 @@ Material You colors Derive app colors from your wallpaper. Not available with OLED black. + Color theme + Default + Notion + One + Proof + Raycast + Rose Pine + Solarized + Vercel + VS Code Plus + Xcode + Linear + Lobster + Material + Matrix + Monokai + Night Owl + Nord + Not available while a color theme is in use. Language App language diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ChoiceDialogTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ChoiceDialogTest.kt index 43ad259c..0704e898 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ChoiceDialogTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ChoiceDialogTest.kt @@ -2,8 +2,11 @@ package app.hapi.companion.feature.settings import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import app.hapi.companion.ui.theme.ColorThemePreset +import app.hapi.companion.ui.theme.preview import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -65,4 +68,30 @@ class ChoiceDialogTest { assertNull(option.swatch) assertEquals(AppLanguage.ENGLISH, option.value) } + + @Test + fun `every color theme preset carries an accent strip`() { + ColorThemePreset.entries.forEach { preset -> + val swatch = colorThemePresetSwatch(preset) + assertNotNull(swatch.accentColor, "${preset.name} card must show its accent") + } + } + + @Test + fun `preset swatches outline halves that would vanish against the dialog`() { + // Every preset's canvases are near-white and near-black by design, so + // each card carries the hairline — the web cards are all bordered too. + ColorThemePreset.entries.forEach { preset -> + assertTrue(colorThemePresetSwatch(preset).outline, preset.name) + } + } + + @Test + fun `preset swatch halves are the preset's own canvases`() { + val nord = colorThemePresetSwatch(ColorThemePreset.NORD) + + assertEquals(ColorThemePreset.NORD.preview().light, nord.startColor) + assertEquals(ColorThemePreset.NORD.preview().dark, nord.endColor) + assertEquals(ColorThemePreset.NORD.preview().accent, nord.accentColor) + } } diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsViewModelTest.kt index 9851aa87..54284129 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/SettingsViewModelTest.kt @@ -3,6 +3,7 @@ package app.hapi.companion.feature.settings import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import app.hapi.companion.ui.theme.ColorThemePreset import app.hapi.protocol.wire.HubHealthResponse import java.io.File import java.util.Base64 @@ -121,4 +122,18 @@ class SettingsViewModelTest { assertFalse(viewModel.themeSettings.value.dynamicColor) assertEquals(AppLanguage.SIMPLIFIED_CHINESE, viewModel.language.value) } + + @Test + fun `color theme preset setter persists through the prefs`() = runWithViewModel( + jwt = null, + health = { HubHealthResponse(status = "ok", protocolVersion = 1) }, + ) { viewModel -> + viewModel.setPreset(ColorThemePreset.NORD) + advanceUntilIdle() + + assertEquals( + ColorThemePreset.NORD, + viewModel.themeSettings.value.preset, + ) + } } diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ThemePrefsTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ThemePrefsTest.kt index 647b9078..7f0aed43 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ThemePrefsTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/settings/ThemePrefsTest.kt @@ -2,6 +2,7 @@ package app.hapi.companion.feature.settings import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.edit +import app.hapi.companion.ui.theme.ColorThemePreset import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -77,15 +78,58 @@ class ThemePrefsTest { dataStore.edit { it[ThemePrefs.MODE_KEY] = "amoled-ultra" it[LanguagePrefs.LANGUAGE_KEY] = "klingon" + it[ThemePrefs.PRESET_KEY] = "windows-95" } assertEquals(ThemeMode.SYSTEM, ThemePrefs(dataStore).settings.first().mode) // Unknown language keys degrade to follow-system (B-M5a default). assertEquals(AppLanguage.SYSTEM, LanguagePrefs(dataStore).language.first()) + // Unknown preset ids degrade to the built-in schemes. + assertEquals( + ColorThemePreset.DEFAULT, + ThemePrefs(dataStore).settings.first().preset, + ) } finally { scope.coroutineContext[Job]?.cancelAndJoin() } } + @Test + fun `color theme preset round-trips and survives a cold start`() = runTest { + val file = storeFile("preset") + + val firstScope = newScope() + try { + val prefs = ThemePrefs(PreferenceDataStoreFactory.create(scope = firstScope) { file }) + prefs.setPreset(ColorThemePreset.NORD) + assertEquals( + ColorThemePreset.NORD, + prefs.settings.first().preset, + ) + // Switching back stores "default" explicitly: "never configured" + // and "deliberately default" persist identically on Android, + // where the key always carries a value. + prefs.setPreset(ColorThemePreset.DEFAULT) + assertEquals( + ColorThemePreset.DEFAULT, + prefs.settings.first().preset, + ) + prefs.setPreset(ColorThemePreset.NORD) + } finally { + firstScope.coroutineContext[Job]?.cancelAndJoin() + } + + val secondScope = newScope() + try { + val prefs = ThemePrefs(PreferenceDataStoreFactory.create(scope = secondScope) { file }) + assertEquals( + ColorThemePreset.NORD, + prefs.settings.first().preset, + ) + } finally { + secondScope.coroutineContext[Job]?.cancelAndJoin() + } + } + @Test fun `language choice round-trips and coexists with theme keys`() = runTest { val scope = newScope() diff --git a/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemePresetsTest.kt b/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemePresetsTest.kt new file mode 100644 index 00000000..fc0d7762 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemePresetsTest.kt @@ -0,0 +1,184 @@ +package app.hapi.companion.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Color theme preset derivation, pinned to the web client's math + * (`web/src/hooks/useColorTheme.ts`): seed transcription, the deterministic + * palette formulas (including JS-style tie rounding), the Material role + * bridge, and the extended-color overlay. Expected hexes were computed by + * replaying the web formulas over the shipped seeds. + */ +class ThemePresetsTest { + + @Test + fun `storage keys parse and unknown values degrade to default`() { + ColorThemePreset.entries.forEach { preset -> + assertEquals(preset, ColorThemePreset.fromStorageKey(preset.storageKey), preset.name) + } + assertEquals(ColorThemePreset.DEFAULT, ColorThemePreset.fromStorageKey("default")) + assertEquals(ColorThemePreset.DEFAULT, ColorThemePreset.fromStorageKey(null)) + assertEquals(ColorThemePreset.DEFAULT, ColorThemePreset.fromStorageKey("windows-95")) + } + + @Test + fun `every named preset carries both seed sets and default carries none`() { + val named = ColorThemePreset.entries.filter { it != ColorThemePreset.DEFAULT }.toSet() + assertEquals(named, themePresetSeeds.keys) + } + + @Test + fun `notion light palette matches the web derivation`() { + val palette = ColorThemePreset.NOTION.palette(dark = false) + + assertEquals(false, palette.isDark) + assertEquals(Color(0xFF3183D8), palette.accent) + assertEquals(Color(0xFFFAFAFA), palette.background) + assertEquals(Color(0xFF37352F), palette.foreground) + assertEquals(Color(0xFFF1F1EF), palette.secondary) + // Elevation mixes shade toward black on light palettes. + assertEquals(Color(0xFFF6F6F6), palette.dialog) + assertEquals(Color(0xFFF1F1F1), palette.surface) + assertEquals(Color(0xFFEAEAEA), palette.surfaceHover) + assertEquals(Color(0xFFEFEFEF), palette.code) + // Light hairlines are slate ink, not pure black. + assertEquals(Color(0xFF0F172A).copy(alpha = 0.10f), palette.border) + assertEquals(Color(0xFF0F172A).copy(alpha = 0.045f), palette.subtle) + // Notion's accent is dark enough that button text stays white. + assertEquals(Color(0xFFFFFFFF), palette.buttonText) + } + + @Test + fun `notion dark palette matches the web derivation including tie rounding`() { + val palette = ColorThemePreset.NOTION.palette(dark = true) + + assertEquals(true, palette.isDark) + // #191919 lifted 5% toward white lands every channel on an exact .5 + // tie; the web client's Math.round resolves ties upward (#252525), + // so the twin must too — not banker's rounding (#242424). + assertEquals(Color(0xFF252525), palette.dialog) + assertEquals(Color(0xFF2E2E2E), palette.surface) + assertEquals(Color(0xFF393939), palette.surfaceHover) + assertEquals(Color(0xFF323232), palette.code) + assertEquals(Color(0xFFFFFFFF).copy(alpha = 0.11f), palette.border) + assertEquals(Color(0xFFFFFFFF).copy(alpha = 0.06f), palette.subtle) + assertEquals(Color(0xFFFFFFFF), palette.buttonText) + } + + @Test + fun `bright accents switch button text to ink`() { + // Material's dark accent #D0BCFF, Nord's #88C0D0 (luminance 0.475, + // just over the 0.45 threshold), and Matrix's #00FF66 all read as + // light enough for ink text; each preset's light accent stays white-text. + assertEquals(Color(0xFF111827), ColorThemePreset.MATERIAL.palette(dark = true).buttonText) + assertEquals(Color(0xFF111827), ColorThemePreset.NORD.palette(dark = true).buttonText) + assertEquals(Color(0xFF111827), ColorThemePreset.MATRIX.palette(dark = true).buttonText) + assertEquals(Color(0xFFFFFFFF), ColorThemePreset.MATERIAL.palette(dark = false).buttonText) + } + + @Test + fun `palette darkness follows the background luminance not the scheme flag`() { + // A hypothetical seed set whose "dark" slot carries a bright canvas + // must derive with light-palette directions — that is how the web + // client picks its mix direction, so a misfiled future preset cannot + // produce inverted elevation steps. + val brightDarkSlot = ThemePresetSeeds( + accent = Color(0xFF3183D8), + background = Color(0xFFFAFAFA), + foreground = Color(0xFF37352F), + hint = Color(0xFF787774), + secondary = Color(0xFFF1F1EF), + ) + val inverted = ThemePresetSeedPair( + light = ThemePresetSeeds(Color(0xFF3183D8), Color(0xFF191919), Color(0xFFD9D9D8), Color(0xFF9B9A97), Color(0xFF252525)), + dark = brightDarkSlot, + ) + + assertEquals(false, inverted.palette(dark = true).isDark) + assertEquals(true, inverted.palette(dark = false).isDark) + } + + @Test + fun `material roles paint from the nord dark palette`() { + val palette = ColorThemePreset.NORD.palette(dark = true) + val scheme = palette.colorScheme() + + assertEquals(Color(0xFF88C0D0), scheme.primary) + assertEquals(Color(0xFF111827), scheme.onPrimary) + assertEquals(Color(0xFF2E3440), scheme.background) + assertEquals(Color(0xFF2E3440), scheme.surface) + assertEquals(Color(0xFFECEFF4), scheme.onSurface) + assertEquals(Color(0xFFD8DEE9), scheme.onSurfaceVariant) + assertEquals(Color(0xFF3B4252), scheme.secondaryContainer) + assertEquals(Color(0xFFECEFF4), scheme.onSecondaryContainer) + // The container ladder walks the web elevation steps. + assertEquals(palette.dialog, scheme.surfaceContainerLow) + assertEquals(palette.surface, scheme.surfaceContainer) + assertEquals(palette.surfaceHover, scheme.surfaceContainerHigh) + assertEquals(Color(0xFF565B64), scheme.surfaceContainerHighest) + assertEquals(palette.border, scheme.outlineVariant) + assertEquals(palette.hint, scheme.outline) + assertEquals(palette.foreground, scheme.inverseSurface) + } + + @Test + fun `container ladder continues one step past the hover tone`() { + // Notion light: #FAFAFA shaded 19% toward black rounds the R/G/B tie + // upward to #CBCBCB, keeping "highest" above the hover step. + val scheme = ColorThemePreset.NOTION.palette(dark = false).colorScheme() + + assertEquals(Color(0xFFCBCBCB), scheme.surfaceContainerHighest) + } + + @Test + fun `extended colors overlay preset tokens and inherit diff tokens from the base`() { + val palette = ColorThemePreset.NORD.palette(dark = true) + val extended = palette.extendedColors(HapiDarkExtendedColors) + + assertEquals(palette.code, extended.codeBackground) + assertEquals(palette.surfaceHover, extended.codeHeaderBackground) + assertEquals(palette.hint, extended.codeHeaderForeground) + assertEquals(palette.code, extended.inlineCodeBackground) + assertEquals(palette.foreground, extended.inlineCodeForeground) + assertEquals(palette.surface, extended.blockquoteBackground) + assertEquals(palette.accent.copy(alpha = 0.35f), extended.blockquoteBar) + assertEquals(palette.surface, extended.tableBackground) + assertEquals(palette.surfaceHover, extended.tableHeaderBackground) + assertEquals(palette.border, extended.divider) + assertEquals(palette.accent, extended.link) + assertEquals(palette.hint, extended.hint) + // Diff surfaces and the syntax-theme flag are not preset-tinted on + // the web client either — they always inherit from the base set. + assertEquals(HapiDarkExtendedColors.diffAddBackground, extended.diffAddBackground) + assertEquals(HapiDarkExtendedColors.diffRemoveBackground, extended.diffRemoveBackground) + assertEquals(HapiDarkExtendedColors.diffHunkBackground, extended.diffHunkBackground) + assertEquals(HapiDarkExtendedColors.isDark, extended.isDark) + } + + @Test + fun `default preset refuses a palette lookup`() { + assertFailsWith { + ColorThemePreset.DEFAULT.palette(dark = false) + } + } + + @Test + fun `preview triples are the web card colors including deliberate accent divergences`() { + // The web option table pins a few accents that differ from the seed + // values so every card reads well; the twin transcribes them as-is. + assertEquals(Color(0xFF111827), ColorThemePreset.DEFAULT.preview().accent) + assertEquals(Color(0xFFFF5555), ColorThemePreset.RAYCAST.preview().accent) + assertEquals(Color(0xFFFF5B6E), ColorThemePreset.LOBSTER.preview().accent) + assertEquals(Color(0xFF526FFF), ColorThemePreset.ONE.preview().accent) + ColorThemePreset.entries.forEach { preset -> + val preview = preset.preview() + assertTrue(preview.light.luminance() > 0.5f, "${preset.name} light half must be light") + assertTrue(preview.dark.luminance() < 0.5f, "${preset.name} dark half must be dark") + } + } +} diff --git a/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemeResolutionOrderTest.kt b/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemeResolutionOrderTest.kt new file mode 100644 index 00000000..e6573249 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/ui/theme/ThemeResolutionOrderTest.kt @@ -0,0 +1,152 @@ +package app.hapi.companion.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The [HapiTheme] resolution order — `preset > oled > dynamic color > static + * light/dark`, with a preset under OLED resolving to its dark seeds — pinned + * by an exhaustive matrix over the settings booleans. This is the Android + * twin of the web client's layering (`data-color-theme` over `data-theme`; + * see `useColorTheme.test.ts` "uses dark palette contrast values for OLED"). + * The order resolves through [resolveThemeSchemeSource] precisely so it can + * be exercised on the JVM: reordering those branches (a stack-conflict + * surgery hazard) or dropping the `|| oled` seed fallback must turn this + * suite red. + */ +class ThemeResolutionOrderTest { + + @Test + fun `a named preset wins over oled, dynamic color, and the static schemes`() { + // All 16 named presets x every darkTheme/oled/dynamicColor/sdk + // combination (256 cases): the preset always takes the scheme. + val named = ColorThemePreset.entries.filter { it != ColorThemePreset.DEFAULT } + for (preset in named) { + for (darkTheme in listOf(false, true)) { + for (oled in listOf(false, true)) { + for (dynamicColor in listOf(false, true)) { + for (supportsDynamic in listOf(false, true)) { + val label = "${preset.name} dark=$darkTheme oled=$oled " + + "dyn=$dynamicColor sdk=$supportsDynamic" + val source = resolveThemeSchemeSource( + preset = preset, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + oled = oled, + supportsDynamic = supportsDynamic, + ) + val resolved = assertIs(source, label) + assertEquals(preset.palette(dark = darkTheme || oled), resolved.palette, label) + } + } + } + } + } + } + + @Test + fun `oled with a light system theme still renders the preset's dark seeds`() { + // If the resolver ever passes `darkTheme` alone (dropping `|| oled`), + // Nord would paint its light canvas here and these concrete pins go + // red — the equality loop above alone would not show *why*. + val source = resolveThemeSchemeSource( + preset = ColorThemePreset.NORD, + darkTheme = false, + dynamicColor = true, + oled = true, + supportsDynamic = true, + ) + val palette = assertIs(source).palette + assertTrue(palette.isDark) + assertEquals(Color(0xFF2E3440), palette.background) + assertEquals(Color(0xFF88C0D0), palette.accent) + } + + @Test + fun `default with oled paints pure black ahead of dynamic color`() { + // OLED never combines with Material You — surfaces would defeat the + // pure-black canvas — regardless of the dark/dynamic flags. + for (darkTheme in listOf(false, true)) { + for (dynamicColor in listOf(false, true)) { + for (supportsDynamic in listOf(false, true)) { + val label = "dark=$darkTheme dyn=$dynamicColor sdk=$supportsDynamic" + val source = resolveThemeSchemeSource( + preset = ColorThemePreset.DEFAULT, + darkTheme = darkTheme, + dynamicColor = dynamicColor, + oled = true, + supportsDynamic = supportsDynamic, + ) + assertEquals(ThemeSchemeSource.Oled, source, label) + } + } + } + } + + @Test + fun `default on android twelve plus follows dynamic color when enabled`() { + assertEquals( + ThemeSchemeSource.Dynamic(dark = true), + resolveThemeSchemeSource( + preset = ColorThemePreset.DEFAULT, + darkTheme = true, + dynamicColor = true, + oled = false, + supportsDynamic = true, + ), + ) + assertEquals( + ThemeSchemeSource.Dynamic(dark = false), + resolveThemeSchemeSource( + preset = ColorThemePreset.DEFAULT, + darkTheme = false, + dynamicColor = true, + oled = false, + supportsDynamic = true, + ), + ) + } + + @Test + fun `dynamic color yields below android twelve even when enabled`() { + // Pre-S devices have no Material You to ask for; the enabled flag + // must not matter and the static schemes take over. + for (darkTheme in listOf(false, true)) { + val source = resolveThemeSchemeSource( + preset = ColorThemePreset.DEFAULT, + darkTheme = darkTheme, + dynamicColor = true, + oled = false, + supportsDynamic = false, + ) + assertEquals( + if (darkTheme) ThemeSchemeSource.StaticDark else ThemeSchemeSource.StaticLight, + source, + "dark=$darkTheme", + ) + } + } + + @Test + fun `default without dynamic color falls back to the static schemes`() { + for (darkTheme in listOf(false, true)) { + for (supportsDynamic in listOf(false, true)) { + val source = resolveThemeSchemeSource( + preset = ColorThemePreset.DEFAULT, + darkTheme = darkTheme, + dynamicColor = false, + oled = false, + supportsDynamic = supportsDynamic, + ) + assertEquals( + if (darkTheme) ThemeSchemeSource.StaticDark else ThemeSchemeSource.StaticLight, + source, + "dark=$darkTheme sdk=$supportsDynamic", + ) + } + } + } +}