Audit 9b: Dynamic color (Material You) + 48dp min touch target helpers

Dynamic color opt-in on Android 12+ via expect/actual DynamicColor:
- commonMain: expect isDynamicColorSupported() + rememberDynamicColorScheme()
- androidMain: SDK>=31 gate, dynamicLight/DarkColorScheme(context)
- iOS/JVM/JS/WASM: no-op actuals
- ThemeManager gains useDynamicColor StateFlow, persisted via ThemeStorage
- App.kt wires both currentTheme + useDynamicColor into HoneyDueTheme
- ThemeSelectionScreen exposes the "Use system colors" toggle

Touch target helpers:
- Modifier.minTouchTarget(48.dp) + Modifier.clickableWithRipple
- Applied at audit-flagged sites in CompleteTaskScreen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 17:39:22 -05:00
parent 214908cd5c
commit ba1ec2a69b
15 changed files with 314 additions and 19 deletions

View File

@@ -151,8 +151,9 @@ fun App(
}
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val useDynamicColor by remember { derivedStateOf { ThemeManager.useDynamicColor } }
HoneyDueTheme(themeColors = currentTheme) {
HoneyDueTheme(themeColors = currentTheme, useDynamicColor = useDynamicColor) {
// Handle contractor file imports (Android-specific, no-op on other platforms)
ContractorImportHandler(
pendingContractorImportUri = pendingContractorImportUri,

View File

@@ -22,6 +22,19 @@ object ThemeStorage {
fun clearThemeId() {
manager?.clearThemeId()
}
/**
* Persist whether the user has opted into dynamic color (Material You) on
* Android 12+. Defaults to `false` when unset so existing users keep the
* custom theme they chose.
*/
fun saveUseDynamicColor(enabled: Boolean) {
manager?.saveUseDynamicColor(enabled)
}
fun getUseDynamicColor(): Boolean {
return manager?.getUseDynamicColor() ?: false
}
}
/**
@@ -32,4 +45,6 @@ expect class ThemeStorageManager {
fun saveThemeId(themeId: String)
fun getThemeId(): String?
fun clearThemeId()
fun saveUseDynamicColor(enabled: Boolean)
fun getUseDynamicColor(): Boolean
}

View File

@@ -0,0 +1,48 @@
package com.tt.honeyDue.ui.components.common
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Enforce the Material 3 minimum 48dp touch target on a composable.
*
* Applies [defaultMinSize] so the element grows to 48dp when smaller but
* leaves larger elements untouched. Use around small icon-buttons, pills,
* and X-remove badges that would otherwise render below the spec.
*/
fun Modifier.minTouchTarget(size: Dp = 48.dp): Modifier =
this.defaultMinSize(minWidth = size, minHeight = size)
/**
* `.clickable` with an explicit `MutableInteractionSource` + ambient ripple.
*
* The default `.clickable { }` on non-Material containers (Box, Column, Row)
* omits ripple feedback entirely, which hurts perceived responsiveness on
* Android. Use this helper to get the same ripple pattern as Material
* buttons while retaining full Modifier chain composability.
*
* @param enabled whether the click is currently active.
* @param onClickLabel a11y label describing the action (prefer something
* meaningful, e.g. "Remove photo", over passing null).
* @param onClick the click handler.
*/
fun Modifier.clickableWithRipple(
enabled: Boolean = true,
onClickLabel: String? = null,
onClick: () -> Unit,
): Modifier = composed {
this.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current,
enabled = enabled,
onClickLabel = onClickLabel,
onClick = onClick,
)
}

View File

@@ -32,6 +32,8 @@ import com.tt.honeyDue.models.TaskCompletionCreateRequest
import com.tt.honeyDue.models.ContractorSummary
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.platform.*
import com.tt.honeyDue.ui.components.common.clickableWithRipple
import com.tt.honeyDue.ui.components.common.minTouchTarget
import com.tt.honeyDue.ui.haptics.Haptics
import com.tt.honeyDue.ui.theme.*
import com.tt.honeyDue.viewmodel.ContractorViewModel
@@ -163,7 +165,9 @@ fun CompleteTaskScreen(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg)
.clickable { showContractorPicker = true }
.clickableWithRipple(onClickLabel = "Select contractor") {
showContractorPicker = true
}
) {
Row(
modifier = Modifier
@@ -514,22 +518,31 @@ private fun ImageThumbnailCard(
}
}
// Audit Phase 9b.2: wrap the 24dp visual badge in a 48dp touch target
// with an explicit ripple so the affordance is reachable and feels
// responsive. The outer Box carries the click; the inner Box keeps
// the previous visual footprint.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(OrganicSpacing.xs)
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error)
.clickable(onClick = onRemove),
contentAlignment = Alignment.Center
.minTouchTarget()
.clickableWithRipple(onClickLabel = "Remove photo", onClick = onRemove),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.onError,
modifier = Modifier.size(16.dp)
)
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.onError,
modifier = Modifier.size(16.dp)
)
}
}
}
}

View File

@@ -27,6 +27,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -37,6 +38,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
@@ -51,6 +53,7 @@ import com.tt.honeyDue.ui.theme.AppSpacing
import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.ThemeColors
import com.tt.honeyDue.ui.theme.ThemeManager
import com.tt.honeyDue.ui.theme.isDynamicColorSupported
/**
* ThemeSelectionScreen — full-screen theme picker matching iOS
@@ -77,6 +80,8 @@ fun ThemeSelectionScreen(
) {
val haptics = rememberHapticFeedback()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val useDynamicColor by remember { derivedStateOf { ThemeManager.useDynamicColor } }
val dynamicColorSupported = remember { isDynamicColorSupported() }
Scaffold(
topBar = {
@@ -126,10 +131,23 @@ fun ThemeSelectionScreen(
LivePreviewHeader(theme = currentTheme)
}
if (dynamicColorSupported) {
item {
DynamicColorToggleRow(
enabled = useDynamicColor,
onToggle = { newValue ->
haptics.perform(HapticFeedbackType.Selection)
ThemeSelectionScreenState.onDynamicColorToggle(newValue)
},
)
}
}
items(ThemeManager.getAllThemes(), key = { it.id }) { theme ->
ThemeRowCard(
theme = theme,
isSelected = theme.id == currentTheme.id,
isSelected = theme.id == currentTheme.id && !useDynamicColor,
dimmed = useDynamicColor,
onClick = {
haptics.perform(HapticFeedbackType.Selection)
ThemeSelectionScreenState.onThemeTap(theme.id)
@@ -200,10 +218,52 @@ private fun LivePreviewHeader(theme: ThemeColors) {
}
}
/**
* Material You (dynamic color) opt-in row. Only shown on Android 12+ where
* wallpaper-derived colors are available. Toggling on causes [HoneyDueTheme]
* to ignore the curated theme list and use the system `dynamicLightColorScheme`.
*/
@Composable
private fun DynamicColorToggleRow(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
StandardCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle(!enabled) },
contentPadding = AppSpacing.md,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Use system colors",
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Follow Android 12+ Material You (wallpaper colors)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
}
@Composable
private fun ThemeRowCard(
theme: ThemeColors,
isSelected: Boolean,
dimmed: Boolean = false,
onClick: () -> Unit,
) {
val isDark = isSystemInDarkTheme()
@@ -214,7 +274,8 @@ private fun ThemeRowCard(
StandardCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.then(if (dimmed) Modifier.alpha(0.4f) else Modifier)
.clickable(enabled = !dimmed, onClick = onClick)
.then(
if (isSelected) {
Modifier.border(
@@ -322,6 +383,22 @@ object ThemeSelectionScreenState {
)
}
/**
* Called when the user toggles the Material You (dynamic color) switch.
* Persists the flag via [ThemeManager.setUseDynamicColor] and emits an
* analytics event so we can track Material You adoption.
*/
fun onDynamicColorToggle(enabled: Boolean) {
ThemeManager.applyDynamicColor(enabled)
PostHogAnalytics.capture(
AnalyticsEvents.THEME_CHANGED,
mapOf(
"theme" to ThemeManager.currentTheme.id,
"dynamic_color" to enabled,
),
)
}
/**
* Called when the user confirms (Done / back). iOS behavior is
* simply to dismiss — the theme has already been applied on tap.

View File

@@ -0,0 +1,24 @@
package com.tt.honeyDue.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
/**
* Dynamic color (Material You) support detection.
*
* Returns `true` only on Android 12+ (API 31, Build.VERSION_CODES.S) where the
* system provides `dynamicLightColorScheme` / `dynamicDarkColorScheme` derived
* from the user's wallpaper. All other platforms and older Android releases
* return `false`.
*/
expect fun isDynamicColorSupported(): Boolean
/**
* Returns the system-provided dynamic color scheme when supported, or `null`
* when dynamic color is unavailable on this platform / OS version.
*
* Must be called from a composable scope because Android's implementation
* needs `LocalContext.current`.
*/
@Composable
expect fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme?

View File

@@ -80,16 +80,23 @@ fun ThemeColors.toColorScheme(isDark: Boolean): ColorScheme {
}
/**
* Main theme composable - integrates with ThemeManager for dynamic theming
* Matches iOS multi-theme system
* Main theme composable - integrates with ThemeManager for dynamic theming.
* Matches iOS multi-theme system.
*
* When [useDynamicColor] is true and the platform supports it (Android 12+),
* the wallpaper-derived Material You scheme is used instead of [themeColors].
* Falls back to [themeColors] on every other platform / OS version, so this
* flag is always safe to pass.
*/
@Composable
fun HoneyDueTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
themeColors: ThemeColors = AppThemes.Default, // Can be overridden with ThemeManager.currentTheme
useDynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = themeColors.toColorScheme(darkTheme)
val dynamicScheme = if (useDynamicColor) rememberDynamicColorScheme(darkTheme) else null
val colorScheme = dynamicScheme ?: themeColors.toColorScheme(darkTheme)
MaterialTheme(
colorScheme = colorScheme,

View File

@@ -18,6 +18,18 @@ object ThemeManager {
var currentTheme by mutableStateOf(AppThemes.Default)
private set
/**
* Whether the user has opted into Android 12+ Material You dynamic color.
* On platforms without dynamic color support this flag is still persisted
* but `HoneyDueTheme` ignores it (falls back to `currentTheme`).
*
* Update via [setUseDynamicColor]. The property uses a private setter to
* avoid a JVM signature clash with the explicit setter method that
* persists the change to [ThemeStorage].
*/
var useDynamicColor by mutableStateOf(false)
private set
/**
* Initialize theme manager and load saved theme
* Call this after ThemeStorage.initialize()
@@ -28,6 +40,7 @@ object ThemeManager {
val savedTheme = AppThemes.getThemeById(savedThemeId)
currentTheme = savedTheme
}
useDynamicColor = ThemeStorage.getUseDynamicColor()
}
/**
@@ -46,6 +59,19 @@ object ThemeManager {
setTheme(theme.id)
}
/**
* Opt into / out of wallpaper-derived dynamic color (Material You).
* Persists via [ThemeStorage] so the choice survives app restarts.
*
* Named `applyDynamicColor` rather than `setUseDynamicColor` to avoid a
* JVM signature clash with the auto-generated setter for the
* [useDynamicColor] property.
*/
fun applyDynamicColor(enabled: Boolean) {
useDynamicColor = enabled
ThemeStorage.saveUseDynamicColor(enabled)
}
/**
* Get all available themes
*/