From 771f5d2bd3fe093ba232c84b54b4ef06ac7d58b1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 5 Dec 2025 09:06:58 -0600 Subject: [PATCH] Add task template suggestions for quick task creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../com/example/casera/data/DataManager.kt | 38 ++ .../com/example/casera/models/TaskTemplate.kt | 56 +++ .../com/example/casera/network/APILayer.kt | 107 +++++ .../com/example/casera/network/ApiConfig.kt | 2 +- .../example/casera/network/TaskTemplateApi.kt | 103 +++++ .../casera/ui/components/AddTaskDialog.kt | 150 ++++++- .../ui/components/TaskSuggestionDropdown.kt | 154 ++++++++ .../components/TaskTemplatesBrowserSheet.kt | 368 ++++++++++++++++++ .../iosApp/Data/DataManagerObservable.swift | 37 ++ iosApp/iosApp/Helpers/L10n.swift | 8 + iosApp/iosApp/Localizable.xcstrings | 130 +++++-- .../Onboarding/OnboardingFirstTaskView.swift | 62 +-- iosApp/iosApp/Task/TaskFormView.swift | 104 ++++- iosApp/iosApp/Task/TaskSuggestionsView.swift | 102 +++++ .../Task/TaskTemplatesBrowserView.swift | 247 ++++++++++++ 15 files changed, 1585 insertions(+), 83 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/models/TaskTemplate.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskSuggestionDropdown.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt create mode 100644 iosApp/iosApp/Task/TaskSuggestionsView.swift create mode 100644 iosApp/iosApp/Task/TaskTemplatesBrowserView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index 65a464c..82bea55 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -167,6 +167,14 @@ object DataManager { private val _contractorSpecialties = MutableStateFlow>(emptyList()) val contractorSpecialties: StateFlow> = _contractorSpecialties.asStateFlow() + // ==================== TASK TEMPLATES ==================== + + private val _taskTemplates = MutableStateFlow>(emptyList()) + val taskTemplates: StateFlow> = _taskTemplates.asStateFlow() + + private val _taskTemplatesGrouped = MutableStateFlow(null) + val taskTemplatesGrouped: StateFlow = _taskTemplatesGrouped.asStateFlow() + // Map-based for O(1) ID resolution private val _residenceTypesMap = MutableStateFlow>(emptyMap()) val residenceTypesMap: StateFlow> = _residenceTypesMap.asStateFlow() @@ -538,6 +546,34 @@ object DataManager { persistToDisk() } + // ==================== TASK TEMPLATE UPDATE METHODS ==================== + + fun setTaskTemplates(templates: List) { + _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 { + 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) { setResidenceTypes(staticData.residenceTypes) setTaskFrequencies(staticData.taskFrequencies) @@ -593,6 +629,8 @@ object DataManager { _taskCategoriesMap.value = emptyMap() _contractorSpecialties.value = emptyList() _contractorSpecialtiesMap.value = emptyMap() + _taskTemplates.value = emptyList() + _taskTemplatesGrouped.value = null _lookupsInitialized.value = false // Clear cache timestamps diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/TaskTemplate.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/TaskTemplate.kt new file mode 100644 index 0000000..763036d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/TaskTemplate.kt @@ -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 = 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, + val count: Int +) + +/** + * Response for all templates grouped by category + */ +@Serializable +data class TaskTemplatesGroupedResponse( + val categories: List, + @SerialName("total_count") val totalCount: Int +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 32da741..4a41708 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -25,6 +25,7 @@ object APILayer { private val lookupsApi = LookupsApi() private val notificationApi = NotificationApi() private val subscriptionApi = SubscriptionApi() + private val taskTemplateApi = TaskTemplateApi() // ==================== Authentication Helper ==================== @@ -121,6 +122,20 @@ object APILayer { 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() return ApiResult.Success(Unit) } catch (e: Exception) { @@ -877,6 +892,98 @@ object APILayer { 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> { + 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 { + 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> { + // 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> { + // 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 { + // 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 ==================== suspend fun login(request: LoginRequest): ApiResult { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index a19e50e..45cffa3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -9,7 +9,7 @@ package com.example.casera.network */ object ApiConfig { // ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️ - val CURRENT_ENV = Environment.DEV + val CURRENT_ENV = Environment.LOCAL enum class Environment { LOCAL, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt new file mode 100644 index 0000000..d0ea050 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/TaskTemplateApi.kt @@ -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> { + 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 { + 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> { + 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> { + 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 { + 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") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt index b1e2a9b..2aea5f6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskSuggestionDropdown.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskSuggestionDropdown.kt new file mode 100644 index 0000000..2c47936 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskSuggestionDropdown.kt @@ -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, + 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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt new file mode 100644 index 0000000..3a0bae8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/TaskTemplatesBrowserSheet.kt @@ -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()) } + + // 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 + } +} diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index dbba45e..ee309db 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -68,6 +68,11 @@ class DataManagerObservable: ObservableObject { @Published var taskCategories: [TaskCategory] = [] @Published var contractorSpecialties: [ContractorSpecialty] = [] + // MARK: - Task Templates + + @Published var taskTemplates: [TaskTemplate] = [] + @Published var taskTemplatesGrouped: TaskTemplatesGroupedResponse? + // MARK: - State Metadata @Published var isInitialized: Bool = false @@ -317,6 +322,26 @@ class DataManagerObservable: ObservableObject { } 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 let isInitializedTask = Task { for await initialized in DataManager.shared.isInitialized { @@ -480,4 +505,16 @@ class DataManagerObservable: ObservableObject { guard let response = allTasks else { return true } 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 + } } diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift index a2f79f4..79907ea 100644 --- a/iosApp/iosApp/Helpers/L10n.swift +++ b/iosApp/iosApp/Helpers/L10n.swift @@ -256,6 +256,14 @@ enum L10n { static var cancel: String { String(localized: "tasks_cancel") } static var restore: String { String(localized: "tasks_restore") } 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 diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 5f4832a..65ea748 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -101,6 +101,9 @@ } } } + }, + "%lld tasks" : { + }, "%lld/%lld tasks selected" : { "localizations" : { @@ -112,6 +115,10 @@ } } }, + "•" : { + "comment" : "A separator between different pieces of information in a text.", + "isCommentAutoGenerated" : true + }, "• 10K+ homeowners" : { }, @@ -4231,6 +4238,9 @@ "Back to Login" : { "comment" : "A button label that takes the user back to the login screen.", "isCommentAutoGenerated" : true + }, + "Browse Task Templates" : { + }, "By: %@" : { "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" : { "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 + }, + "Choose from common home maintenance tasks or create your own below" : { + }, "Choose your plan" : { @@ -17360,6 +17373,14 @@ "comment" : "A description displayed when a user has no tasks.", "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" : { }, @@ -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" : { "extractionState" : "manual", "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" : { "extractionState" : "manual", "localizations" : { @@ -19470,6 +19480,17 @@ } } }, + "profile_support" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support" + } + } + } + }, "profile_task_assigned" : { "extractionState" : "manual", "localizations" : { @@ -21228,6 +21249,9 @@ "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.", "isCommentAutoGenerated" : true + }, + "Quick Start" : { + }, "Re-enter new password" : { @@ -24385,6 +24409,10 @@ }, "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" : { "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" : { + }, + "Task Templates" : { + "comment" : "The title of the view that lists all predefined task templates.", + "isCommentAutoGenerated" : true }, "Tasks" : { "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" : { "extractionState" : "manual", "localizations" : { @@ -27496,6 +27532,10 @@ } } }, + "tasks_no_templates_found" : { + "comment" : "Text displayed when no task templates are found.", + "isCommentAutoGenerated" : true + }, "tasks_none" : { "extractionState" : "manual", "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" : { "extractionState" : "manual", "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" : { "extractionState" : "manual", "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" : { "extractionState" : "manual", "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!" : { }, "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" : { "comment" : "A button label that says \"Try Again\".", diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index 9eaa2e3..e3dea45 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -23,10 +23,10 @@ struct OnboardingFirstTaskContent: View { icon: "thermometer.medium", color: Color.appPrimary, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "fanblades.fill", title: "Change HVAC Filter", category: "hvac", frequency: "monthly", color: Color.appPrimary), + OnboardingTaskTemplate(icon: "air.conditioner.horizontal.fill", title: "Schedule AC Tune-Up", category: "hvac", frequency: "yearly", color: Color.appPrimary), + OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Furnace", category: "hvac", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), + OnboardingTaskTemplate(icon: "wind", title: "Clean Air Ducts", category: "hvac", frequency: "yearly", color: Color.appSecondary) ] ), OnboardingTaskCategory( @@ -34,10 +34,10 @@ struct OnboardingFirstTaskContent: View { icon: "shield.checkered", color: Color.appError, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "smoke.fill", title: "Test Smoke 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), + OnboardingTaskTemplate(icon: "flame.fill", title: "Inspect Fire Extinguisher", category: "safety", frequency: "yearly", color: Color(hex: "#FF6B35") ?? .orange), + OnboardingTaskTemplate(icon: "lock.fill", title: "Test Garage Door Safety", category: "safety", frequency: "monthly", color: Color.appSecondary) ] ), OnboardingTaskCategory( @@ -45,10 +45,10 @@ struct OnboardingFirstTaskContent: View { icon: "drop.fill", color: Color.appSecondary, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "drop.fill", title: "Check for Leaks", category: "plumbing", frequency: "monthly", color: Color.appSecondary), + OnboardingTaskTemplate(icon: "bolt.horizontal.fill", title: "Flush Water Heater", category: "plumbing", frequency: "yearly", color: Color(hex: "#FF9500") ?? .orange), + OnboardingTaskTemplate(icon: "wrench.and.screwdriver.fill", title: "Clean Faucet Aerators", category: "plumbing", frequency: "quarterly", color: Color.appPrimary), + OnboardingTaskTemplate(icon: "arrow.down.circle.fill", title: "Snake Drains", category: "plumbing", frequency: "quarterly", color: Color.appTextSecondary) ] ), OnboardingTaskCategory( @@ -56,10 +56,10 @@ struct OnboardingFirstTaskContent: View { icon: "leaf.fill", color: Color(hex: "#34C759") ?? .green, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "leaf.fill", title: "Lawn Care", category: "landscaping", frequency: "weekly", color: Color(hex: "#34C759") ?? .green), + OnboardingTaskTemplate(icon: "cloud.rain.fill", title: "Clean Gutters", category: "exterior", frequency: "semiannually", color: Color.appSecondary), + OnboardingTaskTemplate(icon: "sun.max.fill", title: "Check Sprinkler System", category: "landscaping", frequency: "monthly", color: Color(hex: "#FF9500") ?? .orange), + OnboardingTaskTemplate(icon: "scissors", title: "Trim Trees & Shrubs", category: "landscaping", frequency: "quarterly", color: Color(hex: "#34C759") ?? .green) ] ), OnboardingTaskCategory( @@ -67,10 +67,10 @@ struct OnboardingFirstTaskContent: View { icon: "refrigerator.fill", color: Color.appAccent, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "refrigerator.fill", title: "Clean Refrigerator Coils", category: "appliances", frequency: "semiannually", color: Color.appAccent), + OnboardingTaskTemplate(icon: "washer.fill", title: "Clean Washing Machine", category: "appliances", frequency: "monthly", color: Color.appSecondary), + OnboardingTaskTemplate(icon: "dishwasher.fill", title: "Clean Dishwasher Filter", category: "appliances", frequency: "monthly", color: Color.appPrimary), + OnboardingTaskTemplate(icon: "oven.fill", title: "Deep Clean Oven", category: "appliances", frequency: "quarterly", color: Color(hex: "#FF6B35") ?? .orange) ] ), OnboardingTaskCategory( @@ -78,15 +78,15 @@ struct OnboardingFirstTaskContent: View { icon: "house.fill", color: Color(hex: "#AF52DE") ?? .purple, tasks: [ - TaskTemplate(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), - TaskTemplate(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: "paintbrush.fill", title: "Touch Up Paint", category: "interior", frequency: "yearly", color: Color(hex: "#AF52DE") ?? .purple), + OnboardingTaskTemplate(icon: "lightbulb.fill", title: "Replace Light Bulbs", category: "electrical", frequency: "monthly", color: Color.appAccent), + OnboardingTaskTemplate(icon: "door.left.hand.closed", title: "Lubricate Door Hinges", category: "interior", frequency: "yearly", color: Color.appTextSecondary), + 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 } } @@ -389,7 +389,7 @@ struct OnboardingTaskCategory: Identifiable { let name: String let icon: String let color: Color - let tasks: [TaskTemplate] + let tasks: [OnboardingTaskTemplate] } // MARK: - Task Category Section @@ -463,7 +463,7 @@ struct TaskCategorySection: View { VStack(spacing: 0) { ForEach(category.tasks) { task in let taskIsSelected = selectedTasks.contains(task.id) - TaskTemplateRow( + OnboardingTaskTemplateRow( template: task, isSelected: taskIsSelected, isDisabled: isAtMaxSelection && !taskIsSelected, @@ -494,8 +494,8 @@ struct TaskCategorySection: View { // MARK: - Task Template Row -struct TaskTemplateRow: View { - let template: TaskTemplate +struct OnboardingTaskTemplateRow: View { + let template: OnboardingTaskTemplate let isSelected: Bool let isDisabled: Bool 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 icon: String let title: String diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index fa0b177..6896825 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -91,6 +91,11 @@ struct TaskFormView: View { // Error alert state @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 { NavigationStack { ZStack { @@ -120,9 +125,60 @@ struct TaskFormView: View { .listRowBackground(Color.appBackgroundSecondary) } + // Browse Templates Button (only for new tasks) + if !isEditMode { + 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 { - TextField(L10n.Tasks.titleLabel, text: $title) - .focused($focusedField, equals: .title) + VStack(alignment: .leading, spacing: 8) { + TextField(L10n.Tasks.titleLabel, text: $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 { Text(titleError) @@ -286,9 +342,53 @@ struct TaskFormView: View { 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() { // Set default values if not already set if selectedCategory == nil && !taskCategories.isEmpty { diff --git a/iosApp/iosApp/Task/TaskSuggestionsView.swift b/iosApp/iosApp/Task/TaskSuggestionsView.swift new file mode 100644 index 0000000..eaf4bca --- /dev/null +++ b/iosApp/iosApp/Task/TaskSuggestionsView.swift @@ -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) +} diff --git a/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift new file mode 100644 index 0000000..37b6ea2 --- /dev/null +++ b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift @@ -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 = [] + + 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)") + } +}