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_expand">Expand</string>
|
||||||
<string name="templates_collapse">Collapse</string>
|
<string name="templates_collapse">Collapse</string>
|
||||||
<string name="templates_add">Add</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 -->
|
<!-- Task Completions -->
|
||||||
<string name="completions_title">Task Completions</string>
|
<string name="completions_title">Task Completions</string>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package com.tt.honeyDue.ui.components
|
package com.tt.honeyDue.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -65,8 +61,8 @@ fun AddTaskDialog(
|
|||||||
var dueDateError by remember { mutableStateOf(false) }
|
var dueDateError by remember { mutableStateOf(false) }
|
||||||
var residenceError by remember { mutableStateOf(false) }
|
var residenceError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Template suggestions state
|
// Template suggestions state (inline search only — full-screen browse
|
||||||
var showTemplatesBrowser by remember { mutableStateOf(false) }
|
// lives in TaskTemplatesBrowserScreen).
|
||||||
var showSuggestions by remember { mutableStateOf(false) }
|
var showSuggestions by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Get data from LookupsRepository and DataManager
|
// Get data from LookupsRepository and DataManager
|
||||||
@@ -181,47 +177,9 @@ fun AddTaskDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browse Templates Button
|
// Note: full-screen template browsing (multi-select + bulk
|
||||||
Card(
|
// create) now lives in TaskTemplatesBrowserScreen; this
|
||||||
modifier = Modifier
|
// dialog keeps only inline suggestions while typing the title.
|
||||||
.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))
|
|
||||||
|
|
||||||
// Title with inline suggestions
|
// Title with inline suggestions
|
||||||
Column {
|
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
|
// 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