feat(identity): pure-black (AMOLED) dark theme

ColorScheme.toPureBlack() sets background, surface, surfaceDim and
surfaceContainerLowest to #000 and dims the other containers by scaling
their sRGB channels, so their hue tint carries over and grid tiles drawn
as containers over surface keep visible gaps. FloretExpressiveTheme takes
pureBlack to apply it to the dark scheme, dynamic or app-supplied.

core-prefs gains ThemeMode.BLACK (dark, pure black) and
ThemeMode.isPureBlack, stored under the same theme_mode key.
This commit is contained in:
2026-10-14 17:19:31 +02:00
parent 0b44d353d2
commit f1ded81c03
7 changed files with 164 additions and 4 deletions
+11
View File
@@ -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
@@ -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,
@@ -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()
+12
View File
@@ -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)
}
@@ -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
@@ -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(
@@ -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()
}
}