From 6b3e64661f047b088e697136e92b16d4feff3b09 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:50:00 -0500 Subject: [PATCH] P2 Stream D: ThemeSelectionScreen (replaces ThemePickerDialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../commonMain/kotlin/com/tt/honeyDue/App.kt | 12 + .../com/tt/honeyDue/navigation/Routes.kt | 3 + .../components/dialogs/ThemePickerDialog.kt | 210 ----------- .../com/tt/honeyDue/ui/screens/MainScreen.kt | 14 + .../tt/honeyDue/ui/screens/ProfileScreen.kt | 22 +- .../ui/screens/theme/ThemeSelectionScreen.kt | 332 ++++++++++++++++++ .../screens/theme/ThemeSelectionScreenTest.kt | 126 +++++++ 7 files changed, 489 insertions(+), 230 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/ThemePickerDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreenTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt index 88edcee..be2af48 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/App.kt @@ -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 { + ThemeSelectionScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt index 668e366..0c043cd 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/navigation/Routes.kt @@ -118,6 +118,9 @@ object ResetPasswordRoute @Serializable object NotificationPreferencesRoute +@Serializable +object ThemeSelectionRoute + // Onboarding Routes @Serializable object OnboardingRoute diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/ThemePickerDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/ThemePickerDialog.kt deleted file mode 100644 index dbdcddd..0000000 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/dialogs/ThemePickerDialog.kt +++ /dev/null @@ -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 - ) - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt index 328b078..078372a 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/MainScreen.kt @@ -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 { + Box(modifier = Modifier.fillMaxSize()) { + ThemeSelectionScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + } + composable { backStackEntry -> val route = backStackEntry.toRoute() Box(modifier = Modifier.fillMaxSize()) { diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt index a643d51..36e5dbc 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/ProfileScreen.kt @@ -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 { 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 new file mode 100644 index 0000000..f814e33 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreen.kt @@ -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() + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreenTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreenTest.kt new file mode 100644 index 0000000..5e19ea7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/theme/ThemeSelectionScreenTest.kt @@ -0,0 +1,126 @@ +package com.tt.honeyDue.ui.screens.theme + +import com.tt.honeyDue.ui.theme.AppThemes +import com.tt.honeyDue.ui.theme.ThemeManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * P2 Stream D — ThemeSelectionScreen tests. + * + * These tests exercise the state-logic backing the screen. We intentionally + * use plain `kotlin.test` rather than `runComposeUiTest { }` because + * Compose UI testing in commonTest for a KMP project with `ThemeManager` + * (which uses `mutableStateOf` and a platform-backed `ThemeStorage`) is + * flaky — the recomposer / Dispatchers interplay breaks on iosSimulator. + * + * Instead we assert the public contract the screen relies on: + * - The 11 iOS themes are exposed by `ThemeManager.getAllThemes()` with + * the IDs and display names the row-cards render. + * - Tapping a row updates `ThemeManager.currentTheme` to the tapped id. + * - The "live header preview" simply reads `ThemeManager.currentTheme`, + * so selecting a theme advances that reactive value. + * - "Initially selected" is whatever `ThemeManager.currentTheme` returns + * at composition time — asserted by comparing ids. + * + * Mirrors iOS `iosApp/iosApp/Profile/ThemeSelectionView.swift`. + */ +class ThemeSelectionScreenTest { + + private lateinit var originalThemeId: String + + @BeforeTest + fun rememberOriginalTheme() { + originalThemeId = ThemeManager.currentTheme.id + } + + @AfterTest + fun restoreOriginalTheme() { + ThemeManager.setTheme(originalThemeId) + } + + // 1. Renders 11 theme cards with correct names and IDs. + @Test + fun allElevenThemesAreAvailableForTheScreen() { + val themes = ThemeManager.getAllThemes() + assertEquals(11, themes.size, "ThemeSelectionScreen must offer all 11 iOS themes") + + val expectedIds = listOf( + "default", "teal", "ocean", "forest", "sunset", "monochrome", + "lavender", "crimson", "midnight", "desert", "mint" + ) + assertEquals(expectedIds, themes.map { it.id }) + + // Each theme must have a non-blank displayName and description + themes.forEach { theme -> + assertTrue(theme.displayName.isNotBlank(), "${theme.id} needs displayName") + assertTrue(theme.description.isNotBlank(), "${theme.id} needs description") + } + } + + // 2. Initially-selected theme matches ThemeManager.currentTheme. + @Test + fun initiallySelectedThemeReflectsThemeManager() { + ThemeManager.setTheme("ocean") + val selectedId = ThemeManager.currentTheme.id + assertEquals("ocean", selectedId, "Screen shows whatever ThemeManager reports as current") + + // The card for that id is the one that renders the checkmark. + val oceanTheme = ThemeManager.getAllThemes().single { it.id == selectedId } + assertEquals("Ocean", oceanTheme.displayName) + } + + // 3. Tapping a theme card changes ThemeManager.currentTheme to the tapped id. + @Test + fun tappingThemeCardUpdatesCurrentTheme() { + ThemeManager.setTheme("default") + assertEquals("default", ThemeManager.currentTheme.id) + + // Simulate the screen's onThemeSelected callback. + ThemeSelectionScreenState.onThemeTap("crimson") + + assertEquals("crimson", ThemeManager.currentTheme.id) + assertSame(AppThemes.Crimson, ThemeManager.currentTheme) + } + + // 4. Live preview header reflects the selected theme's primary color. + // The preview reads `ThemeManager.currentTheme.lightPrimary` (or dark). + @Test + fun livePreviewTracksSelectedThemePrimaryColor() { + ThemeManager.setTheme("default") + val defaultPrimary = ThemeManager.currentTheme.lightPrimary + + ThemeSelectionScreenState.onThemeTap("forest") + val forestPrimary = ThemeManager.currentTheme.lightPrimary + + assertNotNull(forestPrimary) + assertNotSame(defaultPrimary, forestPrimary, "Primary color must update on theme tap") + assertEquals(AppThemes.Forest.lightPrimary, forestPrimary) + } + + // 5. The screen's back-navigation callback is invoked when the user + // confirms. Verified by the state helper recording the call. + @Test + fun onConfirmNavigatesBack() { + var navigatedBack = false + ThemeSelectionScreenState.onConfirm(onBack = { navigatedBack = true }) + assertTrue(navigatedBack, "Confirm/Done must call the back-navigation callback") + } + + // 6. Tapping every theme in turn leaves currentTheme at the last tap — + // proves the reactive state machine across a full cycle. + @Test + fun stateSelectionFlowCyclesThroughAllThemes() { + val ids = ThemeManager.getAllThemes().map { it.id } + ids.forEach { id -> + ThemeSelectionScreenState.onThemeTap(id) + assertEquals(id, ThemeManager.currentTheme.id) + } + } +}