Add task template suggestions for quick task creation

- Add TaskTemplate model with category grouping support
- Add TaskTemplateApi for fetching templates from backend
- Add TaskSuggestionDropdown component for Android task form
- Add TaskTemplatesBrowserSheet for browsing all templates
- Add TaskSuggestionsView and TaskTemplatesBrowserView for iOS
- Update DataManager to cache task templates
- Update APILayer with template fetch and search methods
- Update TaskFormView (iOS) with template suggestions
- Update AddTaskDialog (Android) with template suggestions
- Update onboarding task view to use templates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-05 09:06:58 -06:00
parent fd8f6d612c
commit 771f5d2bd3
15 changed files with 1585 additions and 83 deletions

View File

@@ -1,14 +1,21 @@
package com.example.casera.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
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.casera.data.DataManager
import com.example.casera.models.TaskTemplate
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCategory
@@ -54,10 +61,54 @@ fun AddTaskDialog(
var dueDateError by remember { mutableStateOf(false) }
var residenceError by remember { mutableStateOf(false) }
// Get data from LookupsRepository
// Template suggestions state
var showTemplatesBrowser by remember { mutableStateOf(false) }
var showSuggestions by remember { mutableStateOf(false) }
// Get data from LookupsRepository and DataManager
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
val allTemplates by DataManager.taskTemplates.collectAsState()
// Search templates locally
val filteredSuggestions = remember(title, allTemplates) {
if (title.length >= 2) {
DataManager.searchTaskTemplates(title)
} else {
emptyList()
}
}
// Helper function to apply a task template
fun selectTaskTemplate(template: TaskTemplate) {
title = template.title
description = template.description
// Auto-select matching category by ID or name
template.categoryId?.let { catId ->
categories.find { it.id == catId }?.let {
category = it
}
} ?: template.category?.let { cat ->
categories.find { it.name.equals(cat.name, ignoreCase = true) }?.let {
category = it
}
}
// Auto-select matching frequency by ID or name
template.frequencyId?.let { freqId ->
frequencies.find { it.id == freqId }?.let {
frequency = it
}
} ?: template.frequency?.let { freq ->
frequencies.find { it.name.equals(freq.name, ignoreCase = true) }?.let {
frequency = it
}
}
showSuggestions = false
}
// Set defaults when data loads
LaunchedEffect(frequencies) {
@@ -121,21 +172,77 @@ fun AddTaskDialog(
}
}
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
} else null,
singleLine = true
)
// 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 = "Browse Task Templates",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${allTemplates.size} common tasks",
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
Column {
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
},
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
} else null,
singleLine = true
)
// Inline suggestions dropdown
if (showSuggestions && filteredSuggestions.isNotEmpty()) {
TaskSuggestionDropdown(
suggestions = filteredSuggestions,
onSelect = { template ->
selectTaskTemplate(template)
},
modifier = Modifier.fillMaxWidth()
)
}
}
// Description
OutlinedTextField(
@@ -361,6 +468,17 @@ fun AddTaskDialog(
}
}
)
// Templates browser sheet
if (showTemplatesBrowser) {
TaskTemplatesBrowserSheet(
onDismiss = { showTemplatesBrowser = false },
onSelect = { template ->
selectTaskTemplate(template)
showTemplatesBrowser = false
}
)
}
}
// Helper function to validate date format

View File

@@ -0,0 +1,154 @@
package com.example.casera.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.ChevronRight
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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 com.example.casera.models.TaskTemplate
/**
* Dropdown showing filtered task suggestions based on user input.
* Uses TaskTemplate from backend API.
*/
@Composable
fun TaskSuggestionDropdown(
suggestions: List<TaskTemplate>,
onSelect: (TaskTemplate) -> Unit,
maxSuggestions: Int = 5,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = suggestions.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
modifier = modifier
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
LazyColumn(
modifier = Modifier.heightIn(max = 250.dp)
) {
items(
items = suggestions.take(maxSuggestions),
key = { it.id }
) { template ->
TaskSuggestionItem(
template = template,
onClick = { onSelect(template) }
)
if (template != suggestions.take(maxSuggestions).last()) {
HorizontalDivider(
modifier = Modifier.padding(start = 52.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
}
}
}
@Composable
private fun TaskSuggestionItem(
template: TaskTemplate,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Category-colored icon placeholder
Surface(
modifier = Modifier.size(28.dp),
shape = MaterialTheme.shapes.small,
color = getCategoryColor(template.categoryName.lowercase())
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = template.title.first().toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
// Task info
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = template.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = template.categoryName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = template.frequencyDisplay,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Chevron
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
internal fun getCategoryColor(category: String): androidx.compose.ui.graphics.Color {
return when (category.lowercase()) {
"plumbing" -> MaterialTheme.colorScheme.secondary
"safety", "electrical" -> MaterialTheme.colorScheme.error
"hvac" -> MaterialTheme.colorScheme.primary
"appliances" -> MaterialTheme.colorScheme.tertiary
"exterior", "lawn & garden" -> androidx.compose.ui.graphics.Color(0xFF34C759)
"interior" -> androidx.compose.ui.graphics.Color(0xFFAF52DE)
"general", "seasonal" -> androidx.compose.ui.graphics.Color(0xFFFF9500)
else -> MaterialTheme.colorScheme.primary
}
}

View File

@@ -0,0 +1,368 @@
package com.example.casera.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 com.example.casera.data.DataManager
import com.example.casera.models.TaskTemplate
import com.example.casera.models.TaskTemplateCategoryGroup
/**
* 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 = "Task Templates",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
TextButton(onClick = onDismiss) {
Text("Done")
}
}
// Search bar
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search templates...") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(Icons.Default.Clear, contentDescription = "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 {
Text(
text = "${filteredTemplates.size} ${if (filteredTemplates.size == 1) "result" else "results"}",
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 = if (isExpanded) "Collapse" else "Expand",
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 = "Add",
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 = "No Templates Found",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Try a different search term",
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 = "No Templates Available",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Templates will appear here once loaded",
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
}
}