From 4fe8cd12cb2dd0a44f91a6c4d209f767ba63f23e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 8 Jun 2026 15:39:28 +0200 Subject: [PATCH] feat: M3 Expressive theme with dynamic color + fallback scheme from slate seed CalendulaSeed (0xFF5C6B7A) anchors the design palette. Theme picks Dynamic Color on API 31+ when enabled (default true) and falls back to a hand-tuned Light/Dark scheme otherwise. CalendulaTypography is the M3 Expressive default for V1 - custom type scale lands in a later UI-design iteration. ColorSchemeTest pins the seed value (3 unit tests, all pass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../calendula/ui/theme/Color.kt | 43 +++++++++++++++++++ .../calendula/ui/theme/Theme.kt | 40 +++++++++++++++++ .../jeanlucmakiola/calendula/ui/theme/Type.kt | 10 +++++ .../calendula/ui/theme/ColorSchemeTest.kt | 28 ++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt new file mode 100644 index 0000000..36bf67c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Color.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +/** + * Seed color anchoring the entire palette. See spec section 12. + * Desaturated slate blue-gray; distinct from HouseHoldKeaper's sage. + */ +val CalendulaSeed: Color = Color(0xFF5C6B7A) + +/** + * Fallback light scheme used on devices that don't support dynamic color + * (API < 31) or when the user disables it. The real palette is derived by + * Material's tonal-palette generator from CalendulaSeed at runtime; these + * constants are a hand-picked subset that approximates that result. + */ +val CalendulaLightFallback = lightColorScheme( + primary = Color(0xFF3B5364), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFCBE6FA), + onPrimaryContainer = Color(0xFF001E2E), + secondary = Color(0xFF526070), + onSecondary = Color(0xFFFFFFFF), + background = Color(0xFFFBFCFE), + onBackground = Color(0xFF191C1F), + surface = Color(0xFFFBFCFE), + onSurface = Color(0xFF191C1F), +) + +val CalendulaDarkFallback = darkColorScheme( + primary = Color(0xFFA3CBE2), + onPrimary = Color(0xFF003348), + primaryContainer = Color(0xFF21495F), + onPrimaryContainer = Color(0xFFCBE6FA), + secondary = Color(0xFFB9C8DA), + onSecondary = Color(0xFF243240), + background = Color(0xFF101316), + onBackground = Color(0xFFE1E3E6), + surface = Color(0xFF101316), + onSurface = Color(0xFFE1E3E6), +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt new file mode 100644 index 0000000..f5ae816 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * App theme. Honors: + * - System light/dark. + * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme + * derived from [CalendulaSeed]. + * + * The Settings screen (later) can override useDynamicColor and themePreference, + * but the V1 foundation just follows the system. + */ +@Composable +fun CalendulaTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val ctx = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) + } + darkTheme -> CalendulaDarkFallback + else -> CalendulaLightFallback + } + + MaterialTheme( + colorScheme = colorScheme, + typography = CalendulaTypography, + content = content, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt new file mode 100644 index 0000000..239374b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt @@ -0,0 +1,10 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import androidx.compose.material3.Typography + +/** + * Default Material 3 Expressive typography. Custom font + tuned scale will + * land in a later UI-design iteration; the defaults are intentional for V1 + * scaffolding to keep the foundation lean. + */ +val CalendulaTypography = Typography() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt new file mode 100644 index 0000000..37aae49 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/ColorSchemeTest.kt @@ -0,0 +1,28 @@ +package de.jeanlucmakiola.calendula.ui.theme + +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ColorSchemeTest { + + @Test + fun `seed color matches design spec slate`() { + // The seed color must remain stable - the design is anchored to it. + // Change this only if the spec is updated. + assertThat(CalendulaSeed).isEqualTo(Color(0xFF5C6B7A)) + } + + @Test + fun `light fallback scheme uses seed as primary derivation source`() { + val scheme = CalendulaLightFallback + // Primary should be a recognizable derivative of the seed (not neutral gray) + assertThat(scheme.primary).isNotEqualTo(Color.Black) + assertThat(scheme.primary).isNotEqualTo(Color.White) + } + + @Test + fun `dark fallback scheme differs from light`() { + assertThat(CalendulaDarkFallback.background).isNotEqualTo(CalendulaLightFallback.background) + } +}