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.VerifyEmailScreen
|
||||||
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
|
||||||
import com.tt.honeyDue.ui.screens.onboarding.OnboardingScreen
|
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.OnboardingViewModel
|
||||||
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -706,6 +707,9 @@ fun App(
|
|||||||
onNavigateToNotificationPreferences = {
|
onNavigateToNotificationPreferences = {
|
||||||
navController.navigate(NotificationPreferencesRoute)
|
navController.navigate(NotificationPreferencesRoute)
|
||||||
},
|
},
|
||||||
|
onNavigateToThemeSelection = {
|
||||||
|
navController.navigate(ThemeSelectionRoute)
|
||||||
|
},
|
||||||
onNavigateToUpgrade = {
|
onNavigateToUpgrade = {
|
||||||
navController.navigate(UpgradeRoute)
|
navController.navigate(UpgradeRoute)
|
||||||
}
|
}
|
||||||
@@ -719,6 +723,14 @@ fun App(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<ThemeSelectionRoute> {
|
||||||
|
ThemeSelectionScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ object ResetPasswordRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
object NotificationPreferencesRoute
|
object NotificationPreferencesRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object ThemeSelectionRoute
|
||||||
|
|
||||||
// Onboarding Routes
|
// Onboarding Routes
|
||||||
@Serializable
|
@Serializable
|
||||||
object OnboardingRoute
|
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.models.TaskDetail
|
||||||
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
import com.tt.honeyDue.platform.PlatformUpgradeScreen
|
||||||
import com.tt.honeyDue.storage.TokenStorage
|
import com.tt.honeyDue.storage.TokenStorage
|
||||||
|
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
|
||||||
import com.tt.honeyDue.ui.theme.*
|
import com.tt.honeyDue.ui.theme.*
|
||||||
import honeydue.composeapp.generated.resources.*
|
import honeydue.composeapp.generated.resources.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -260,6 +261,9 @@ fun MainScreen(
|
|||||||
onNavigateToNotificationPreferences = {
|
onNavigateToNotificationPreferences = {
|
||||||
navController.navigate(NotificationPreferencesRoute)
|
navController.navigate(NotificationPreferencesRoute)
|
||||||
},
|
},
|
||||||
|
onNavigateToThemeSelection = {
|
||||||
|
navController.navigate(ThemeSelectionRoute)
|
||||||
|
},
|
||||||
onNavigateToUpgrade = {
|
onNavigateToUpgrade = {
|
||||||
navController.navigate(UpgradeRoute)
|
navController.navigate(UpgradeRoute)
|
||||||
}
|
}
|
||||||
@@ -277,6 +281,16 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<ThemeSelectionRoute> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ThemeSelectionScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
composable<CompleteTaskRoute> { backStackEntry ->
|
composable<CompleteTaskRoute> { backStackEntry ->
|
||||||
val route = backStackEntry.toRoute<CompleteTaskRoute>()
|
val route = backStackEntry.toRoute<CompleteTaskRoute>()
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
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.HandleErrors
|
||||||
import com.tt.honeyDue.ui.components.common.ErrorCard
|
import com.tt.honeyDue.ui.components.common.ErrorCard
|
||||||
import com.tt.honeyDue.ui.components.dialogs.DeleteAccountDialog
|
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.utils.SubscriptionHelper
|
||||||
import com.tt.honeyDue.ui.theme.AppRadius
|
import com.tt.honeyDue.ui.theme.AppRadius
|
||||||
import com.tt.honeyDue.ui.theme.AppSpacing
|
import com.tt.honeyDue.ui.theme.AppSpacing
|
||||||
@@ -46,6 +45,7 @@ fun ProfileScreen(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onAccountDeleted: () -> Unit = {},
|
onAccountDeleted: () -> Unit = {},
|
||||||
onNavigateToNotificationPreferences: () -> Unit = {},
|
onNavigateToNotificationPreferences: () -> Unit = {},
|
||||||
|
onNavigateToThemeSelection: () -> Unit = {},
|
||||||
onNavigateToUpgrade: (() -> Unit)? = null,
|
onNavigateToUpgrade: (() -> Unit)? = null,
|
||||||
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
viewModel: AuthViewModel = viewModel { AuthViewModel() }
|
||||||
) {
|
) {
|
||||||
@@ -56,7 +56,6 @@ fun ProfileScreen(
|
|||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var successMessage by remember { mutableStateOf("") }
|
var successMessage by remember { mutableStateOf("") }
|
||||||
var isLoadingUser by remember { mutableStateOf(true) }
|
var isLoadingUser by remember { mutableStateOf(true) }
|
||||||
var showThemePicker by remember { mutableStateOf(false) }
|
|
||||||
var showUpgradePrompt by remember { mutableStateOf(false) }
|
var showUpgradePrompt by remember { mutableStateOf(false) }
|
||||||
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
var showDeleteAccountDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -245,7 +244,7 @@ fun ProfileScreen(
|
|||||||
OrganicCard(
|
OrganicCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { showThemePicker = true }
|
.clickable { onNavigateToThemeSelection() }
|
||||||
.naturalShadow()
|
.naturalShadow()
|
||||||
) {
|
) {
|
||||||
Row(
|
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
|
// Delete Account Dialog
|
||||||
if (showDeleteAccountDialog) {
|
if (showDeleteAccountDialog) {
|
||||||
val isSocialAuth = currentUser?.authProvider?.let {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user