mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
co-authored by
Claude Code
HAPI
parent
3fc15e92cf
commit
b4fcddee2f
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user