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