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