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) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-08 15:39:28 +02:00
parent e4f445ac75
commit 4fe8cd12cb
4 changed files with 121 additions and 0 deletions

View File

@@ -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),
)

View File

@@ -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,
)
}

View File

@@ -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()

View File

@@ -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)
}
}