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

@@ -167,6 +167,14 @@ object DataManager {
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList()) private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow() val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
// ==================== TASK TEMPLATES ====================
private val _taskTemplates = MutableStateFlow<List<TaskTemplate>>(emptyList())
val taskTemplates: StateFlow<List<TaskTemplate>> = _taskTemplates.asStateFlow()
private val _taskTemplatesGrouped = MutableStateFlow<TaskTemplatesGroupedResponse?>(null)
val taskTemplatesGrouped: StateFlow<TaskTemplatesGroupedResponse?> = _taskTemplatesGrouped.asStateFlow()
// Map-based for O(1) ID resolution // Map-based for O(1) ID resolution
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap()) private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow() val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
@@ -538,6 +546,34 @@ object DataManager {
persistToDisk() persistToDisk()
} }
// ==================== TASK TEMPLATE UPDATE METHODS ====================
fun setTaskTemplates(templates: List<TaskTemplate>) {
_taskTemplates.value = templates
// Don't persist - these are fetched fresh from API
}
fun setTaskTemplatesGrouped(response: TaskTemplatesGroupedResponse) {
_taskTemplatesGrouped.value = response
// Also extract flat list from grouped response
val flatList = response.categories.flatMap { it.templates }
_taskTemplates.value = flatList
// Don't persist - these are fetched fresh from API
}
/**
* Search task templates by query string (local search)
*/
fun searchTaskTemplates(query: String): List<TaskTemplate> {
if (query.length < 2) return emptyList()
val lowercaseQuery = query.lowercase()
return _taskTemplates.value.filter { template ->
template.title.lowercase().contains(lowercaseQuery) ||
template.description.lowercase().contains(lowercaseQuery) ||
template.tags.any { it.lowercase().contains(lowercaseQuery) }
}.take(10)
}
fun setAllLookups(staticData: StaticDataResponse) { fun setAllLookups(staticData: StaticDataResponse) {
setResidenceTypes(staticData.residenceTypes) setResidenceTypes(staticData.residenceTypes)
setTaskFrequencies(staticData.taskFrequencies) setTaskFrequencies(staticData.taskFrequencies)
@@ -593,6 +629,8 @@ object DataManager {
_taskCategoriesMap.value = emptyMap() _taskCategoriesMap.value = emptyMap()
_contractorSpecialties.value = emptyList() _contractorSpecialties.value = emptyList()
_contractorSpecialtiesMap.value = emptyMap() _contractorSpecialtiesMap.value = emptyMap()
_taskTemplates.value = emptyList()
_taskTemplatesGrouped.value = null
_lookupsInitialized.value = false _lookupsInitialized.value = false
// Clear cache timestamps // Clear cache timestamps

View File

@@ -0,0 +1,56 @@
package com.example.casera.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents a task template fetched from the backend API.
* Users can select these when adding a new task to auto-fill form fields.
*/
@Serializable
data class TaskTemplate(
val id: Int,
val title: String,
val description: String = "",
@SerialName("category_id") val categoryId: Int? = null,
val category: TaskCategory? = null,
@SerialName("frequency_id") val frequencyId: Int? = null,
val frequency: TaskFrequency? = null,
@SerialName("icon_ios") val iconIos: String = "",
@SerialName("icon_android") val iconAndroid: String = "",
val tags: List<String> = emptyList(),
@SerialName("display_order") val displayOrder: Int = 0,
@SerialName("is_active") val isActive: Boolean = true
) {
/**
* Human-readable frequency display
*/
val frequencyDisplay: String
get() = frequency?.displayName ?: "One time"
/**
* Category name for display
*/
val categoryName: String
get() = category?.name ?: "Uncategorized"
}
/**
* Response for grouped templates by category
*/
@Serializable
data class TaskTemplateCategoryGroup(
@SerialName("category_name") val categoryName: String,
@SerialName("category_id") val categoryId: Int? = null,
val templates: List<TaskTemplate>,
val count: Int
)
/**
* Response for all templates grouped by category
*/
@Serializable
data class TaskTemplatesGroupedResponse(
val categories: List<TaskTemplateCategoryGroup>,
@SerialName("total_count") val totalCount: Int
)

View File

@@ -25,6 +25,7 @@ object APILayer {
private val lookupsApi = LookupsApi() private val lookupsApi = LookupsApi()
private val notificationApi = NotificationApi() private val notificationApi = NotificationApi()
private val subscriptionApi = SubscriptionApi() private val subscriptionApi = SubscriptionApi()
private val taskTemplateApi = TaskTemplateApi()
// ==================== Authentication Helper ==================== // ==================== Authentication Helper ====================
@@ -121,6 +122,20 @@ object APILayer {
println("⏭️ Skipping subscription status (not authenticated)") println("⏭️ Skipping subscription status (not authenticated)")
} }
// Load task templates (PUBLIC - no auth required)
println("🔄 Fetching task templates...")
val templatesResult = taskTemplateApi.getTemplatesGrouped()
println("📦 Task templates result: $templatesResult")
if (templatesResult is ApiResult.Success) {
println("✅ Updating task templates with ${templatesResult.data.totalCount} templates")
DataManager.setTaskTemplatesGrouped(templatesResult.data)
println("✅ Task templates updated successfully")
} else if (templatesResult is ApiResult.Error) {
println("❌ Failed to fetch task templates: ${templatesResult.message}")
// Non-fatal error - templates are optional for app functionality
}
DataManager.markLookupsInitialized() DataManager.markLookupsInitialized()
return ApiResult.Success(Unit) return ApiResult.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
@@ -877,6 +892,98 @@ object APILayer {
return contractorApi.getContractorsByResidence(token, residenceId) return contractorApi.getContractorsByResidence(token, residenceId)
} }
// ==================== Task Template Operations ====================
/**
* Get all task templates from DataManager. If cache is empty, fetch from API.
* Task templates are PUBLIC (no auth required).
*/
suspend fun getTaskTemplates(forceRefresh: Boolean = false): ApiResult<List<TaskTemplate>> {
if (!forceRefresh) {
val cached = DataManager.taskTemplates.value
if (cached.isNotEmpty()) {
return ApiResult.Success(cached)
}
}
val result = taskTemplateApi.getTemplates()
if (result is ApiResult.Success) {
DataManager.setTaskTemplates(result.data)
}
return result
}
/**
* Get task templates grouped by category.
* Task templates are PUBLIC (no auth required).
*/
suspend fun getTaskTemplatesGrouped(forceRefresh: Boolean = false): ApiResult<TaskTemplatesGroupedResponse> {
if (!forceRefresh) {
val cached = DataManager.taskTemplatesGrouped.value
if (cached != null) {
return ApiResult.Success(cached)
}
}
val result = taskTemplateApi.getTemplatesGrouped()
if (result is ApiResult.Success) {
DataManager.setTaskTemplatesGrouped(result.data)
}
return result
}
/**
* Search task templates by query string.
* First searches local cache, falls back to API if needed.
*/
suspend fun searchTaskTemplates(query: String): ApiResult<List<TaskTemplate>> {
// Try local search first if we have templates cached
val cached = DataManager.taskTemplates.value
if (cached.isNotEmpty()) {
val results = DataManager.searchTaskTemplates(query)
return ApiResult.Success(results)
}
// Fall back to API search
return taskTemplateApi.searchTemplates(query)
}
/**
* Get templates by category ID.
*/
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
// Try to get from grouped cache first
val grouped = DataManager.taskTemplatesGrouped.value
if (grouped != null) {
val categoryTemplates = grouped.categories
.find { it.categoryId == categoryId }?.templates
if (categoryTemplates != null) {
return ApiResult.Success(categoryTemplates)
}
}
// Fall back to API
return taskTemplateApi.getTemplatesByCategory(categoryId)
}
/**
* Get a single task template by ID.
*/
suspend fun getTaskTemplate(id: Int): ApiResult<TaskTemplate> {
// Try to find in cache first
val cached = DataManager.taskTemplates.value.find { it.id == id }
if (cached != null) {
return ApiResult.Success(cached)
}
// Fall back to API
return taskTemplateApi.getTemplate(id)
}
// ==================== Auth Operations ==================== // ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> { suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {

View File

@@ -9,7 +9,7 @@ package com.example.casera.network
*/ */
object ApiConfig { object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.DEV val CURRENT_ENV = Environment.LOCAL
enum class Environment { enum class Environment {
LOCAL, LOCAL,

View File

@@ -0,0 +1,103 @@
package com.example.casera.network
import com.example.casera.models.TaskTemplate
import com.example.casera.models.TaskTemplatesGroupedResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
/**
* API client for task templates.
* Task templates are public (no auth required) and used for autocomplete when adding tasks.
*/
class TaskTemplateApi(private val client: HttpClient = ApiClient.httpClient) {
private val baseUrl = ApiClient.getBaseUrl()
/**
* Get all task templates as a flat list
*/
suspend fun getTemplates(): ApiResult<List<TaskTemplate>> {
return try {
val response = client.get("$baseUrl/tasks/templates/")
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch task templates", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get all task templates grouped by category
*/
suspend fun getTemplatesGrouped(): ApiResult<TaskTemplatesGroupedResponse> {
return try {
val response = client.get("$baseUrl/tasks/templates/grouped/")
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch grouped task templates", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Search task templates by query string
*/
suspend fun searchTemplates(query: String): ApiResult<List<TaskTemplate>> {
return try {
val response = client.get("$baseUrl/tasks/templates/search/") {
parameter("q", query)
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to search task templates", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get templates by category ID
*/
suspend fun getTemplatesByCategory(categoryId: Int): ApiResult<List<TaskTemplate>> {
return try {
val response = client.get("$baseUrl/tasks/templates/by-category/$categoryId/")
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch templates by category", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
/**
* Get a single template by ID
*/
suspend fun getTemplate(id: Int): ApiResult<TaskTemplate> {
return try {
val response = client.get("$baseUrl/tasks/templates/$id/")
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Template not found", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -1,14 +1,21 @@
package com.example.casera.ui.components package com.example.casera.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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp 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.repository.LookupsRepository
import com.example.casera.models.MyResidencesResponse import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCategory import com.example.casera.models.TaskCategory
@@ -54,10 +61,54 @@ 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) }
// 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 frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState() val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.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 // Set defaults when data loads
LaunchedEffect(frequencies) { LaunchedEffect(frequencies) {
@@ -121,12 +172,56 @@ fun AddTaskDialog(
} }
} }
// Title // 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( OutlinedTextField(
value = title, value = title,
onValueChange = { onValueChange = {
title = it title = it
titleError = false titleError = false
showSuggestions = it.length >= 2 && filteredSuggestions.isNotEmpty()
}, },
label = { Text("Title *") }, label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -137,6 +232,18 @@ fun AddTaskDialog(
singleLine = true singleLine = true
) )
// Inline suggestions dropdown
if (showSuggestions && filteredSuggestions.isNotEmpty()) {
TaskSuggestionDropdown(
suggestions = filteredSuggestions,
onSelect = { template ->
selectTaskTemplate(template)
},
modifier = Modifier.fillMaxWidth()
)
}
}
// Description // Description
OutlinedTextField( OutlinedTextField(
value = description, value = description,
@@ -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 // 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
}
}

View File

@@ -68,6 +68,11 @@ class DataManagerObservable: ObservableObject {
@Published var taskCategories: [TaskCategory] = [] @Published var taskCategories: [TaskCategory] = []
@Published var contractorSpecialties: [ContractorSpecialty] = [] @Published var contractorSpecialties: [ContractorSpecialty] = []
// MARK: - Task Templates
@Published var taskTemplates: [TaskTemplate] = []
@Published var taskTemplatesGrouped: TaskTemplatesGroupedResponse?
// MARK: - State Metadata // MARK: - State Metadata
@Published var isInitialized: Bool = false @Published var isInitialized: Bool = false
@@ -317,6 +322,26 @@ class DataManagerObservable: ObservableObject {
} }
observationTasks.append(contractorSpecialtiesTask) observationTasks.append(contractorSpecialtiesTask)
// Task Templates
let taskTemplatesTask = Task {
for await items in DataManager.shared.taskTemplates {
await MainActor.run {
self.taskTemplates = items
}
}
}
observationTasks.append(taskTemplatesTask)
// Task Templates Grouped
let taskTemplatesGroupedTask = Task {
for await response in DataManager.shared.taskTemplatesGrouped {
await MainActor.run {
self.taskTemplatesGrouped = response
}
}
}
observationTasks.append(taskTemplatesGroupedTask)
// Metadata - isInitialized // Metadata - isInitialized
let isInitializedTask = Task { let isInitializedTask = Task {
for await initialized in DataManager.shared.isInitialized { for await initialized in DataManager.shared.isInitialized {
@@ -480,4 +505,16 @@ class DataManagerObservable: ObservableObject {
guard let response = allTasks else { return true } guard let response = allTasks else { return true }
return response.columns.allSatisfy { $0.tasks.isEmpty } return response.columns.allSatisfy { $0.tasks.isEmpty }
} }
// MARK: - Task Template Helpers
/// Search task templates by query string
func searchTaskTemplates(query: String) -> [TaskTemplate] {
return DataManager.shared.searchTaskTemplates(query: query)
}
/// Get total task template count
var taskTemplateCount: Int {
return taskTemplates.count
}
} }

View File

@@ -256,6 +256,14 @@ enum L10n {
static var cancel: String { String(localized: "tasks_cancel") } static var cancel: String { String(localized: "tasks_cancel") }
static var restore: String { String(localized: "tasks_restore") } static var restore: String { String(localized: "tasks_restore") }
static var unarchive: String { String(localized: "tasks_unarchive") } static var unarchive: String { String(localized: "tasks_unarchive") }
// Task Templates
static var browseTemplates: String { String(localized: "tasks_browse_templates") }
static var searchTemplates: String { String(localized: "tasks_search_templates") }
static var noTemplatesFound: String { String(localized: "tasks_no_templates_found") }
static var tryDifferentSearch: String { String(localized: "tasks_try_different_search") }
static var result: String { String(localized: "tasks_result") }
static var results: String { String(localized: "tasks_results") }
} }
// MARK: - Contractors // MARK: - Contractors

View File

@@ -101,6 +101,9 @@
} }
} }
} }
},
"%lld tasks" : {
}, },
"%lld/%lld tasks selected" : { "%lld/%lld tasks selected" : {
"localizations" : { "localizations" : {
@@ -112,6 +115,10 @@
} }
} }
}, },
"•" : {
"comment" : "A separator between different pieces of information in a text.",
"isCommentAutoGenerated" : true
},
"• 10K+ homeowners" : { "• 10K+ homeowners" : {
}, },
@@ -4231,6 +4238,9 @@
"Back to Login" : { "Back to Login" : {
"comment" : "A button label that takes the user back to the login screen.", "comment" : "A button label that takes the user back to the login screen.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Browse Task Templates" : {
}, },
"By: %@" : { "By: %@" : {
"comment" : "A line in the checkout view displaying the name of the contractor who completed a task.", "comment" : "A line in the checkout view displaying the name of the contractor who completed a task.",
@@ -4264,6 +4274,9 @@
"Check your spam folder if you don't see it" : { "Check your spam folder if you don't see it" : {
"comment" : "A description below the \"Send New Code\" button, instructing the user to check their spam folder if they haven't received the verification code.", "comment" : "A description below the \"Send New Code\" button, instructing the user to check their spam folder if they haven't received the verification code.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Choose from common home maintenance tasks or create your own below" : {
}, },
"Choose your plan" : { "Choose your plan" : {
@@ -17360,6 +17373,14 @@
"comment" : "A description displayed when a user has no tasks.", "comment" : "A description displayed when a user has no tasks.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"No Templates Available" : {
"comment" : "A message indicating that there are no task templates available.",
"isCommentAutoGenerated" : true
},
"No Templates Found" : {
"comment" : "A message displayed when no task templates match a search query.",
"isCommentAutoGenerated" : true
},
"or" : { "or" : {
}, },
@@ -17714,6 +17735,28 @@
} }
} }
}, },
"profile_contact_support" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact Support"
}
}
}
},
"profile_contact_support_subtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Get help with your account"
}
}
}
},
"profile_edit_profile" : { "profile_edit_profile" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -19372,39 +19415,6 @@
} }
} }
}, },
"profile_support" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Support"
}
}
}
},
"profile_contact_support" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Contact Support"
}
}
}
},
"profile_contact_support_subtitle" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Get help with your account"
}
}
}
},
"profile_subscription" : { "profile_subscription" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -19470,6 +19480,17 @@
} }
} }
}, },
"profile_support" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Support"
}
}
}
},
"profile_task_assigned" : { "profile_task_assigned" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -21228,6 +21249,9 @@
"Property #%d" : { "Property #%d" : {
"comment" : "A fallback text that appears when the associated residence ID is not found in the user's residences. The placeholder number is replaced with the actual residence ID.", "comment" : "A fallback text that appears when the associated residence ID is not found in the user's residences. The placeholder number is replaced with the actual residence ID.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Quick Start" : {
}, },
"Re-enter new password" : { "Re-enter new password" : {
@@ -24385,6 +24409,10 @@
}, },
"Save your home to your account" : { "Save your home to your account" : {
},
"Search templates..." : {
"comment" : "A placeholder text for a search bar in the task templates browser.",
"isCommentAutoGenerated" : true
}, },
"Send New Code" : { "Send New Code" : {
"comment" : "A button label that allows a user to request a new verification code.", "comment" : "A button label that allows a user to request a new verification code.",
@@ -24566,6 +24594,10 @@
}, },
"Take your home management\nto the next level" : { "Take your home management\nto the next level" : {
},
"Task Templates" : {
"comment" : "The title of the view that lists all predefined task templates.",
"isCommentAutoGenerated" : true
}, },
"Tasks" : { "Tasks" : {
"comment" : "A label displayed above the list of task categories.", "comment" : "A label displayed above the list of task categories.",
@@ -25221,6 +25253,10 @@
} }
} }
}, },
"tasks_browse_templates" : {
"comment" : "Text for browsing task templates.",
"isCommentAutoGenerated" : true
},
"tasks_cancel" : { "tasks_cancel" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -27496,6 +27532,10 @@
} }
} }
}, },
"tasks_no_templates_found" : {
"comment" : "Text displayed when no task templates are found.",
"isCommentAutoGenerated" : true
},
"tasks_none" : { "tasks_none" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -28471,6 +28511,14 @@
} }
} }
}, },
"tasks_result" : {
"comment" : "A singular label for a result in a list. E.g. \"1 result\", \"2 results\".",
"isCommentAutoGenerated" : true
},
"tasks_results" : {
"comment" : "Plural form of \"result\".",
"isCommentAutoGenerated" : true
},
"tasks_scheduling" : { "tasks_scheduling" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -28536,6 +28584,10 @@
} }
} }
}, },
"tasks_search_templates" : {
"comment" : "Title of a screen that allows users to search for task templates.",
"isCommentAutoGenerated" : true
},
"tasks_select_category" : { "tasks_select_category" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -29316,6 +29368,10 @@
} }
} }
}, },
"tasks_try_different_search" : {
"comment" : "Text to prompt the user to try a different search query when no task templates are found.",
"isCommentAutoGenerated" : true
},
"tasks_unarchive" : { "tasks_unarchive" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -29511,11 +29567,19 @@
} }
} }
}, },
"Templates will appear here once loaded" : {
"comment" : "A description text displayed when there are no task templates available.",
"isCommentAutoGenerated" : true
},
"That's Perfect!" : { "That's Perfect!" : {
}, },
"The Smith Residence" : { "The Smith Residence" : {
},
"Try a different search term" : {
"comment" : "A description below the \"No Templates Found\" message in the search results section of the task templates browser.",
"isCommentAutoGenerated" : true
}, },
"Try Again" : { "Try Again" : {
"comment" : "A button label that says \"Try Again\".", "comment" : "A button label that says \"Try Again\".",

View File

@@ -23,10 +23,10 @@ struct OnboardingFirstTaskContent: View {
icon: "thermometer.medium", icon: "thermometer.medium",
color: Color.appPrimary, color: Color.appPrimary,
tasks: [ tasks: [
TaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary), OnboardingTaskTemplate(icon: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary),
TaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary), OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary),
TaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
TaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary) OnboardingTaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary)
] ]
), ),
OnboardingTaskCategory( OnboardingTaskCategory(
@@ -34,10 +34,10 @@ struct OnboardingFirstTaskContent: View {
icon: "shield.checkered", icon: "shield.checkered",
color: Color.appError, color: Color.appError,
tasks: [ tasks: [
TaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: Color.appError), OnboardingTaskTemplate(icon: "smoke.fill", title: "Test Smoke Detectors", category: "safety", frequency: "monthly", color: Color.appError),
TaskTemplate(icon: "dot.radiowaves.left.and.right", title: "Check CO Detectors", category: "safety", frequency: "monthly", color: Color.appError), OnboardingTaskTemplate(icon: "dot.radiowaves.left.and.right", title: "Check CO Detectors", category: "safety", frequency: "monthly", color: Color.appError),
TaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange),
TaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary) OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary)
] ]
), ),
OnboardingTaskCategory( OnboardingTaskCategory(
@@ -45,10 +45,10 @@ struct OnboardingFirstTaskContent: View {
icon: "drop.fill", icon: "drop.fill",
color: Color.appSecondary, color: Color.appSecondary,
tasks: [ tasks: [
TaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary), OnboardingTaskTemplate(icon: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary),
TaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange), OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange),
TaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary), OnboardingTaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary),
TaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary) OnboardingTaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary)
] ]
), ),
OnboardingTaskCategory( OnboardingTaskCategory(
@@ -56,10 +56,10 @@ struct OnboardingFirstTaskContent: View {
icon: "leaf.fill", icon: "leaf.fill",
color: Color(hex: "#34C759") ?? .green, color: Color(hex: "#34C759") ?? .green,
tasks: [ tasks: [
TaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green), OnboardingTaskTemplate(icon: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green),
TaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary), OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary),
TaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange), OnboardingTaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange),
TaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green) OnboardingTaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green)
] ]
), ),
OnboardingTaskCategory( OnboardingTaskCategory(
@@ -67,10 +67,10 @@ struct OnboardingFirstTaskContent: View {
icon: "refrigerator.fill", icon: "refrigerator.fill",
color: Color.appAccent, color: Color.appAccent,
tasks: [ tasks: [
TaskTemplate(icon: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent), OnboardingTaskTemplate(icon: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent),
TaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary), OnboardingTaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary),
TaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary), OnboardingTaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary),
TaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange) OnboardingTaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange)
] ]
), ),
OnboardingTaskCategory( OnboardingTaskCategory(
@@ -78,15 +78,15 @@ struct OnboardingFirstTaskContent: View {
icon: "house.fill", icon: "house.fill",
color: Color(hex: "#AF52DE") ?? .purple, color: Color(hex: "#AF52DE") ?? .purple,
tasks: [ tasks: [
TaskTemplate(icon: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple), OnboardingTaskTemplate(icon: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple),
TaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent), OnboardingTaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent),
TaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary), OnboardingTaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary),
TaskTemplate(icon: "window.vertical.closed", title: "Clean Window Tracks", category: "interior", frequency: "semiannually", color: Color.appPrimary) OnboardingTaskTemplate(icon: "window.vertical.closed", title: "Clean Window Tracks", category: "interior", frequency: "semiannually", color: Color.appPrimary)
] ]
) )
] ]
private var allTasks: [TaskTemplate] { private var allTasks: [OnboardingTaskTemplate] {
taskCategories.flatMap { $0.tasks } taskCategories.flatMap { $0.tasks }
} }
@@ -389,7 +389,7 @@ struct OnboardingTaskCategory: Identifiable {
let name: String let name: String
let icon: String let icon: String
let color: Color let color: Color
let tasks: [TaskTemplate] let tasks: [OnboardingTaskTemplate]
} }
// MARK: - Task Category Section // MARK: - Task Category Section
@@ -463,7 +463,7 @@ struct TaskCategorySection: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(category.tasks) { task in ForEach(category.tasks) { task in
let taskIsSelected = selectedTasks.contains(task.id) let taskIsSelected = selectedTasks.contains(task.id)
TaskTemplateRow( OnboardingTaskTemplateRow(
template: task, template: task,
isSelected: taskIsSelected, isSelected: taskIsSelected,
isDisabled: isAtMaxSelection && !taskIsSelected, isDisabled: isAtMaxSelection && !taskIsSelected,
@@ -494,8 +494,8 @@ struct TaskCategorySection: View {
// MARK: - Task Template Row // MARK: - Task Template Row
struct TaskTemplateRow: View { struct OnboardingTaskTemplateRow: View {
let template: TaskTemplate let template: OnboardingTaskTemplate
let isSelected: Bool let isSelected: Bool
let isDisabled: Bool let isDisabled: Bool
var onTap: () -> Void var onTap: () -> Void
@@ -549,9 +549,9 @@ struct TaskTemplateRow: View {
} }
} }
// MARK: - Task Template Model // MARK: - Onboarding Task Template Model
struct TaskTemplate: Identifiable { struct OnboardingTaskTemplate: Identifiable {
let id = UUID() let id = UUID()
let icon: String let icon: String
let title: String let title: String

View File

@@ -91,6 +91,11 @@ struct TaskFormView: View {
// Error alert state // Error alert state
@State private var errorAlert: ErrorAlertInfo? = nil @State private var errorAlert: ErrorAlertInfo? = nil
// Template suggestions
@State private var showingTemplatesBrowser = false
@State private var filteredSuggestions: [TaskTemplate] = []
@State private var showSuggestions = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
@@ -120,9 +125,60 @@ struct TaskFormView: View {
.listRowBackground(Color.appBackgroundSecondary) .listRowBackground(Color.appBackgroundSecondary)
} }
// Browse Templates Button (only for new tasks)
if !isEditMode {
Section { Section {
Button {
showingTemplatesBrowser = true
} label: {
HStack {
Image(systemName: "list.bullet.rectangle")
.font(.system(size: 18))
.foregroundColor(Color.appPrimary)
.frame(width: 28)
Text("Browse Task Templates")
.foregroundColor(Color.appTextPrimary)
Spacer()
Text("\(dataManager.taskTemplateCount) tasks")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
} header: {
Text("Quick Start")
} footer: {
Text("Choose from common home maintenance tasks or create your own below")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.listRowBackground(Color.appBackgroundSecondary)
}
Section {
VStack(alignment: .leading, spacing: 8) {
TextField(L10n.Tasks.titleLabel, text: $title) TextField(L10n.Tasks.titleLabel, text: $title)
.focused($focusedField, equals: .title) .focused($focusedField, equals: .title)
.onChange(of: title) { newValue in
updateSuggestions(query: newValue)
}
// Inline suggestions dropdown
if showSuggestions && !filteredSuggestions.isEmpty && focusedField == .title {
TaskSuggestionsView(
suggestions: filteredSuggestions,
onSelect: { template in
selectTaskTemplate(template)
}
)
}
}
if !titleError.isEmpty { if !titleError.isEmpty {
Text(titleError) Text(titleError)
@@ -286,8 +342,52 @@ struct TaskFormView: View {
errorAlert = nil errorAlert = nil
} }
) )
.sheet(isPresented: $showingTemplatesBrowser) {
TaskTemplatesBrowserView { template in
selectTaskTemplate(template)
} }
} }
}
}
// MARK: - Suggestions Helpers
private func updateSuggestions(query: String) {
if query.count >= 2 {
filteredSuggestions = dataManager.searchTaskTemplates(query: query)
showSuggestions = !filteredSuggestions.isEmpty
} else {
filteredSuggestions = []
showSuggestions = false
}
}
private func selectTaskTemplate(_ template: TaskTemplate) {
// Fill in the title
title = template.title
// Fill in description if available
description = template.description_
// Auto-select matching category by ID or name
if let categoryId = template.categoryId {
selectedCategory = taskCategories.first(where: { $0.id == Int(categoryId.int32Value) })
} else if let category = template.category {
selectedCategory = taskCategories.first(where: { $0.name.lowercased() == category.name.lowercased() })
}
// Auto-select matching frequency by ID or name
if let frequencyId = template.frequencyId {
selectedFrequency = taskFrequencies.first(where: { $0.id == Int(frequencyId.int32Value) })
} else if let frequency = template.frequency {
selectedFrequency = taskFrequencies.first(where: { $0.name.lowercased() == frequency.name.lowercased() })
}
// Clear suggestions and dismiss keyboard
showSuggestions = false
filteredSuggestions = []
focusedField = nil
}
private func setDefaults() { private func setDefaults() {
// Set default values if not already set // Set default values if not already set

View File

@@ -0,0 +1,102 @@
import SwiftUI
import ComposeApp
/// Inline dropdown showing filtered task template suggestions
struct TaskSuggestionsView: View {
let suggestions: [TaskTemplate]
let onSelect: (TaskTemplate) -> Void
let maxSuggestions: Int
init(
suggestions: [TaskTemplate],
onSelect: @escaping (TaskTemplate) -> Void,
maxSuggestions: Int = 5
) {
self.suggestions = suggestions
self.onSelect = onSelect
self.maxSuggestions = maxSuggestions
}
var body: some View {
VStack(spacing: 0) {
ForEach(Array(suggestions.prefix(maxSuggestions).enumerated()), id: \.element.id) { index, template in
Button {
onSelect(template)
} label: {
HStack(spacing: 12) {
// Category-colored icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 18))
.foregroundColor(categoryColor(for: template.categoryName))
.frame(width: 28, height: 28)
// Task info
VStack(alignment: .leading, spacing: 2) {
Text(template.title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(Color.appTextPrimary)
.lineLimit(1)
HStack(spacing: 4) {
Text(template.categoryName)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
Text("")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
Text(template.frequencyDisplay)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
Spacer()
// Chevron
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Divider between items (not after last item)
if index < min(suggestions.count, maxSuggestions) - 1 {
Divider()
.padding(.leading, 52)
}
}
}
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
}
private func categoryColor(for categoryName: String) -> Color {
switch categoryName.lowercased() {
case "plumbing": return Color.appSecondary
case "safety", "electrical": return Color.appError
case "hvac": return Color.appPrimary
case "appliances": return Color.appAccent
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
case "interior": return Color(hex: "#AF52DE") ?? .purple
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
default: return Color.appPrimary
}
}
}
#Preview {
VStack(spacing: 20) {
Text("Preview requires backend templates to be loaded")
.foregroundColor(.secondary)
Spacer()
}
.background(Color.appBackgroundPrimary)
}

View File

@@ -0,0 +1,247 @@
import SwiftUI
import ComposeApp
/// Full-screen browser for all task templates from backend
struct TaskTemplatesBrowserView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject private var dataManager = DataManagerObservable.shared
let onSelect: (TaskTemplate) -> Void
@State private var searchText: String = ""
@State private var expandedCategories: Set<String> = []
private var filteredTemplates: [TaskTemplate] {
if searchText.isEmpty {
return []
}
return dataManager.searchTaskTemplates(query: searchText)
}
private var isSearching: Bool {
!searchText.isEmpty
}
var body: some View {
NavigationStack {
List {
if isSearching {
// Search results
searchResultsSection
} else {
// Browse by category
categorySections
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.searchable(text: $searchText, prompt: "Search templates...")
.navigationTitle("Task Templates")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.Common.done) {
dismiss()
}
}
}
}
}
// MARK: - Search Results
@ViewBuilder
private var searchResultsSection: some View {
if filteredTemplates.isEmpty {
Section {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 40))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
Text("No Templates Found")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("Try a different search term")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
.listRowBackground(Color.clear)
} else {
Section {
ForEach(filteredTemplates, id: \.id) { template in
taskRow(template)
}
} header: {
Text("\(filteredTemplates.count) \(filteredTemplates.count == 1 ? "result" : "results")")
}
.listRowBackground(Color.appBackgroundSecondary)
}
}
// MARK: - Category Sections
@ViewBuilder
private var categorySections: some View {
if let grouped = dataManager.taskTemplatesGrouped {
ForEach(grouped.categories, id: \.categoryName) { categoryGroup in
let categoryKey = categoryGroup.categoryName
let isExpanded = expandedCategories.contains(categoryKey)
Section {
// Category header (tappable to expand/collapse)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedCategories.remove(categoryKey)
} else {
expandedCategories.insert(categoryKey)
}
}
} label: {
HStack {
// Category icon
Image(systemName: categoryIcon(for: categoryGroup.categoryName))
.font(.system(size: 18))
.foregroundColor(categoryColor(for: categoryGroup.categoryName))
.frame(width: 28, height: 28)
// Category name
Text(categoryGroup.categoryName)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Spacer()
// Count badge
Text("\(categoryGroup.count)")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appBackgroundPrimary)
.clipShape(Capsule())
// Expand/collapse indicator
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Tasks (shown when expanded)
if isExpanded {
ForEach(categoryGroup.templates, id: \.id) { template in
taskRow(template)
.padding(.leading, 16)
}
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
} else {
// Empty state
Section {
VStack(spacing: 12) {
Image(systemName: "checklist")
.font(.system(size: 40))
.foregroundColor(Color.appTextSecondary.opacity(0.5))
Text("No Templates Available")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("Templates will appear here once loaded")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
.listRowBackground(Color.clear)
}
}
// MARK: - Task Row
@ViewBuilder
private func taskRow(_ template: TaskTemplate) -> some View {
Button {
onSelect(template)
dismiss()
} label: {
HStack(spacing: 12) {
// Icon
Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos)
.font(.system(size: 16))
.foregroundColor(categoryColor(for: template.categoryName))
.frame(width: 24, height: 24)
// Task info
VStack(alignment: .leading, spacing: 2) {
Text(template.title)
.font(.subheadline)
.foregroundColor(Color.appTextPrimary)
.lineLimit(2)
Text(template.frequencyDisplay)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
Spacer()
// Add indicator
Image(systemName: "plus.circle")
.font(.system(size: 18))
.foregroundColor(Color.appPrimary)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Helpers
private func categoryIcon(for categoryName: String) -> String {
switch categoryName.lowercased() {
case "plumbing": return "drop.fill"
case "safety": return "shield.checkered"
case "electrical": return "bolt.fill"
case "hvac": return "thermometer.medium"
case "appliances": return "refrigerator.fill"
case "exterior": return "house.fill"
case "lawn & garden": return "leaf.fill"
case "interior": return "sofa.fill"
case "general", "seasonal": return "calendar"
default: return "checklist"
}
}
private func categoryColor(for categoryName: String) -> Color {
switch categoryName.lowercased() {
case "plumbing": return Color.appSecondary
case "safety", "electrical": return Color.appError
case "hvac": return Color.appPrimary
case "appliances": return Color.appAccent
case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green
case "interior": return Color(hex: "#AF52DE") ?? .purple
case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange
default: return Color.appPrimary
}
}
}
#Preview {
TaskTemplatesBrowserView { template in
print("Selected: \(template.title)")
}
}