Add task template suggestions for quick task creation

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

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

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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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\".",

View File

@@ -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

View File

@@ -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 {

View File

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

View File

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