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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user