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:
Trey T
2026-04-18 12:50:00 -05:00
parent 0d50726490
commit 6b3e64661f
7 changed files with 489 additions and 230 deletions

View File

@@ -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()
}
)
}
}
}

View File

@@ -118,6 +118,9 @@ object ResetPasswordRoute
@Serializable
object NotificationPreferencesRoute
@Serializable
object ThemeSelectionRoute
// Onboarding Routes
@Serializable
object OnboardingRoute

View File

@@ -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
)
)
}

View File

@@ -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()) {

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}