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