diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt index 0d8559cc..2f20eef1 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt @@ -1,26 +1,79 @@ package press.mantra.compose.ui.theme +import android.app.UiModeManager +import android.content.Context import android.os.Build import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +/** + * Android 14 (API 34) added a three-step contrast setting under Accessibility > Display. + * `UiModeManager.getContrast()` reports it as a float, and the platform documents the + * three positions as 0.0, 0.5 and 1.0. Values between are treated as the nearer step + * rather than rejected -- the API returns a float, so a future finer-grained slider + * should degrade to the closest scheme this app has rather than to Standard. + * + * Below API 34 there is nothing to read and the answer is [ThemeContrast.Standard]. The + * app's minSdk is 26. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } +actual fun platformThemeContrast(): ThemeContrast { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return ThemeContrast.Standard - darkTheme -> darkScheme - else -> lightScheme + val context = LocalContext.current + val uiModeManager = remember(context) { + context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + } ?: return ThemeContrast.Standard + + var contrast by remember(uiModeManager) { + mutableStateOf(contrastStepFor(uiModeManager.contrast)) } -} \ No newline at end of file + + // The setting can be changed while the app is in the foreground, and unlike a theme + // or locale change it does not restart the activity or arrive as a Configuration + // update -- so without this listener the new value would only take effect on the + // next cold start, which is the case the setting exists for. + DisposableEffect(uiModeManager, context) { + val listener = UiModeManager.ContrastChangeListener { value -> + contrast = contrastStepFor(value) + } + // context.mainExecutor rather than ContextCompat.getMainExecutor: it needs API 28 + // and this whole branch is already gated on 34, and it keeps androidx.core off + // this file's imports -- composeApp does not declare it, it only arrives + // transitively through activity-compose. + uiModeManager.addContrastChangeListener(context.mainExecutor, listener) + onDispose { uiModeManager.removeContrastChangeListener(listener) } + } + + return contrast +} + +/** Nearest of the platform's three documented positions. */ +private fun contrastStepFor(value: Float): ThemeContrast = when { + value < 0.25f -> ThemeContrast.Standard + value < 0.75f -> ThemeContrast.Medium + else -> ThemeContrast.High +} + +/** + * Material You, from Android 12 (API 31). + * + * No contrast argument is needed. From API 34 the `android.R.color.system_*` resources + * these are built from shift with the contrast setting themselves, so a dynamic scheme + * already carries it; passing this app's own contrast on top would apply it twice. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? { + if (!dynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + + val context = LocalContext.current + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index 6c4816ae..b3e5c885 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -384,19 +384,59 @@ val unspecified_scheme = ColorFamily( Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified ) +/** + * How much contrast the person using the device has asked for. + * + * Not a preference this app invents -- every platform that has the setting owns it, and + * `platformThemeContrast` reports it. M3's accessibility foundation puts *honour + * individuals* first: "supporting varying preferences and choices that allow individuals + * to address how their changing conditions, individual knowledge, and varying needs are + * met." The four schemes to answer with were already written out in Color.kt and were, + * until this type existed, unreachable. + */ +enum class ThemeContrast { + /** The app's default schemes. */ + Standard, + + /** Android's 0.5 contrast step. No iOS or desktop equivalent, so never reported there. */ + Medium, + + /** Android's 1.0 step; iOS "Increase Contrast"; Windows high contrast mode. */ + High, +} + +/** + * The scheme to use, before dynamic colour gets a say. + * + * Kept in common code rather than behind the platform boundary so that all six schemes + * are selected from one table. The platform actuals answer two narrow questions instead + * -- what contrast was asked for, and whether there is a wallpaper palette to prefer -- + * which is the part that genuinely differs. + */ +internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorScheme = + when (contrast) { + ThemeContrast.Standard -> if (darkTheme) darkScheme else lightScheme + ThemeContrast.Medium -> + if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ThemeContrast.High -> + if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + } + @Composable fun TorchTheme( darkTheme: Boolean = isSystemInDarkTheme(), + contrast: ThemeContrast = platformThemeContrast(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable() () -> Unit + content: @Composable () -> Unit ) { - val colorScheme = themeColorScheme( - darkTheme, - dynamicColor, - darkScheme, - lightScheme - ) + // Dynamic colour wins when the platform offers it, because it is the user's own + // choice and already carries their contrast setting -- on Android 14+ the + // `system_*` palette resources shift with it, so `dynamicLightColorScheme` needs + // no help from `contrast`. Everywhere else the app's schemes answer, and that is + // where `contrast` decides which of the six. + val colorScheme = dynamicColorScheme(darkTheme, dynamicColor) + ?: appColorScheme(darkTheme, contrast) MaterialTheme( colorScheme = colorScheme, @@ -405,10 +445,21 @@ fun TorchTheme( ) } +/** + * The platform's contrast setting, recomposing when it changes. + * + * Reading it once at startup would be a worse version of honouring it: someone who turns + * contrast up because they cannot read the screen in front of them should not have to + * find out that the app needs restarting. + */ @Composable -expect fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme +expect fun platformThemeContrast(): ThemeContrast + +/** + * A wallpaper-derived palette, or `null` where the platform has none. + * + * Returns `null` rather than falling back internally so that the choice of app scheme -- + * which now depends on contrast as well as darkness -- stays in one place. + */ +@Composable +expect fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt new file mode 100644 index 00000000..900cac4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt @@ -0,0 +1,83 @@ +package press.mantra.compose.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +/** + * The six-way table `appColorScheme` picks from. + * + * Worth a test because the failure mode is silent and specific: a scheme wired to the + * wrong cell still renders a complete, plausible UI. Somebody who turns contrast up and + * gets the medium scheme back has no way to tell it apart from a high-contrast scheme + * that is simply not very high, and `Color.kt`'s six schemes are 468 near-identical + * lines in which a transposed row reads as a typo you would have to be looking for. + * + * Until this table existed, four of the six schemes were unreachable -- declared in full + * and never selected. + */ +class AppColorSchemeSelectionTest { + + @Test + fun `each darkness and contrast pair selects its own scheme`() { + assertSame(lightScheme, appColorScheme(darkTheme = false, contrast = ThemeContrast.Standard)) + assertSame(darkScheme, appColorScheme(darkTheme = true, contrast = ThemeContrast.Standard)) + + assertSame( + mediumContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.Medium), + ) + assertSame( + mediumContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.Medium), + ) + + assertSame( + highContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.High), + ) + assertSame( + highContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.High), + ) + } + + @Test + fun `all six schemes are distinct`() { + // A copy-paste that left two cells pointing at the same object would satisfy the + // table test above only if it also mislabelled one, so this catches the other + // half: six declarations that are not six schemes. + val schemes = ThemeContrast.entries.flatMap { contrast -> + listOf(false, true).map { dark -> "$contrast/${if (dark) "dark" else "light"}" to appColorScheme(dark, contrast) } + } + + schemes.forEachIndexed { i, (nameA, a) -> + schemes.drop(i + 1).forEach { (nameB, b) -> + assertNotEquals(a, b, "$nameA and $nameB are the same scheme") + } + } + } + + @Test + fun `raising contrast never lowers the contrast of body text`() { + // The one direction that must hold. Individual tonal surfaces legitimately move + // the other way -- see ColorSchemeContrastTest for why a full monotonicity + // assertion is wrong -- but onSurface against surface is the pair the setting + // exists for, and it going backwards would be indefensible. + listOf(false, true).forEach { dark -> + val standard = appColorScheme(dark, ThemeContrast.Standard) + val medium = appColorScheme(dark, ThemeContrast.Medium) + val high = appColorScheme(dark, ThemeContrast.High) + + val ratios = listOf(standard, medium, high).map { contrastRatio(it.surface, it.onSurface) } + val theme = if (dark) "dark" else "light" + + assertEquals( + ratios.sorted(), + ratios, + "$theme: onSurface on surface does not rise with contrast — $ratios", + ) + } + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt index 1760ce45..edd9e0d8 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt @@ -2,16 +2,55 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import platform.UIKit.UIAccessibilityDarkerSystemColorsEnabled +import platform.UIKit.UIAccessibilityDarkerSystemColorsStatusDidChangeNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +/** + * iOS Settings > Accessibility > Display & Text Size > Increase Contrast. + * + * A boolean, not a three-step slider, so this answers [ThemeContrast.High] or + * [ThemeContrast.Standard] and never [ThemeContrast.Medium]. Apple's own contrast work is + * mostly done inside its system colours rather than exposed as a level; the one bit it + * does expose is `UIAccessibilityDarkerSystemColorsEnabled`. + * + * **Not compiled on this machine.** The ios targets are declared only on macos (see + * docs/jvm-target.md for why the composite build forces that), so this file has been + * written against the UIKit and Foundation bindings rather than checked by a compiler. + * The Android and jvm actuals of the same two functions are compiled and tested. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - darkTheme -> darkScheme - else -> lightScheme +actual fun platformThemeContrast(): ThemeContrast { + var contrast by remember { mutableStateOf(currentContrast()) } + + // The switch can be flipped while the app is foregrounded, and iOS announces it + // rather than restarting anything -- so without this observer the new setting would + // wait for the next cold start. + DisposableEffect(Unit) { + val observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { _ -> contrast = currentContrast() } + + onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) } } -} \ No newline at end of file + + return contrast +} + +private fun currentContrast(): ThemeContrast = + if (UIAccessibilityDarkerSystemColorsEnabled()) ThemeContrast.High else ThemeContrast.Standard + +/** + * Always `null`: dynamic colour means Material You, an android wallpaper-derived palette + * with no iOS counterpart. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt index c5d5e191..db5010e2 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt @@ -2,16 +2,63 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import java.awt.Toolkit /** - * `dynamicColor` is ignored: it means Material You, which reads a wallpaper-derived palette - * from the android system and has no desktop counterpart. The app's own schemes are used - * whatever the caller asks for. + * Desktop has no portable contrast setting, and this reads the one platform that exposes + * a usable signal through AWT. + * + * Windows publishes its high contrast mode as the `win.highContrast.on` desktop property, + * which AWT surfaces on that platform and reports as `null` everywhere else. There is no + * medium step -- Windows high contrast is a boolean -- so this answers + * [ThemeContrast.High] or [ThemeContrast.Standard] and never [ThemeContrast.Medium]. + * + * On linux and macos the answer is always [ThemeContrast.Standard]. macos does have + * "Increase contrast" and linux desktops have their own equivalents, but neither reaches + * AWT, and reading them means a native call per platform. **Until the app has a settings + * screen this is simply unhonoured there**, which is worth knowing rather than papering + * over: the setting should ultimately be a preference the user can override anyway, + * since a desktop app cannot always see what the desktop was told. */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme = if (darkTheme) darkScheme else lightScheme +actual fun platformThemeContrast(): ThemeContrast { + val toolkit = remember { runCatching { Toolkit.getDefaultToolkit() }.getOrNull() } + ?: return ThemeContrast.Standard + + var contrast by remember(toolkit) { mutableStateOf(windowsHighContrast(toolkit)) } + + // Windows fires a property change when the user toggles high contrast, so the app + // does not need restarting. The listener is a no-op on platforms that never publish + // the property. + DisposableEffect(toolkit) { + val listener = java.beans.PropertyChangeListener { + contrast = windowsHighContrast(toolkit) + } + toolkit.addPropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) + onDispose { toolkit.removePropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) } + } + + return contrast +} + +private const val HIGH_CONTRAST_PROPERTY = "win.highContrast.on" + +private fun windowsHighContrast(toolkit: Toolkit): ThemeContrast = + if (toolkit.getDesktopProperty(HIGH_CONTRAST_PROPERTY) == true) { + ThemeContrast.High + } else { + ThemeContrast.Standard + } + +/** + * Always `null`: dynamic colour means Material You, which reads a wallpaper-derived + * palette from the android system and has no desktop counterpart. The app's own schemes + * answer whatever the caller asks for. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md index 9d53c1e1..48626aee 100644 --- a/docs/material-design-conformance.md +++ b/docs/material-design-conformance.md @@ -418,12 +418,26 @@ find-and-replace. Keep the existing hex values for the roles already defined so nothing shifts visually; this phase adds, it does not restyle. -2. **Reach the contrast schemes.** `TorchTheme` grows a contrast parameter and - the platform actuals report it — Android from `UiModeManager.getContrast()` - (API 34+, a float where `0f`/`0.33f`/`0.66f` map onto the three schemes), - falling back to the default scheme on the app's minSdk of 26; iOS from - `UIAccessibilityDarkerSystemColorsEnabled`; desktop from a preference. The - four schemes already written stop being dead code. +2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast` + defaulted from a new `platformThemeContrast()` expect/actual, and the six-way + selection table lives in common code as `appColorScheme`. The old + `themeColorScheme` expect took four arguments and did both jobs; it splits into + `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow + question, so the scheme table is in one place rather than three. + + Android reads `UiModeManager.getContrast()` (API 34+) and registers a + `ContrastChangeListener`, because a contrast change does not restart the + activity or arrive as a `Configuration` update — without the listener the new + setting would wait for the next cold start, which is the case the setting + exists for. iOS reads `UIAccessibilityDarkerSystemColorsEnabled` and observes + `…StatusDidChangeNotification`; it is a boolean, so iOS never reports Medium. + Desktop reads Windows' `win.highContrast.on` AWT desktop property and answers + Standard on linux and macos, which is honest rather than complete — see + "What this plan does not cover". + + Verified on an API 36 emulator with dynamic colour off: the `onPrimaryContainer` + pixel of the "Skip for now" label reads `#848484` → `#A7A7A7` → `#D0D0D0` as the + setting moves, the three declared values exactly, **without the app restarting**. 3. **Give `MaterialTheme` its other three slots.** `Shapes`, `Typography` and a `MotionScheme` are all parameters of the overload the app already calls: @@ -787,6 +801,17 @@ Phases 1–5 can be worked in parallel by different people if 1 lands first; `ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point expressive, but it is a product decision with a visible outcome, and it should be made deliberately rather than inherited from an import. +- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults + `dynamicColor = true`, and on Android 12+ that wins unconditionally — so on + essentially every current Android device, none of the six schemes below is used + and the app renders in whatever the user's wallpaper produced. Verified on an API + 36 emulator: the app paints Material lavender until dynamic colour is switched + off, at which point the black-and-gold brand appears. M3's customization + foundation treats this as a developer choice, and applying it selectively — a + profile screen, say — is the usual answer for an app with a brand. Deciding it is + a product call, not a conformance one, and it is why phase 1's contrast work is + reachable today only on Android below 12, on iOS and on desktop. On Android 14+ + with dynamic colour on, the platform honours contrast itself. - **Whether the monochrome palette is right.** The scheme's `primary` is pure black in light and near-white in dark, with the entire tertiary family a copy of primary. That is a defensible choice for this product and it passes