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:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user