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:
@@ -21,9 +21,18 @@ actual class ThemeStorageManager(context: Context) {
|
|||||||
prefs.edit().remove(KEY_THEME_ID).apply()
|
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 {
|
companion object {
|
||||||
private const val PREFS_NAME = "honeydue_theme_prefs"
|
private const val PREFS_NAME = "honeydue_theme_prefs"
|
||||||
private const val KEY_THEME_ID = "theme_id"
|
private const val KEY_THEME_ID = "theme_id"
|
||||||
|
private const val KEY_USE_DYNAMIC_COLOR = "use_dynamic_color"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: ThemeStorageManager? = null
|
private var instance: ThemeStorageManager? = null
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -151,8 +151,9 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
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)
|
// Handle contractor file imports (Android-specific, no-op on other platforms)
|
||||||
ContractorImportHandler(
|
ContractorImportHandler(
|
||||||
pendingContractorImportUri = pendingContractorImportUri,
|
pendingContractorImportUri = pendingContractorImportUri,
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ object ThemeStorage {
|
|||||||
fun clearThemeId() {
|
fun clearThemeId() {
|
||||||
manager?.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 saveThemeId(themeId: String)
|
||||||
fun getThemeId(): String?
|
fun getThemeId(): String?
|
||||||
fun clearThemeId()
|
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.models.ContractorSummary
|
||||||
import com.tt.honeyDue.network.ApiResult
|
import com.tt.honeyDue.network.ApiResult
|
||||||
import com.tt.honeyDue.platform.*
|
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.haptics.Haptics
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
import com.tt.honeyDue.viewmodel.ContractorViewModel
|
||||||
@@ -163,7 +165,9 @@ fun CompleteTaskScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = OrganicSpacing.lg)
|
.padding(horizontal = OrganicSpacing.lg)
|
||||||
.clickable { showContractorPicker = true }
|
.clickableWithRipple(onClickLabel = "Select contractor") {
|
||||||
|
showContractorPicker = true
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(OrganicSpacing.xs)
|
.minTouchTarget()
|
||||||
.size(24.dp)
|
.clickableWithRipple(onClickLabel = "Remove photo", onClick = onRemove),
|
||||||
.clip(CircleShape)
|
contentAlignment = Alignment.Center,
|
||||||
.background(MaterialTheme.colorScheme.error)
|
|
||||||
.clickable(onClick = onRemove),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
Icons.Default.Close,
|
modifier = Modifier
|
||||||
contentDescription = "Remove",
|
.size(24.dp)
|
||||||
tint = MaterialTheme.colorScheme.onError,
|
.clip(CircleShape)
|
||||||
modifier = Modifier.size(16.dp)
|
.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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -37,6 +38,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.AppThemes
|
||||||
import com.tt.honeyDue.ui.theme.ThemeColors
|
import com.tt.honeyDue.ui.theme.ThemeColors
|
||||||
import com.tt.honeyDue.ui.theme.ThemeManager
|
import com.tt.honeyDue.ui.theme.ThemeManager
|
||||||
|
import com.tt.honeyDue.ui.theme.isDynamicColorSupported
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ThemeSelectionScreen — full-screen theme picker matching iOS
|
* ThemeSelectionScreen — full-screen theme picker matching iOS
|
||||||
@@ -77,6 +80,8 @@ fun ThemeSelectionScreen(
|
|||||||
) {
|
) {
|
||||||
val haptics = rememberHapticFeedback()
|
val haptics = rememberHapticFeedback()
|
||||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||||
|
val useDynamicColor by remember { derivedStateOf { ThemeManager.useDynamicColor } }
|
||||||
|
val dynamicColorSupported = remember { isDynamicColorSupported() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -126,10 +131,23 @@ fun ThemeSelectionScreen(
|
|||||||
LivePreviewHeader(theme = currentTheme)
|
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 ->
|
items(ThemeManager.getAllThemes(), key = { it.id }) { theme ->
|
||||||
ThemeRowCard(
|
ThemeRowCard(
|
||||||
theme = theme,
|
theme = theme,
|
||||||
isSelected = theme.id == currentTheme.id,
|
isSelected = theme.id == currentTheme.id && !useDynamicColor,
|
||||||
|
dimmed = useDynamicColor,
|
||||||
onClick = {
|
onClick = {
|
||||||
haptics.perform(HapticFeedbackType.Selection)
|
haptics.perform(HapticFeedbackType.Selection)
|
||||||
ThemeSelectionScreenState.onThemeTap(theme.id)
|
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
|
@Composable
|
||||||
private fun ThemeRowCard(
|
private fun ThemeRowCard(
|
||||||
theme: ThemeColors,
|
theme: ThemeColors,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
|
dimmed: Boolean = false,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
@@ -214,7 +274,8 @@ private fun ThemeRowCard(
|
|||||||
StandardCard(
|
StandardCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.then(if (dimmed) Modifier.alpha(0.4f) else Modifier)
|
||||||
|
.clickable(enabled = !dimmed, onClick = onClick)
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(
|
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
|
* Called when the user confirms (Done / back). iOS behavior is
|
||||||
* simply to dismiss — the theme has already been applied on tap.
|
* 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
|
* Main theme composable - integrates with ThemeManager for dynamic theming.
|
||||||
* Matches iOS multi-theme system
|
* 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
|
@Composable
|
||||||
fun HoneyDueTheme(
|
fun HoneyDueTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
themeColors: ThemeColors = AppThemes.Default, // Can be overridden with ThemeManager.currentTheme
|
themeColors: ThemeColors = AppThemes.Default, // Can be overridden with ThemeManager.currentTheme
|
||||||
|
useDynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = themeColors.toColorScheme(darkTheme)
|
val dynamicScheme = if (useDynamicColor) rememberDynamicColorScheme(darkTheme) else null
|
||||||
|
val colorScheme = dynamicScheme ?: themeColors.toColorScheme(darkTheme)
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ object ThemeManager {
|
|||||||
var currentTheme by mutableStateOf(AppThemes.Default)
|
var currentTheme by mutableStateOf(AppThemes.Default)
|
||||||
private set
|
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
|
* Initialize theme manager and load saved theme
|
||||||
* Call this after ThemeStorage.initialize()
|
* Call this after ThemeStorage.initialize()
|
||||||
@@ -28,6 +40,7 @@ object ThemeManager {
|
|||||||
val savedTheme = AppThemes.getThemeById(savedThemeId)
|
val savedTheme = AppThemes.getThemeById(savedThemeId)
|
||||||
currentTheme = savedTheme
|
currentTheme = savedTheme
|
||||||
}
|
}
|
||||||
|
useDynamicColor = ThemeStorage.getUseDynamicColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +59,19 @@ object ThemeManager {
|
|||||||
setTheme(theme.id)
|
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
|
* Get all available themes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -22,8 +22,21 @@ actual class ThemeStorageManager {
|
|||||||
defaults.synchronize()
|
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 {
|
companion object {
|
||||||
private const val KEY_THEME_ID = "theme_id"
|
private const val KEY_THEME_ID = "theme_id"
|
||||||
|
private const val KEY_USE_DYNAMIC_COLOR = "use_dynamic_color"
|
||||||
|
|
||||||
private val instance by lazy { ThemeStorageManager() }
|
private val instance by lazy { ThemeStorageManager() }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user