feat(android): offer web-parity color theme presets in appearance settings (#2228)

* feat(android): offer web-parity color theme presets in appearance settings

Bring the web client's color-theme catalog (Default plus 16 named
palettes) to Android as an appearance choice. Each preset carries five
seed colors per scheme and derives every other tone with the web
client's exact math — per-channel elevation mixes, the WCAG-luminance
dark/light call, and tie-up rounding — so a preset renders the same on
both clients. A selected preset overrides the dynamic and OLED schemes
(OLED renders the preset's dark seeds, like the web layering), while
Default keeps Material You and the built-in light/dark/OLED schemes.

The picker reuses the expressive choice dialog: every card shows the
preset's light/dark canvas split plus its accent strip, and longer
catalogs scroll inside the dialog so they stay usable on small screens.
Material You switches off with an explanatory note while a preset is
active, since presets pin their own palettes.

Persistence is one DataStore key over the existing theme prefs, with
unknown ids degrading to Default. Strings land in en/ru/zh in matching
order; the proper-noun preset names stay untranslated, matching the web
locales.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* test(android): pin the HapiTheme scheme resolution order with a matrix

The preset > oled > dynamic > static ordering and the OLED-yields-to-dark-
seeds rule (preset.palette(dark = darkTheme || oled)) lived inline in the
HapiTheme composable where no JVM test could reach them -- reordering the
when (a stack-conflict surgery hazard) would pass every gate silently.

Extract the order into a pure resolver (resolveThemeSchemeSource returns a
descriptor; the composable maps it to schemes, with a local SDK guard lint
can see) and pin it with an exhaustive matrix: all 16 named presets x every
darkTheme/oled/dynamicColor/sdk combination resolve to the preset palette,
DEFAULT+oled beats dynamic color, dynamic color only fires on S+ when
enabled, and Nord-under-OLED-with-light-system asserts concrete dark-seed
hexes. Verified discriminating by mutation: dropping `|| oled` fails 2
tests, hoisting dynamic above oled fails 1.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
admin
2026-11-02 18:24:46 +08:00
committed by GitHub
co-authored by Claude Code HAPI
parent 3fc15e92cf
commit b4fcddee2f
16 changed files with 1140 additions and 38 deletions
@@ -87,6 +87,7 @@ class MainActivity : AppCompatActivity() {
},
dynamicColor = theme.dynamicColor,
oled = theme.mode == ThemeMode.OLED,
preset = theme.preset,
) {
CompositionLocalProvider(LocalAppGraph provides appGraph) {
HapiNavigation()
@@ -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<T>(
* 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 <T> ChoiceDialog(
@@ -106,7 +136,10 @@ internal fun <T> 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 <T> 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),
)
}
}
}
@@ -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() {
@@ -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
@@ -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) }
}
@@ -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<Preferences>) {
@@ -51,6 +61,7 @@ class ThemePrefs(private val dataStore: DataStore<Preferences>) {
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<Preferences>) {
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<String> = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR_KEY: Preferences.Key<Boolean> = booleanPreferencesKey("theme_dynamic_color")
val PRESET_KEY: Preferences.Key<String> = stringPreferencesKey("theme_preset")
}
}
@@ -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(
@@ -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<ColorThemePreset, ThemePresetSeedPair> = 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)
}
@@ -126,6 +126,25 @@
<string name="settings_dynamic_color">Цвета Material You</string>
<string name="settings_dynamic_color_description">Брать цвета приложения из обоев.</string>
<string name="settings_dynamic_color_oled_note">Недоступно с чёрной OLED.</string>
<string name="settings_color_theme">Цветовая тема</string>
<string name="settings_color_theme_default">По умолчанию</string>
<string name="settings_color_theme_notion">Notion</string>
<string name="settings_color_theme_one">One</string>
<string name="settings_color_theme_proof">Proof</string>
<string name="settings_color_theme_raycast">Raycast</string>
<string name="settings_color_theme_rose_pine">Rose Pine</string>
<string name="settings_color_theme_solarized">Solarized</string>
<string name="settings_color_theme_vercel">Vercel</string>
<string name="settings_color_theme_vs_code_plus">VS Code Plus</string>
<string name="settings_color_theme_xcode">Xcode</string>
<string name="settings_color_theme_linear">Linear</string>
<string name="settings_color_theme_lobster">Lobster</string>
<string name="settings_color_theme_material">Material</string>
<string name="settings_color_theme_matrix">Matrix</string>
<string name="settings_color_theme_monokai">Monokai</string>
<string name="settings_color_theme_night_owl">Night Owl</string>
<string name="settings_color_theme_nord">Nord</string>
<string name="settings_dynamic_color_preset_note">Недоступна при выбранной цветовой теме.</string>
<string name="settings_section_language">Язык</string>
<string name="settings_language">Язык приложения</string>
@@ -133,6 +133,25 @@
<string name="settings_dynamic_color">Material You 配色</string>
<string name="settings_dynamic_color_description">根据壁纸生成应用配色。</string>
<string name="settings_dynamic_color_oled_note">在 OLED 纯黑下不可用。</string>
<string name="settings_color_theme">颜色主题</string>
<string name="settings_color_theme_default">默认</string>
<string name="settings_color_theme_notion">Notion</string>
<string name="settings_color_theme_one">One</string>
<string name="settings_color_theme_proof">Proof</string>
<string name="settings_color_theme_raycast">Raycast</string>
<string name="settings_color_theme_rose_pine">Rose Pine</string>
<string name="settings_color_theme_solarized">Solarized</string>
<string name="settings_color_theme_vercel">Vercel</string>
<string name="settings_color_theme_vs_code_plus">VS Code Plus</string>
<string name="settings_color_theme_xcode">Xcode</string>
<string name="settings_color_theme_linear">Linear</string>
<string name="settings_color_theme_lobster">Lobster</string>
<string name="settings_color_theme_material">Material</string>
<string name="settings_color_theme_matrix">Matrix</string>
<string name="settings_color_theme_monokai">Monokai</string>
<string name="settings_color_theme_night_owl">Night Owl</string>
<string name="settings_color_theme_nord">Nord</string>
<string name="settings_dynamic_color_preset_note">使用颜色主题时不可用。</string>
<string name="settings_section_language">语言</string>
<string name="settings_language">应用语言</string>
@@ -126,6 +126,25 @@
<string name="settings_dynamic_color">Material You colors</string>
<string name="settings_dynamic_color_description">Derive app colors from your wallpaper.</string>
<string name="settings_dynamic_color_oled_note">Not available with OLED black.</string>
<string name="settings_color_theme">Color theme</string>
<string name="settings_color_theme_default">Default</string>
<string name="settings_color_theme_notion">Notion</string>
<string name="settings_color_theme_one">One</string>
<string name="settings_color_theme_proof">Proof</string>
<string name="settings_color_theme_raycast">Raycast</string>
<string name="settings_color_theme_rose_pine">Rose Pine</string>
<string name="settings_color_theme_solarized">Solarized</string>
<string name="settings_color_theme_vercel">Vercel</string>
<string name="settings_color_theme_vs_code_plus">VS Code Plus</string>
<string name="settings_color_theme_xcode">Xcode</string>
<string name="settings_color_theme_linear">Linear</string>
<string name="settings_color_theme_lobster">Lobster</string>
<string name="settings_color_theme_material">Material</string>
<string name="settings_color_theme_matrix">Matrix</string>
<string name="settings_color_theme_monokai">Monokai</string>
<string name="settings_color_theme_night_owl">Night Owl</string>
<string name="settings_color_theme_nord">Nord</string>
<string name="settings_dynamic_color_preset_note">Not available while a color theme is in use.</string>
<string name="settings_section_language">Language</string>
<string name="settings_language">App language</string>
@@ -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)
}
}
@@ -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,
)
}
}
@@ -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()
@@ -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<IllegalStateException> {
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")
}
}
}
@@ -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<ThemeSchemeSource.Preset>(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<ThemeSchemeSource.Preset>(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",
)
}
}
}
}