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