P2 Stream D: ThemeSelectionScreen (replaces ThemePickerDialog)
Full-screen theme picker with 11 theme previews matching iOS ThemeSelectionView. Live preview on tap. Old dialog deleted and call-sites migrated to the new route. Tests use the state-logic fallback pattern (plain kotlin.test rather than runComposeUiTest) because Compose UI testing in commonTest for this KMP project is flaky — the existing ThemeManager uses mutableStateOf plus platform-backed ThemeStorage, which doesn't play well with the recomposer on iosSimulatorArm64. The behavior under test is identical: helpers in ThemeSelectionScreenState drive the same code paths the composable invokes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import com.tt.honeyDue.ui.screens.TasksScreen
|
||||
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
|
||||
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingScreen
|
||||
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
||||
import com.tt.honeyDue.viewmodel.OnboardingViewModel
|
||||
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -706,6 +707,9 @@ fun App(
|
||||
onNavigateToNotificationPreferences = {
|
||||
navController.navigate(NotificationPreferencesRoute)
|
||||
},
|
||||
onNavigateToThemeSelection = {
|
||||
navController.navigate(ThemeSelectionRoute)
|
||||
},
|
||||
onNavigateToUpgrade = {
|
||||
navController.navigate(UpgradeRoute)
|
||||
}
|
||||
@@ -719,6 +723,14 @@ fun App(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<ThemeSelectionRoute> {
|
||||
ThemeSelectionScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ object ResetPasswordRoute
|
||||
@Serializable
|
||||
object NotificationPreferencesRoute
|
||||
|
||||
@Serializable
|
||||
object ThemeSelectionRoute
|
||||
|
||||
// Onboarding Routes
|
||||
@Serializable
|
||||
object OnboardingRoute
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package com.tt.honeyDue.ui.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import com.tt.honeyDue.platform.HapticFeedbackType
|
||||
import com.tt.honeyDue.platform.rememberHapticFeedback
|
||||
|
||||
/**
|
||||
* ThemePickerDialog - Shows all available themes in a grid
|
||||
* Matches iOS theme picker functionality
|
||||
*
|
||||
* Features:
|
||||
* - Grid layout with 2 columns
|
||||
* - Shows theme preview colors
|
||||
* - Current theme highlighted with checkmark
|
||||
* - Theme name and description
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* if (showThemePicker) {
|
||||
* ThemePickerDialog(
|
||||
* currentTheme = ThemeManager.currentTheme,
|
||||
* onThemeSelected = { theme ->
|
||||
* ThemeManager.setTheme(theme)
|
||||
* showThemePicker = false
|
||||
* },
|
||||
* onDismiss = { showThemePicker = false }
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Composable
|
||||
fun ThemePickerDialog(
|
||||
currentTheme: ThemeColors,
|
||||
onThemeSelected: (ThemeColors) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(AppRadius.lg),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(AppSpacing.lg)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(AppSpacing.xl)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "Choose Theme",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(bottom = AppSpacing.lg)
|
||||
)
|
||||
|
||||
// Theme Grid
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md),
|
||||
modifier = Modifier.heightIn(max = 400.dp)
|
||||
) {
|
||||
items(ThemeManager.getAllThemes()) { theme ->
|
||||
ThemeCard(
|
||||
theme = theme,
|
||||
isSelected = theme.id == currentTheme.id,
|
||||
onClick = {
|
||||
hapticFeedback.perform(HapticFeedbackType.Selection)
|
||||
onThemeSelected(theme)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Close button
|
||||
Spacer(modifier = Modifier.height(AppSpacing.lg))
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual theme card in the picker
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeCard(
|
||||
theme: ThemeColors,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(AppRadius.md)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
shape = RoundedCornerShape(AppRadius.md),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.backgroundSecondary
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(AppSpacing.md),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Color preview circles
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
|
||||
modifier = Modifier.padding(bottom = AppSpacing.sm)
|
||||
) {
|
||||
// Preview with light mode colors
|
||||
ColorCircle(theme.lightPrimary)
|
||||
ColorCircle(theme.lightSecondary)
|
||||
ColorCircle(theme.lightAccent)
|
||||
}
|
||||
|
||||
// Theme name
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = theme.displayName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Theme description
|
||||
Text(
|
||||
text = theme.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = AppSpacing.xs)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small colored circle for theme preview
|
||||
*/
|
||||
@Composable
|
||||
private fun ColorCircle(color: Color) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = Color.Black.copy(alpha = 0.1f),
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.tt.honeyDue.models.Residence
|
||||
import com.tt.honeyDue.models.TaskDetail
|
||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||
import com.tt.honeyDue.storage.TokenStorage
|
||||
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
||||
import com.tt.honeyDue.ui.theme.*
|
||||
import honeydue.composeapp.generated.resources.*
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -260,6 +261,9 @@ fun MainScreen(
|
||||
onNavigateToNotificationPreferences = {
|
||||
navController.navigate(NotificationPreferencesRoute)
|
||||
},
|
||||
onNavigateToThemeSelection = {
|
||||
navController.navigate(ThemeSelectionRoute)
|
||||
},
|
||||
onNavigateToUpgrade = {
|
||||
navController.navigate(UpgradeRoute)
|
||||
}
|
||||
@@ -277,6 +281,16 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
composable<ThemeSelectionRoute> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ThemeSelectionScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable<CompleteTaskRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<CompleteTaskRoute>()
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.tt.honeyDue.ui.components.HandleErrors
|
||||
import com.tt.honeyDue.ui.components.common.ErrorCard
|
||||
import com.tt.honeyDue.ui.components.dialogs.DeleteAccountDialog
|
||||
import com.tt.honeyDue.ui.components.dialogs.ThemePickerDialog
|
||||
import com.tt.honeyDue.utils.SubscriptionHelper
|
||||
import com.tt.honeyDue.ui.theme.AppRadius
|
||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||
@@ -46,6 +45,7 @@ fun ProfileScreen(
|
||||
onLogout: () -> Unit,
|
||||
onAccountDeleted: () -> Unit = {},
|
||||
onNavigateToNotificationPreferences: () -> Unit = {},
|
||||
onNavigateToThemeSelection: () -> Unit = {},
|
||||
onNavigateToUpgrade: (() -> Unit)? = null,
|
||||
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
||||
) {
|
||||
@@ -56,7 +56,6 @@ fun ProfileScreen(
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var successMessage by remember { mutableStateOf("") }
|
||||
var isLoadingUser by remember { mutableStateOf(true) }
|
||||
var showThemePicker by remember { mutableStateOf(false) }
|
||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -245,7 +244,7 @@ fun ProfileScreen(
|
||||
OrganicCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showThemePicker = true }
|
||||
.clickable { onNavigateToThemeSelection() }
|
||||
.naturalShadow()
|
||||
) {
|
||||
Row(
|
||||
@@ -782,23 +781,6 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Theme Picker Dialog
|
||||
if (showThemePicker) {
|
||||
ThemePickerDialog(
|
||||
currentTheme = currentTheme,
|
||||
onThemeSelected = { theme ->
|
||||
ThemeManager.setTheme(theme)
|
||||
// Track theme change
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.THEME_CHANGED,
|
||||
mapOf("theme" to theme.id)
|
||||
)
|
||||
showThemePicker = false
|
||||
},
|
||||
onDismiss = { showThemePicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Account Dialog
|
||||
if (showDeleteAccountDialog) {
|
||||
val isSocialAuth = currentUser?.authProvider?.let {
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package com.tt.honeyDue.ui.screens.theme
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tt.honeyDue.analytics.AnalyticsEvents
|
||||
import com.tt.honeyDue.analytics.PostHogAnalytics
|
||||
import com.tt.honeyDue.platform.HapticFeedbackType
|
||||
import com.tt.honeyDue.platform.rememberHapticFeedback
|
||||
import com.tt.honeyDue.ui.components.common.StandardCard
|
||||
import com.tt.honeyDue.ui.theme.AppRadius
|
||||
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
|
||||
|
||||
/**
|
||||
* ThemeSelectionScreen — full-screen theme picker matching iOS
|
||||
* `iosApp/iosApp/Profile/ThemeSelectionView.swift`.
|
||||
*
|
||||
* Behavior:
|
||||
* - Lists all 11 themes from [ThemeManager.getAllThemes].
|
||||
* - Each row shows a preview of the theme's primary/secondary/accent
|
||||
* swatches, the theme name, description, and a checkmark on the
|
||||
* currently selected theme.
|
||||
* - Tapping a row calls [ThemeSelectionScreenState.onThemeTap] which
|
||||
* auto-applies the theme via [ThemeManager.setTheme] (matches iOS —
|
||||
* there is no explicit Apply button; the toolbar "Done" button only
|
||||
* dismisses).
|
||||
* - A live preview header at the top reacts to the selected theme using
|
||||
* [ThemeManager.currentTheme] so the user sees the change before
|
||||
* confirming.
|
||||
* - Tapping the back/Done button calls the provided `onNavigateBack`.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ThemeSelectionScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val haptics = rememberHapticFeedback()
|
||||
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Appearance",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
ThemeSelectionScreenState.onConfirm(onBack = onNavigateBack)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = {
|
||||
ThemeSelectionScreenState.onConfirm(onBack = onNavigateBack)
|
||||
}) {
|
||||
Text(
|
||||
text = "Done",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { paddingValues: PaddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.md),
|
||||
) {
|
||||
item {
|
||||
LivePreviewHeader(theme = currentTheme)
|
||||
}
|
||||
|
||||
items(ThemeManager.getAllThemes(), key = { it.id }) { theme ->
|
||||
ThemeRowCard(
|
||||
theme = theme,
|
||||
isSelected = theme.id == currentTheme.id,
|
||||
onClick = {
|
||||
haptics.perform(HapticFeedbackType.Selection)
|
||||
ThemeSelectionScreenState.onThemeTap(theme.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(AppSpacing.lg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live preview card at the top of the screen — a miniature header that
|
||||
* uses the selected theme's colors directly (not MaterialTheme) so the
|
||||
* preview updates instantly regardless of the ambient theme.
|
||||
*/
|
||||
@Composable
|
||||
private fun LivePreviewHeader(theme: ThemeColors) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val primary = if (isDark) theme.darkPrimary else theme.lightPrimary
|
||||
val backgroundSecondary = if (isDark) theme.darkBackgroundSecondary else theme.lightBackgroundSecondary
|
||||
val textPrimary = if (isDark) theme.darkTextPrimary else theme.lightTextPrimary
|
||||
val textSecondary = if (isDark) theme.darkTextSecondary else theme.lightTextSecondary
|
||||
val textOnPrimary = if (isDark) theme.darkTextOnPrimary else theme.lightTextOnPrimary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(AppRadius.lg))
|
||||
.background(backgroundSecondary)
|
||||
.padding(AppSpacing.lg),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(AppRadius.md))
|
||||
.background(primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Aa",
|
||||
color = textOnPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = theme.displayName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textPrimary,
|
||||
)
|
||||
Text(
|
||||
text = theme.description,
|
||||
color = textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeRowCard(
|
||||
theme: ThemeColors,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val swatchPrimary = if (isDark) theme.darkPrimary else theme.lightPrimary
|
||||
val swatchSecondary = if (isDark) theme.darkSecondary else theme.lightSecondary
|
||||
val swatchAccent = if (isDark) theme.darkAccent else theme.lightAccent
|
||||
|
||||
StandardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(AppRadius.md),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
contentPadding = AppSpacing.md,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
|
||||
) {
|
||||
// Swatch group
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
|
||||
) {
|
||||
Swatch(color = swatchPrimary)
|
||||
Swatch(color = swatchSecondary)
|
||||
Swatch(color = swatchAccent)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = AppSpacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs),
|
||||
) {
|
||||
Text(
|
||||
text = theme.displayName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = theme.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Swatch(color: Color) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* State helper for [ThemeSelectionScreen].
|
||||
*
|
||||
* Pulled out as a plain object so the behavior is unit-testable in
|
||||
* `commonTest` without standing up the Compose recomposer (see
|
||||
* `ThemeSelectionScreenTest`). Production composables call into these
|
||||
* helpers so the code under test is identical to the code that ships.
|
||||
*/
|
||||
object ThemeSelectionScreenState {
|
||||
|
||||
/**
|
||||
* Called when the user taps a theme row. Updates the app's theme
|
||||
* and emits analytics — mirrors iOS `selectTheme(_:)` which updates
|
||||
* `themeManager.currentTheme` and tracks `.themeChanged(...)`.
|
||||
*/
|
||||
fun onThemeTap(themeId: String) {
|
||||
val theme = AppThemes.getThemeById(themeId)
|
||||
ThemeManager.setTheme(theme)
|
||||
PostHogAnalytics.capture(
|
||||
AnalyticsEvents.THEME_CHANGED,
|
||||
mapOf("theme" to theme.id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user confirms (Done / back). iOS behavior is
|
||||
* simply to dismiss — the theme has already been applied on tap.
|
||||
*/
|
||||
fun onConfirm(onBack: () -> Unit) {
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user