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:
@@ -167,6 +167,14 @@ object DataManager {
|
||||
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
||||
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
|
||||
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
|
||||
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
|
||||
@@ -538,6 +546,34 @@ object DataManager {
|
||||
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) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<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 ====================
|
||||
|
||||
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\".",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
102
iosApp/iosApp/Task/TaskSuggestionsView.swift
Normal file
102
iosApp/iosApp/Task/TaskSuggestionsView.swift
Normal 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)
|
||||
}
|
||||
247
iosApp/iosApp/Task/TaskTemplatesBrowserView.swift
Normal file
247
iosApp/iosApp/Task/TaskTemplatesBrowserView.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user