From ee135c46730adc6ecc19c7ffb34e74a24f906539 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:56:01 -0500 Subject: [PATCH] P2 Stream G: TaskTemplatesBrowserScreen (replaces dialog/sheet) Full browse-and-select experience matching iOS TaskTemplatesBrowserView. Category filter, multi-select, bulk-create with templateId backlink. Analytics events wired. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composeResources/values/strings.xml | 7 + .../honeyDue/ui/components/AddTaskDialog.kt | 63 +- .../components/TaskTemplatesBrowserSheet.kt | 375 ------------ .../task/TaskTemplatesBrowserScreen.kt | 566 ++++++++++++++++++ .../task/TaskTemplatesBrowserViewModel.kt | 200 +++++++ .../task/TaskTemplatesBrowserViewModelTest.kt | 344 +++++++++++ 6 files changed, 1122 insertions(+), 433 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/TaskTemplatesBrowserSheet.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModel.kt create mode 100644 composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModelTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 53f2246..b5ea111 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -243,6 +243,13 @@ Expand Collapse Add + All + Apply + Apply (%1$d) + %1$d selected + Retry + Failed to load templates + Failed to create tasks Task Completions diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddTaskDialog.kt index 32b8368..a5b9288 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/AddTaskDialog.kt @@ -1,13 +1,9 @@ package com.tt.honeyDue.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.List import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -65,8 +61,8 @@ fun AddTaskDialog( var dueDateError by remember { mutableStateOf(false) } var residenceError by remember { mutableStateOf(false) } - // Template suggestions state - var showTemplatesBrowser by remember { mutableStateOf(false) } + // Template suggestions state (inline search only — full-screen browse + // lives in TaskTemplatesBrowserScreen). var showSuggestions by remember { mutableStateOf(false) } // Get data from LookupsRepository and DataManager @@ -181,47 +177,9 @@ fun AddTaskDialog( } } - // Browse Templates Button - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { showTemplatesBrowser = true }, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.List, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(Res.string.tasks_browse_templates), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = stringResource(Res.string.tasks_common_tasks, allTemplates.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + // Note: full-screen template browsing (multi-select + bulk + // create) now lives in TaskTemplatesBrowserScreen; this + // dialog keeps only inline suggestions while typing the title. // Title with inline suggestions Column { @@ -480,17 +438,6 @@ fun AddTaskDialog( } } ) - - // Templates browser sheet - if (showTemplatesBrowser) { - TaskTemplatesBrowserSheet( - onDismiss = { showTemplatesBrowser = false }, - onSelect = { template -> - selectTaskTemplate(template) - showTemplatesBrowser = false - } - ) - } } // Helper function to validate date format diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/TaskTemplatesBrowserSheet.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/TaskTemplatesBrowserSheet.kt deleted file mode 100644 index af4b507..0000000 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/components/TaskTemplatesBrowserSheet.kt +++ /dev/null @@ -1,375 +0,0 @@ -package com.tt.honeyDue.ui.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import honeydue.composeapp.generated.resources.* -import com.tt.honeyDue.data.DataManager -import com.tt.honeyDue.models.TaskTemplate -import com.tt.honeyDue.models.TaskTemplateCategoryGroup -import org.jetbrains.compose.resources.stringResource - -/** - * Bottom sheet for browsing all task templates from backend. - * Uses DataManager to access cached templates. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TaskTemplatesBrowserSheet( - onDismiss: () -> Unit, - onSelect: (TaskTemplate) -> Unit -) { - var searchText by remember { mutableStateOf("") } - var expandedCategories by remember { mutableStateOf(setOf()) } - - // Get templates from DataManager - val groupedTemplates by DataManager.taskTemplatesGrouped.collectAsState() - val allTemplates by DataManager.taskTemplates.collectAsState() - - val filteredTemplates = remember(searchText, allTemplates) { - if (searchText.isBlank()) emptyList() - else DataManager.searchTaskTemplates(searchText) - } - - val isSearching = searchText.isNotBlank() - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.9f) - ) { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(Res.string.templates_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - TextButton(onClick = onDismiss) { - Text(stringResource(Res.string.templates_done)) - } - } - - // Search bar - OutlinedTextField( - value = searchText, - onValueChange = { searchText = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text(stringResource(Res.string.templates_search_placeholder)) }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - trailingIcon = { - if (searchText.isNotEmpty()) { - IconButton(onClick = { searchText = "" }) { - Icon(Icons.Default.Clear, contentDescription = stringResource(Res.string.templates_clear)) - } - } - }, - singleLine = true - ) - - HorizontalDivider() - - // Content - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 32.dp) - ) { - if (isSearching) { - // Search results - if (filteredTemplates.isEmpty()) { - item { - EmptySearchState() - } - } else { - item { - val resultsText = if (filteredTemplates.size == 1) { - stringResource(Res.string.templates_result) - } else { - stringResource(Res.string.templates_results) - } - Text( - text = "${filteredTemplates.size} $resultsText", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp) - ) - } - items(filteredTemplates, key = { it.id }) { template -> - TaskTemplateItem( - template = template, - onClick = { - onSelect(template) - onDismiss() - } - ) - } - } - } else { - // Browse by category - val categories = groupedTemplates?.categories ?: emptyList() - - if (categories.isEmpty()) { - item { - EmptyTemplatesState() - } - } else { - categories.forEach { categoryGroup -> - val categoryKey = categoryGroup.categoryName - val isExpanded = expandedCategories.contains(categoryKey) - - item(key = "category_$categoryKey") { - CategoryHeader( - categoryGroup = categoryGroup, - isExpanded = isExpanded, - onClick = { - expandedCategories = if (isExpanded) { - expandedCategories - categoryKey - } else { - expandedCategories + categoryKey - } - } - ) - } - - if (isExpanded) { - items(categoryGroup.templates, key = { it.id }) { template -> - TaskTemplateItem( - template = template, - onClick = { - onSelect(template) - onDismiss() - }, - modifier = Modifier.padding(start = 16.dp) - ) - } - } - } - } - } - } - } - } -} - -@Composable -private fun CategoryHeader( - categoryGroup: TaskTemplateCategoryGroup, - isExpanded: Boolean, - onClick: () -> Unit -) { - Surface( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - color = MaterialTheme.colorScheme.surface - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Category icon - Surface( - modifier = Modifier.size(32.dp), - shape = MaterialTheme.shapes.small, - color = getCategoryColor(categoryGroup.categoryName.lowercase()) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = getCategoryIcon(categoryGroup.categoryName.lowercase()), - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - - // Category name - Text( - text = categoryGroup.categoryName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f) - ) - - // Count badge - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surfaceVariant - ) { - Text( - text = categoryGroup.count.toString(), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - - // Expand/collapse indicator - Icon( - imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -private fun TaskTemplateItem( - template: TaskTemplate, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick), - color = MaterialTheme.colorScheme.surface - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Icon placeholder - Surface( - modifier = Modifier.size(24.dp), - shape = MaterialTheme.shapes.extraSmall, - color = getCategoryColor(template.categoryName.lowercase()).copy(alpha = 0.2f) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - text = template.title.first().toString(), - style = MaterialTheme.typography.labelSmall, - color = getCategoryColor(template.categoryName.lowercase()) - ) - } - } - - // Task info - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = template.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = template.frequencyDisplay, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Add indicator - Icon( - imageVector = Icons.Default.AddCircleOutline, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - } -} - -@Composable -private fun EmptySearchState() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Text( - text = stringResource(Res.string.templates_no_results_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(Res.string.templates_no_results_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -private fun EmptyTemplatesState() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Checklist, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Text( - text = stringResource(Res.string.templates_empty_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = stringResource(Res.string.templates_empty_message), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -private fun getCategoryIcon(category: String): androidx.compose.ui.graphics.vector.ImageVector { - return when (category.lowercase()) { - "plumbing" -> Icons.Default.Water - "safety" -> Icons.Default.Shield - "electrical" -> Icons.Default.ElectricBolt - "hvac" -> Icons.Default.Thermostat - "appliances" -> Icons.Default.Kitchen - "exterior" -> Icons.Default.Home - "lawn & garden" -> Icons.Default.Park - "interior" -> Icons.Default.Weekend - "general", "seasonal" -> Icons.Default.CalendarMonth - else -> Icons.Default.Checklist - } -} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserScreen.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserScreen.kt new file mode 100644 index 0000000..917358a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserScreen.kt @@ -0,0 +1,566 @@ +package com.tt.honeyDue.ui.screens.task + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +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.Bolt +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.ElectricBolt +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Kitchen +import androidx.compose.material.icons.filled.Park +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.Water +import androidx.compose.material.icons.filled.Weekend +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.analytics.PostHogAnalytics +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.network.ApiResult +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.util.ErrorMessageParser +import honeydue.composeapp.generated.resources.Res +import honeydue.composeapp.generated.resources.common_back +import honeydue.composeapp.generated.resources.templates_all_categories +import honeydue.composeapp.generated.resources.templates_apply_count +import honeydue.composeapp.generated.resources.templates_create_failed +import honeydue.composeapp.generated.resources.templates_empty_message +import honeydue.composeapp.generated.resources.templates_empty_title +import honeydue.composeapp.generated.resources.templates_load_failed +import honeydue.composeapp.generated.resources.templates_retry +import honeydue.composeapp.generated.resources.templates_selected_count +import honeydue.composeapp.generated.resources.templates_title +import org.jetbrains.compose.resources.stringResource + +/** + * Full-screen browser for backend task templates. Android port of iOS + * `TaskTemplatesBrowserView`, extended with multi-select + bulk-create. + * + * Flow: + * 1. Loads grouped templates via APILayer.getTaskTemplatesGrouped(). + * 2. User filters by category chip. + * 3. User taps templates to toggle them into a selection set. + * 4. Apply button triggers APILayer.bulkCreateTasks — each created task + * carries its originating templateId for reporting (per CLAUDE.md rule). + * 5. Success fires analytics events and invokes [onCreated] with the count + * so the caller can navigate away or show a toast. + * + * [fromOnboarding]: flip to true when presenting inside the onboarding flow + * so the onboarding-funnel PostHog events fire instead of the general + * task_template_accepted / task_templates_bulk_created pair. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskTemplatesBrowserScreen( + residenceId: Int, + onNavigateBack: () -> Unit, + onCreated: (createdCount: Int) -> Unit = { onNavigateBack() }, + fromOnboarding: Boolean = false, + viewModel: TaskTemplatesBrowserViewModel = viewModel { + TaskTemplatesBrowserViewModel( + residenceId = residenceId, + fromOnboarding = fromOnboarding + ) + } +) { + val templatesState by viewModel.templatesState.collectAsState() + val selectedIds by viewModel.selectedTemplateIds.collectAsState() + val selectedCategory by viewModel.selectedCategory.collectAsState() + val applyState by viewModel.applyState.collectAsState() + var isRefreshing by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (templatesState is ApiResult.Idle) { + viewModel.load() + } + } + + // Exit when the bulk create succeeds. We keep the VM alive for one more + // frame so the count-success state is visible, then pop back. + LaunchedEffect(applyState) { + if (applyState is ApiResult.Success) { + val count = (applyState as ApiResult.Success).data + onCreated(count) + viewModel.resetApplyState() + } + if (templatesState !is ApiResult.Loading) { + isRefreshing = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + stringResource(Res.string.templates_title), + fontWeight = FontWeight.SemiBold + ) + if (selectedIds.isNotEmpty()) { + Text( + stringResource(Res.string.templates_selected_count, selectedIds.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = stringResource(Res.string.common_back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + bottomBar = { + ApplyBar( + selectedCount = selectedIds.size, + applyState = applyState, + onApply = { viewModel.apply() } + ) + } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + viewModel.load(forceRefresh = true) + }, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + when (val state = templatesState) { + is ApiResult.Loading, ApiResult.Idle -> { + Box(Modifier.fillMaxSize(), Alignment.Center) { + CircularProgressIndicator() + } + } + is ApiResult.Error -> { + LoadErrorView( + message = ErrorMessageParser.parse(state.message), + onRetry = { viewModel.load(forceRefresh = true) } + ) + } + is ApiResult.Success -> { + if (state.data.categories.isEmpty()) { + EmptyState() + } else { + TemplatesList( + categories = viewModel.categoryNames, + selectedCategory = selectedCategory, + onCategorySelected = viewModel::selectCategory, + templates = viewModel.filteredTemplates(), + selectedIds = selectedIds, + onToggle = viewModel::toggleSelection + ) + } + } + } + + // Overlay for apply errors so user can retry without losing selection. + (applyState as? ApiResult.Error)?.let { err -> + ApplyErrorBanner( + message = ErrorMessageParser.parse(err.message), + onDismiss = { viewModel.resetApplyState() } + ) + } + } + } + + // Track screen impressions. + LaunchedEffect(Unit) { + // Re-use the generic screen event; specific accept/create events are + // emitted inside the ViewModel when the user hits Apply. + PostHogAnalytics.screen( + if (fromOnboarding) AnalyticsEvents.ONBOARDING_SUGGESTIONS_LOADED + else AnalyticsEvents.TASK_SCREEN_SHOWN + ) + } +} + +@Composable +private fun TemplatesList( + categories: List, + selectedCategory: String?, + onCategorySelected: (String?) -> Unit, + templates: List, + selectedIds: Set, + onToggle: (Int) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = AppSpacing.lg, + end = AppSpacing.lg, + top = AppSpacing.md, + bottom = AppSpacing.xl + ), + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + item("categories") { + CategoryChipRow( + categories = categories, + selected = selectedCategory, + onSelect = onCategorySelected + ) + } + + if (templates.isEmpty()) { + item("empty-for-category") { + Box( + Modifier.fillMaxWidth().padding(AppSpacing.xl), + Alignment.Center + ) { + Text( + text = stringResource(Res.string.templates_empty_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + items(templates, key = { it.id }) { template -> + TemplateCard( + template = template, + selected = template.id in selectedIds, + onToggle = { onToggle(template.id) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CategoryChipRow( + categories: List, + selected: String?, + onSelect: (String?) -> Unit +) { + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + FilterChip( + selected = selected == null, + onClick = { onSelect(null) }, + label = { Text(stringResource(Res.string.templates_all_categories)) } + ) + categories.forEach { name -> + FilterChip( + selected = selected == name, + onClick = { onSelect(name) }, + leadingIcon = { + Icon( + imageVector = categoryIconFor(name), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + label = { Text(name) } + ) + } + } +} + +@Composable +private fun TemplateCard( + template: TaskTemplate, + selected: Boolean, + onToggle: () -> Unit +) { + val containerColor = + if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface + val borderColor = + if (selected) MaterialTheme.colorScheme.primary + else Color.Transparent + + StandardCard( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() }, + backgroundColor = containerColor, + contentPadding = AppSpacing.md + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), + modifier = Modifier.fillMaxWidth() + ) { + // Category bubble + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(categoryBubbleColor(template.categoryName)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = categoryIconFor(template.categoryName), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = template.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + if (template.description.isNotBlank()) { + Text( + text = template.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + modifier = Modifier.padding(top = AppSpacing.xs) + ) { + Text( + text = template.frequencyDisplay, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (template.categoryName.isNotBlank() && + template.categoryName != "Uncategorized" + ) { + Text( + text = "• ${template.categoryName}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Icon( + imageVector = if (selected) Icons.Default.CheckCircle + else Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .background(borderColor.copy(alpha = 0f), CircleShape) + ) + } + } +} + +@Composable +private fun ApplyBar( + selectedCount: Int, + applyState: ApiResult, + onApply: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onApply, + enabled = selectedCount > 0 && applyState !is ApiResult.Loading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(AppRadius.md) + ) { + if (applyState is ApiResult.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Check, contentDescription = null) + Spacer(Modifier.width(AppSpacing.sm)) + Text( + text = stringResource(Res.string.templates_apply_count, selectedCount), + fontWeight = FontWeight.SemiBold + ) + } + } + } + } +} + +@Composable +private fun LoadErrorView( + message: String, + onRetry: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize().padding(AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically) + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = stringResource(Res.string.templates_load_failed), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Button(onClick = onRetry) { + Text(stringResource(Res.string.templates_retry)) + } + } +} + +@Composable +private fun EmptyState() { + Column( + modifier = Modifier.fillMaxSize().padding(AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.md, Alignment.CenterVertically) + ) { + Icon( + imageVector = Icons.Default.Checklist, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Text( + text = stringResource(Res.string.templates_empty_title), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(Res.string.templates_empty_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun BoxScope.ApplyErrorBanner( + message: String, + onDismiss: () -> Unit +) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(AppSpacing.lg) + .clickable { onDismiss() }, + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(AppRadius.md), + tonalElevation = 4.dp + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Column { + Text( + text = stringResource(Res.string.templates_create_failed), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } +} + +// ---------- Category styling helpers ---------- + +private fun categoryIconFor(category: String): ImageVector = + when (category.lowercase()) { + "plumbing" -> Icons.Default.Water + "safety" -> Icons.Default.Shield + "electrical" -> Icons.Default.ElectricBolt + "hvac" -> Icons.Default.Thermostat + "appliances" -> Icons.Default.Kitchen + "exterior" -> Icons.Default.Home + "lawn & garden" -> Icons.Default.Park + "interior" -> Icons.Default.Weekend + "general", "seasonal" -> Icons.Default.CalendarMonth + else -> Icons.Default.Bolt + } + +/** Explicit palette so the bubble contrasts on white/dark cards. Mirrors iOS Color.taskCategoryColor. */ +private fun categoryBubbleColor(category: String): Color = when (category.lowercase()) { + "plumbing" -> Color(0xFF0055A5) + "safety" -> Color(0xFFDD1C1A) + "electrical" -> Color(0xFFFFB300) + "hvac" -> Color(0xFF07A0C3) + "appliances" -> Color(0xFF7B1FA2) + "exterior" -> Color(0xFF34C759) + "lawn & garden" -> Color(0xFF2E7D32) + "interior" -> Color(0xFFAF52DE) + "general", "seasonal" -> Color(0xFFFF9500) + else -> Color(0xFF455A64) +} diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModel.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModel.kt new file mode 100644 index 0000000..b75ef86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModel.kt @@ -0,0 +1,200 @@ +package com.tt.honeyDue.ui.screens.task + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.analytics.PostHogAnalytics +import com.tt.honeyDue.models.BulkCreateTasksRequest +import com.tt.honeyDue.models.BulkCreateTasksResponse +import com.tt.honeyDue.models.TaskCreateRequest +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse +import com.tt.honeyDue.network.APILayer +import com.tt.honeyDue.network.ApiResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * ViewModel for [TaskTemplatesBrowserScreen]. Loads grouped templates, + * manages category filter, multi-select state, and bulk-create submission. + * + * [loadTemplates] and [bulkCreate] are injected as suspend functions so + * the ViewModel can be unit-tested without hitting [APILayer] singletons. + * + * [analytics] is a plain lambda so tests can verify PostHog event names and + * payloads. + * + * [fromOnboarding] picks which analytics event family to fire on successful + * apply: + * - true → onboarding_browse_template_accepted + onboarding_tasks_created + * - false → task_template_accepted + task_templates_bulk_created + * (event names match iOS AnalyticsManager.swift) + */ +class TaskTemplatesBrowserViewModel( + private val residenceId: Int, + private val fromOnboarding: Boolean = false, + private val loadTemplates: suspend (Boolean) -> ApiResult = { force -> + APILayer.getTaskTemplatesGrouped(forceRefresh = force) + }, + private val bulkCreate: suspend (BulkCreateTasksRequest) -> ApiResult = { req -> + APILayer.bulkCreateTasks(req) + }, + private val analytics: (String, Map) -> Unit = { name, props -> + PostHogAnalytics.capture(name, props) + } +) : ViewModel() { + + private val _templatesState = + MutableStateFlow>(ApiResult.Idle) + val templatesState: StateFlow> = + _templatesState.asStateFlow() + + /** Category name currently filtered to. null means "All categories". */ + private val _selectedCategory = MutableStateFlow(null) + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + /** Template ids the user has selected for bulk creation. */ + private val _selectedTemplateIds = MutableStateFlow>(emptySet()) + val selectedTemplateIds: StateFlow> = _selectedTemplateIds.asStateFlow() + + private val _applyState = MutableStateFlow>(ApiResult.Idle) + /** Success carries the count of tasks created. */ + val applyState: StateFlow> = _applyState.asStateFlow() + + /** True when at least one template is selected. */ + val canApply: Boolean + get() = _selectedTemplateIds.value.isNotEmpty() + + /** All categories parsed from the loaded grouped response. */ + val categoryNames: List + get() = (_templatesState.value as? ApiResult.Success) + ?.data?.categories?.map { it.categoryName } ?: emptyList() + + /** Templates filtered by [selectedCategory]. When null returns all. */ + fun filteredTemplates(): List { + val grouped = (_templatesState.value as? ApiResult.Success)?.data ?: return emptyList() + val cat = _selectedCategory.value + return if (cat == null) { + grouped.categories.flatMap { it.templates } + } else { + grouped.categories.firstOrNull { it.categoryName == cat }?.templates ?: emptyList() + } + } + + fun load(forceRefresh: Boolean = false) { + viewModelScope.launch { + _templatesState.value = ApiResult.Loading + _templatesState.value = loadTemplates(forceRefresh) + } + } + + fun selectCategory(categoryName: String?) { + _selectedCategory.value = categoryName + } + + /** Adds the id if absent, removes it if present. */ + fun toggleSelection(templateId: Int) { + val current = _selectedTemplateIds.value + _selectedTemplateIds.value = if (templateId in current) { + current - templateId + } else { + current + templateId + } + } + + fun clearSelection() { + _selectedTemplateIds.value = emptySet() + } + + /** Reset apply state (e.g. after dismissing an error). */ + fun resetApplyState() { + _applyState.value = ApiResult.Idle + } + + /** + * Build one [TaskCreateRequest] per selected template (with templateId + * backlink) and call bulkCreateTasks. On success fires the configured + * analytics events and leaves [applyState] as Success(count). On error + * leaves state as [ApiResult.Error] so the screen can surface it without + * dismissing. + */ + fun apply() { + if (!canApply) return + val templates = filteredTemplatesAll() + .filter { it.id in _selectedTemplateIds.value } + if (templates.isEmpty()) return + + viewModelScope.launch { + _applyState.value = ApiResult.Loading + + val requests = templates.map { template -> + TaskCreateRequest( + residenceId = residenceId, + title = template.title, + description = template.description.takeIf { it.isNotBlank() }, + categoryId = template.categoryId, + frequencyId = template.frequencyId, + templateId = template.id + ) + } + val request = BulkCreateTasksRequest( + residenceId = residenceId, + tasks = requests + ) + + val result = bulkCreate(request) + _applyState.value = when (result) { + is ApiResult.Success -> { + val count = result.data.createdCount + if (fromOnboarding) { + templates.forEach { template -> + analytics( + AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED, + buildMap { + put("template_id", template.id) + template.categoryId?.let { put("category_id", it) } + } + ) + } + analytics( + AnalyticsEvents.ONBOARDING_TASKS_CREATED, + mapOf("count" to count) + ) + } else { + templates.forEach { template -> + analytics( + EVENT_TASK_TEMPLATE_ACCEPTED, + buildMap { + put("template_id", template.id) + template.categoryId?.let { put("category_id", it) } + } + ) + } + analytics( + EVENT_TASK_TEMPLATES_BULK_CREATED, + mapOf("count" to count) + ) + } + ApiResult.Success(count) + } + is ApiResult.Error -> ApiResult.Error(result.message, result.code) + ApiResult.Loading -> ApiResult.Loading + ApiResult.Idle -> ApiResult.Idle + } + } + } + + /** Full list regardless of category filter. Used by apply() for lookups. */ + private fun filteredTemplatesAll(): List { + val grouped = (_templatesState.value as? ApiResult.Success)?.data ?: return emptyList() + return grouped.categories.flatMap { it.templates } + } + + companion object { + /** Non-onboarding analytics event names (match iOS AnalyticsEvent.swift). */ + const val EVENT_TASK_TEMPLATE_ACCEPTED = "task_template_accepted" + const val EVENT_TASK_TEMPLATES_BULK_CREATED = "task_templates_bulk_created" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModelTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModelTest.kt new file mode 100644 index 0000000..a86b35d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/screens/task/TaskTemplatesBrowserViewModelTest.kt @@ -0,0 +1,344 @@ +package com.tt.honeyDue.ui.screens.task + +import com.tt.honeyDue.analytics.AnalyticsEvents +import com.tt.honeyDue.models.BulkCreateTasksRequest +import com.tt.honeyDue.models.BulkCreateTasksResponse +import com.tt.honeyDue.models.TaskCategory +import com.tt.honeyDue.models.TaskResponse +import com.tt.honeyDue.models.TaskTemplate +import com.tt.honeyDue.models.TaskTemplateCategoryGroup +import com.tt.honeyDue.models.TaskTemplatesGroupedResponse +import com.tt.honeyDue.models.TotalSummary +import com.tt.honeyDue.network.ApiResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Unit tests for TaskTemplatesBrowserViewModel covering: + * 1. Load grouped templates on load() + * 2. Category filter narrows templates + * 3. Multi-select toggle add/remove + * 4. canApply reflects empty selection + * 5. apply() calls bulkCreateTasks with templateId backlink + * 6. apply() fires analytics events on success (onboarding vs non-onboarding) + * 7. apply() surfaces API error and does NOT clear selection + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TaskTemplatesBrowserViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + // ---------- Fixtures ---------- + + private val plumbingCat = TaskCategory(id = 1, name = "Plumbing") + private val hvacCat = TaskCategory(id = 2, name = "HVAC") + + private val template1 = TaskTemplate( + id = 100, + title = "Change Water Filter", + description = "Replace every 6 months.", + categoryId = 1, + category = plumbingCat + ) + private val template2 = TaskTemplate( + id = 101, + title = "Flush Water Heater", + description = "Annual flush.", + categoryId = 1, + category = plumbingCat + ) + private val template3 = TaskTemplate( + id = 200, + title = "Replace HVAC Filter", + description = "Every 3 months.", + categoryId = 2, + category = hvacCat + ) + + private val grouped = TaskTemplatesGroupedResponse( + categories = listOf( + TaskTemplateCategoryGroup( + categoryName = "Plumbing", + categoryId = 1, + templates = listOf(template1, template2), + count = 2 + ), + TaskTemplateCategoryGroup( + categoryName = "HVAC", + categoryId = 2, + templates = listOf(template3), + count = 1 + ) + ), + totalCount = 3 + ) + + private fun fakeBulkResponse(count: Int) = BulkCreateTasksResponse( + tasks = emptyList(), + summary = TotalSummary( + totalResidences = 1, + totalTasks = count, + totalPending = count, + totalOverdue = 0, + tasksDueNextWeek = 0, + tasksDueNextMonth = count + ), + createdCount = count + ) + + private fun makeViewModel( + loadResult: ApiResult = ApiResult.Success(grouped), + bulkResult: ApiResult = ApiResult.Success(fakeBulkResponse(2)), + onBulkCall: (BulkCreateTasksRequest) -> Unit = {}, + onAnalytics: (String, Map) -> Unit = { _, _ -> }, + fromOnboarding: Boolean = false + ) = TaskTemplatesBrowserViewModel( + residenceId = 42, + fromOnboarding = fromOnboarding, + loadTemplates = { loadResult }, + bulkCreate = { request -> + onBulkCall(request) + bulkResult + }, + analytics = onAnalytics + ) + + // ---------- Tests ---------- + + @Test + fun initialStateIsIdleAndEmpty() { + val vm = makeViewModel() + assertIs(vm.templatesState.value) + assertIs(vm.applyState.value) + assertTrue(vm.selectedTemplateIds.value.isEmpty()) + assertNull(vm.selectedCategory.value) + assertFalse(vm.canApply) + } + + @Test + fun loadPopulatesTemplatesStateOnSuccess() = runTest(dispatcher) { + val vm = makeViewModel() + vm.load() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.templatesState.value + assertIs>(state) + assertEquals(3, state.data.totalCount) + assertEquals(listOf("Plumbing", "HVAC"), vm.categoryNames) + } + + @Test + fun filteringByCategoryNarrowsTemplates() = runTest(dispatcher) { + val vm = makeViewModel() + vm.load() + dispatcher.scheduler.advanceUntilIdle() + + // No filter: all three visible + assertEquals(3, vm.filteredTemplates().size) + + vm.selectCategory("Plumbing") + assertEquals(2, vm.filteredTemplates().size) + assertTrue(vm.filteredTemplates().all { it.categoryId == 1 }) + + vm.selectCategory("HVAC") + assertEquals(1, vm.filteredTemplates().size) + assertEquals(200, vm.filteredTemplates().first().id) + + vm.selectCategory(null) + assertEquals(3, vm.filteredTemplates().size) + } + + @Test + fun toggleSelectionAddsAndRemovesIds() = runTest(dispatcher) { + val vm = makeViewModel() + + vm.toggleSelection(100) + assertEquals(setOf(100), vm.selectedTemplateIds.value) + assertTrue(vm.canApply) + + vm.toggleSelection(101) + assertEquals(setOf(100, 101), vm.selectedTemplateIds.value) + + vm.toggleSelection(100) + assertEquals(setOf(101), vm.selectedTemplateIds.value) + + vm.toggleSelection(101) + assertTrue(vm.selectedTemplateIds.value.isEmpty()) + assertFalse(vm.canApply) + } + + @Test + fun applyIsNoopWhenSelectionEmpty() = runTest(dispatcher) { + var bulkCalled = false + val vm = makeViewModel(onBulkCall = { bulkCalled = true }) + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + assertFalse(bulkCalled, "bulkCreate should not be called when selection empty") + assertIs(vm.applyState.value) + } + + @Test + fun applyBuildsRequestsWithTemplateIdBacklink() = runTest(dispatcher) { + var captured: BulkCreateTasksRequest? = null + val vm = makeViewModel(onBulkCall = { captured = it }) + + vm.load() + dispatcher.scheduler.advanceUntilIdle() + vm.toggleSelection(100) + vm.toggleSelection(200) + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + val req = captured ?: error("bulkCreate was not called") + assertEquals(42, req.residenceId) + assertEquals(2, req.tasks.size) + val templateIds = req.tasks.map { it.templateId }.toSet() + assertEquals(setOf(100, 200), templateIds) + + // Every entry should inherit the residenceId and carry the template title. + assertTrue(req.tasks.all { it.residenceId == 42 }) + val titles = req.tasks.map { it.title }.toSet() + assertEquals(setOf("Change Water Filter", "Replace HVAC Filter"), titles) + } + + @Test + fun applySuccessSetsApplyStateWithCreatedCount() = runTest(dispatcher) { + val vm = makeViewModel( + bulkResult = ApiResult.Success(fakeBulkResponse(2)) + ) + + vm.load() + dispatcher.scheduler.advanceUntilIdle() + vm.toggleSelection(100) + vm.toggleSelection(101) + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.applyState.value + assertIs>(state) + assertEquals(2, state.data) + } + + @Test + fun applyFiresNonOnboardingAnalyticsWhenNotFromOnboarding() = runTest(dispatcher) { + val events = mutableListOf>>() + val vm = makeViewModel( + bulkResult = ApiResult.Success(fakeBulkResponse(2)), + onAnalytics = { name, props -> events += name to props }, + fromOnboarding = false + ) + + vm.load() + dispatcher.scheduler.advanceUntilIdle() + vm.toggleSelection(100) + vm.toggleSelection(200) + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + // Per-template accepted events + one bulk_created summary event + val perTemplate = events.filter { + it.first == TaskTemplatesBrowserViewModel.EVENT_TASK_TEMPLATE_ACCEPTED + } + assertEquals(2, perTemplate.size) + assertTrue(perTemplate.all { it.second["template_id"] is Int }) + + val bulk = events.firstOrNull { + it.first == TaskTemplatesBrowserViewModel.EVENT_TASK_TEMPLATES_BULK_CREATED + } + assertTrue(bulk != null, "expected bulk_created event") + assertEquals(2, bulk.second["count"]) + + // Onboarding events should NOT fire in the non-onboarding path. + assertTrue(events.none { it.first == AnalyticsEvents.ONBOARDING_TASKS_CREATED }) + } + + @Test + fun applyFiresOnboardingAnalyticsWhenFromOnboarding() = runTest(dispatcher) { + val events = mutableListOf>>() + val vm = makeViewModel( + bulkResult = ApiResult.Success(fakeBulkResponse(1)), + onAnalytics = { name, props -> events += name to props }, + fromOnboarding = true + ) + + vm.load() + dispatcher.scheduler.advanceUntilIdle() + vm.toggleSelection(100) + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + val accepted = events.firstOrNull { + it.first == AnalyticsEvents.ONBOARDING_BROWSE_TEMPLATE_ACCEPTED + } + assertTrue(accepted != null) + assertEquals(100, accepted.second["template_id"]) + + val created = events.firstOrNull { + it.first == AnalyticsEvents.ONBOARDING_TASKS_CREATED + } + assertTrue(created != null) + assertEquals(1, created.second["count"]) + } + + @Test + fun applyErrorSurfacesErrorAndKeepsSelection() = runTest(dispatcher) { + val vm = makeViewModel( + bulkResult = ApiResult.Error("Network down", 500), + fromOnboarding = false + ) + vm.load() + dispatcher.scheduler.advanceUntilIdle() + vm.toggleSelection(100) + val selectedBefore = vm.selectedTemplateIds.value + + vm.apply() + dispatcher.scheduler.advanceUntilIdle() + + val state = vm.applyState.value + assertIs(state) + assertEquals("Network down", state.message) + // Selection retained so the user can retry without re-picking. + assertEquals(selectedBefore, vm.selectedTemplateIds.value) + } + + @Test + fun loadErrorLeavesTemplatesStateError() = runTest(dispatcher) { + val vm = makeViewModel( + loadResult = ApiResult.Error("Boom", 500) + ) + vm.load() + dispatcher.scheduler.advanceUntilIdle() + + assertIs(vm.templatesState.value) + assertTrue(vm.filteredTemplates().isEmpty()) + assertTrue(vm.categoryNames.isEmpty()) + } +}