Merge pull request 'feat(identity): pure-black (AMOLED) dark theme' (#13) from feat/pure-black-theme into main

Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/13
This commit is contained in:
Jean-Luc Makiola
2026-10-14 17:39:45 +02:00
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()
}
}