diff --git a/CHANGELOG.md b/CHANGELOG.md index 8894c4a..d220c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ are no binary releases; "version" tracks the shared `version` in the root build ### Added +- **`identity`** — a pure-black (AMOLED) dark theme. `ColorScheme.toPureBlack()` + sets `background`, `surface`, `surfaceDim` and `surfaceContainerLowest` to + #000 and pulls the other containers down towards black, keeping their hue + tint, so grid tiles drawn as containers over `surface` still show their + gaps. `FloretExpressiveTheme` gains `pureBlack` (default `false`), which + applies it to the dark scheme, dynamic or app-supplied. +- **`core-prefs`** — `ThemeMode.BLACK`, dark with pure-black surfaces. + `isDark` treats it as dark, and the new `ThemeMode.isPureBlack` is what an + app passes to the theme's `pureBlack`. It's stored as `"BLACK"` under the + same `theme_mode` key; a build without it reads that as `SYSTEM`. + - **`glance`** (new module) — the family's shared home-screen-widget plumbing and M3 Expressive widget vocabulary, built on Jetpack Glance: `FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt index dcb0c22..19a3fc5 100644 --- a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt @@ -4,16 +4,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -/** Light/dark override. [SYSTEM] follows the device setting. */ -enum class ThemeMode { SYSTEM, LIGHT, DARK } +/** + * Light/dark override. [SYSTEM] follows the device setting; [BLACK] is dark + * with pure-black surfaces (the AMOLED option). + */ +enum class ThemeMode { SYSTEM, LIGHT, DARK, BLACK } /** Resolve to a concrete dark flag; [SYSTEM] defers to [systemDark]. */ fun ThemeMode.isDark(systemDark: Boolean): Boolean = when (this) { ThemeMode.SYSTEM -> systemDark ThemeMode.LIGHT -> false - ThemeMode.DARK -> true + ThemeMode.DARK, ThemeMode.BLACK -> true } +/** Whether the theme should draw pure-black surfaces; only [ThemeMode.BLACK] does. */ +val ThemeMode.isPureBlack: Boolean get() = this == ThemeMode.BLACK + /** The two appearance choices every app in the family has. */ data class Appearance( val themeMode: ThemeMode = ThemeMode.SYSTEM, diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt index d42d47e..32155d9 100644 --- a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt @@ -42,6 +42,24 @@ class ThemeModeTest { assertThat(dark).isTrue() } + @Test + fun `isDark lets BLACK override a light system`() { + val mode = ThemeMode.BLACK + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isTrue() + } + + @Test + fun `isPureBlack is set only for BLACK`() { + val modes = ThemeMode.entries + + val pureBlack = modes.filter { it.isPureBlack } + + assertThat(pureBlack).containsExactly(ThemeMode.BLACK) + } + @Test fun `Appearance defaults to following the system with dynamic colour on`() { val appearance = Appearance() diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index a9fa649..26740f5 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -23,6 +23,13 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } } kotlin { @@ -39,4 +46,9 @@ dependencies { implementation(libs.androidx.animation) // activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek). implementation(libs.androidx.activity.compose) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) } diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt new file mode 100644 index 0000000..a99aa1b --- /dev/null +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/PureBlack.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color + +/** + * The pure-black (AMOLED) take on a dark [ColorScheme]: the ground — `background`, + * `surface`, `surfaceDim`, `surfaceContainerLowest` — goes to #000, and the + * container ladder is pulled down towards black rather than flattened onto it. + * Apps draw grids as container tiles over a `surface` ground, so the tiles keep + * a small lift and the gaps between them still read as lines; sheets and menus + * on the upper containers still separate from the page. + * + * Each container keeps a fixed share of its own sRGB channels, so the scheme's + * hue tint — a dynamic wallpaper colour or an app's seed — carries + * over. Accent, outline and `on*` roles are untouched. Meant for a dark scheme + * only; on a light one it would just darken the surfaces. + */ +fun ColorScheme.toPureBlack(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceDim = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = surfaceContainerLow.dimmed(PURE_BLACK_LOW), + surfaceContainer = surfaceContainer.dimmed(PURE_BLACK_CONTAINER), + surfaceContainerHigh = surfaceContainerHigh.dimmed(PURE_BLACK_HIGH), + surfaceContainerHighest = surfaceContainerHighest.dimmed(PURE_BLACK_HIGHEST), +) + +/** + * This colour with each sRGB channel scaled by [share], keeping its hue. Plain + * channel scaling rather than `lerp`, which blends in Oklab and lands darker. + */ +private fun Color.dimmed(share: Float): Color = + Color(red = red * share, green = green * share, blue = blue * share, alpha = alpha) + +// Shares of each container's own sRGB channels kept in pure black. Against the +// stock dark tones (#1D1B20 / #211F26 / #2B2930 / #36343B) they land near +// #131215 / #1A191E / #252329 / #302F35. +private const val PURE_BLACK_LOW = 0.65f +private const val PURE_BLACK_CONTAINER = 0.80f +private const val PURE_BLACK_HIGH = 0.85f +private const val PURE_BLACK_HIGHEST = 0.90f diff --git a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt index bce727d..6574513 100644 --- a/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt +++ b/identity/src/main/kotlin/de/jeanlucmakiola/floret/identity/Theme.kt @@ -34,6 +34,9 @@ val LocalFloretDarkTheme = staticCompositionLocalOf { false } * passing its own hand-tuned, seed-derived [lightScheme] / [darkScheme] and an * optional [typography]. A thin per-app wrapper (e.g. `AgendulaTheme`) supplies * those and forwards [darkTheme] / [dynamicColor] from settings. + * + * [pureBlack] swaps the dark scheme — dynamic or app-supplied — for its + * [toPureBlack] take, the AMOLED option. It has no effect on a light theme. */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -42,6 +45,7 @@ fun FloretExpressiveTheme( darkScheme: ColorScheme, darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, + pureBlack: Boolean = false, typography: Typography = Typography(), content: @Composable () -> Unit, ) { @@ -52,7 +56,7 @@ fun FloretExpressiveTheme( } darkTheme -> darkScheme else -> lightScheme - } + }.let { if (darkTheme && pureBlack) it.toPureBlack() else it } CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) { MaterialExpressiveTheme( diff --git a/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt new file mode 100644 index 0000000..de62607 --- /dev/null +++ b/identity/src/test/kotlin/de/jeanlucmakiola/floret/identity/PureBlackTest.kt @@ -0,0 +1,66 @@ +package de.jeanlucmakiola.floret.identity + +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The pure-black take on a dark scheme: a black ground, a dimmed container ladder. */ +class PureBlackTest { + + private val dark = darkColorScheme() + + @Test + fun `toPureBlack sets the ground roles to black`() { + val black = dark.toPureBlack() + + val ground = listOf(black.background, black.surface, black.surfaceDim, black.surfaceContainerLowest) + + assertThat(ground).containsExactly(Color.Black, Color.Black, Color.Black, Color.Black) + } + + @Test + fun `toPureBlack keeps the containers above black and in order`() { + val black = dark.toPureBlack() + + val ladder = listOf( + black.surfaceContainerLow, + black.surfaceContainer, + black.surfaceContainerHigh, + black.surfaceContainerHighest, + ).map { it.red + it.green + it.blue } + + assertThat(ladder.first()).isGreaterThan(0f) + assertThat(ladder).isInStrictOrder() + } + + @Test + fun `toPureBlack dims each container below its stock tone`() { + val black = dark.toPureBlack() + + val dimmed = listOf( + black.surfaceContainer.red < dark.surfaceContainer.red, + black.surfaceContainerHighest.red < dark.surfaceContainerHighest.red, + ) + + assertThat(dimmed).containsExactly(true, true) + } + + @Test + fun `toPureBlack scales a container's channels linearly`() { + val black = dark.toPureBlack() + + val ratio = black.surfaceContainer.red / dark.surfaceContainer.red + + assertThat(ratio).isWithin(0.02f).of(0.80f) + } + + @Test + fun `toPureBlack leaves accent and content roles alone`() { + val black = dark.toPureBlack() + + val kept = listOf(black.primary, black.onSurface, black.outline) + + assertThat(kept).containsExactly(dark.primary, dark.onSurface, dark.outline).inOrder() + } +}