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) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:56:01 -05:00
parent 1fcb456ef1
commit ee135c4673
6 changed files with 1122 additions and 433 deletions

View File

@@ -243,6 +243,13 @@
<string name="templates_expand">Expand</string>
<string name="templates_collapse">Collapse</string>
<string name="templates_add">Add</string>
<string name="templates_all_categories">All</string>
<string name="templates_apply">Apply</string>
<string name="templates_apply_count">Apply (%1$d)</string>
<string name="templates_selected_count">%1$d selected</string>
<string name="templates_retry">Retry</string>
<string name="templates_load_failed">Failed to load templates</string>
<string name="templates_create_failed">Failed to create tasks</string>
<!-- Task Completions -->
<string name="completions_title">Task Completions</string>

View File

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

View File

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

View File

@@ -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<Int>).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<String>,
selectedCategory: String?,
onCategorySelected: (String?) -> Unit,
templates: List<TaskTemplate>,
selectedIds: Set<Int>,
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<String>,
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<Int>,
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)
}

View File

@@ -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<TaskTemplatesGroupedResponse> = { force ->
APILayer.getTaskTemplatesGrouped(forceRefresh = force)
},
private val bulkCreate: suspend (BulkCreateTasksRequest) -> ApiResult<BulkCreateTasksResponse> = { req ->
APILayer.bulkCreateTasks(req)
},
private val analytics: (String, Map<String, Any>) -> Unit = { name, props ->
PostHogAnalytics.capture(name, props)
}
) : ViewModel() {
private val _templatesState =
MutableStateFlow<ApiResult<TaskTemplatesGroupedResponse>>(ApiResult.Idle)
val templatesState: StateFlow<ApiResult<TaskTemplatesGroupedResponse>> =
_templatesState.asStateFlow()
/** Category name currently filtered to. null means "All categories". */
private val _selectedCategory = MutableStateFlow<String?>(null)
val selectedCategory: StateFlow<String?> = _selectedCategory.asStateFlow()
/** Template ids the user has selected for bulk creation. */
private val _selectedTemplateIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedTemplateIds: StateFlow<Set<Int>> = _selectedTemplateIds.asStateFlow()
private val _applyState = MutableStateFlow<ApiResult<Int>>(ApiResult.Idle)
/** Success carries the count of tasks created. */
val applyState: StateFlow<ApiResult<Int>> = _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<String>
get() = (_templatesState.value as? ApiResult.Success)
?.data?.categories?.map { it.categoryName } ?: emptyList()
/** Templates filtered by [selectedCategory]. When null returns all. */
fun filteredTemplates(): List<TaskTemplate> {
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<TaskTemplate> {
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"
}
}

View File

@@ -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<TaskResponse>(),
summary = TotalSummary(
totalResidences = 1,
totalTasks = count,
totalPending = count,
totalOverdue = 0,
tasksDueNextWeek = 0,
tasksDueNextMonth = count
),
createdCount = count
)
private fun makeViewModel(
loadResult: ApiResult<TaskTemplatesGroupedResponse> = ApiResult.Success(grouped),
bulkResult: ApiResult<BulkCreateTasksResponse> = ApiResult.Success(fakeBulkResponse(2)),
onBulkCall: (BulkCreateTasksRequest) -> Unit = {},
onAnalytics: (String, Map<String, Any>) -> 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<ApiResult.Idle>(vm.templatesState.value)
assertIs<ApiResult.Idle>(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<ApiResult.Success<TaskTemplatesGroupedResponse>>(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<ApiResult.Idle>(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<ApiResult.Success<Int>>(state)
assertEquals(2, state.data)
}
@Test
fun applyFiresNonOnboardingAnalyticsWhenNotFromOnboarding() = runTest(dispatcher) {
val events = mutableListOf<Pair<String, Map<String, Any>>>()
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<Pair<String, Map<String, Any>>>()
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<ApiResult.Error>(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<ApiResult.Error>(vm.templatesState.value)
assertTrue(vm.filteredTemplates().isEmpty())
assertTrue(vm.categoryNames.isEmpty())
}
}