diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.android.kt index c84c078..a4729e0 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.android.kt @@ -21,9 +21,18 @@ actual class ThemeStorageManager(context: Context) { prefs.edit().remove(KEY_THEME_ID).apply() } + actual fun saveUseDynamicColor(enabled: Boolean) { + prefs.edit().putBoolean(KEY_USE_DYNAMIC_COLOR, enabled).apply() + } + + actual fun getUseDynamicColor(): Boolean { + return prefs.getBoolean(KEY_USE_DYNAMIC_COLOR, false) + } + companion object { private const val PREFS_NAME = "honeydue_theme_prefs" private const val KEY_THEME_ID = "theme_id" + private const val KEY_USE_DYNAMIC_COLOR = "use_dynamic_color" @Volatile private var instance: ThemeStorageManager? = null diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.android.kt new file mode 100644 index 0000000..eef335c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.android.kt @@ -0,0 +1,22 @@ +package com.tt.honeyDue.ui.theme + +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.ui.platform.LocalContext + +/** + * Android actual: dynamic color (Material You) is available on Android 12+ + * (API 31, [Build.VERSION_CODES.S]). + */ +actual fun isDynamicColorSupported(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +@Composable +actual fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme? { + if (!isDynamicColorSupported()) return null + val context = LocalContext.current + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt index 5e3c763..94d79aa 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/ThemeStorage.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/ThemeStorage.kt index 26c7e55..5c7872c 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/ThemeStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/storage/ThemeStorage.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/common/TouchTargetHelpers.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/common/TouchTargetHelpers.kt new file mode 100644 index 0000000..7077db2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/common/TouchTargetHelpers.kt @@ -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, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt index 451765e..ab4058e 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/CompleteTaskScreen.kt @@ -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) + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt index 531a422..e400ae5 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt @@ -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. diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.kt new file mode 100644 index 0000000..fa93bdf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.kt @@ -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? diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/Theme.kt index de1f5d9..adb1bc8 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/Theme.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/ThemeManager.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/ThemeManager.kt index ccb7b99..68c5c83 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/ThemeManager.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/theme/ThemeManager.kt @@ -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 */ diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.ios.kt index 8c72025..e4366ee 100644 --- a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/storage/ThemeStorageManager.ios.kt @@ -22,8 +22,21 @@ actual class ThemeStorageManager { defaults.synchronize() } + actual fun saveUseDynamicColor(enabled: Boolean) { + defaults.setBool(enabled, forKey = KEY_USE_DYNAMIC_COLOR) + defaults.synchronize() + } + + actual fun getUseDynamicColor(): Boolean { + // iOS has no Material You — always false. Still read/write so stored + // state survives a platform reinstall scenario and cross-platform parity + // APIs stay symmetric. + return defaults.boolForKey(KEY_USE_DYNAMIC_COLOR) + } + companion object { private const val KEY_THEME_ID = "theme_id" + private const val KEY_USE_DYNAMIC_COLOR = "use_dynamic_color" private val instance by lazy { ThemeStorageManager() } diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.ios.kt new file mode 100644 index 0000000..432a008 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.ios.kt @@ -0,0 +1,10 @@ +package com.tt.honeyDue.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** iOS has no equivalent to Material You wallpaper-driven colors. */ +actual fun isDynamicColorSupported(): Boolean = false + +@Composable +actual fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.js.kt new file mode 100644 index 0000000..f9d8f6d --- /dev/null +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.js.kt @@ -0,0 +1,10 @@ +package com.tt.honeyDue.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** JS browsers have no dynamic color support. */ +actual fun isDynamicColorSupported(): Boolean = false + +@Composable +actual fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.jvm.kt new file mode 100644 index 0000000..6effcdf --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.jvm.kt @@ -0,0 +1,10 @@ +package com.tt.honeyDue.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** Desktop JVM has no dynamic color support. */ +actual fun isDynamicColorSupported(): Boolean = false + +@Composable +actual fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.wasmJs.kt new file mode 100644 index 0000000..e58360a --- /dev/null +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/ui/theme/DynamicColor.wasmJs.kt @@ -0,0 +1,10 @@ +package com.tt.honeyDue.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** WasmJS browsers have no dynamic color support. */ +actual fun isDynamicColorSupported(): Boolean = false + +@Composable +actual fun rememberDynamicColorScheme(darkTheme: Boolean): ColorScheme? = null