This commit is contained in:
Trey t
2025-11-05 10:38:46 -06:00
parent 025fcf677a
commit 2be3a5a3a8
23 changed files with 2837 additions and 124 deletions

View File

@@ -0,0 +1,432 @@
import SwiftUI
import ComposeApp
struct AddTaskView: View {
let residenceId: Int32
@Binding var isPresented: Bool
@StateObject private var viewModel = TaskViewModel()
@StateObject private var lookupsManager = LookupsManager.shared
@FocusState private var focusedField: Field?
// Form fields
@State private var title: String = ""
@State private var description: String = ""
@State private var selectedCategory: TaskCategory?
@State private var selectedFrequency: TaskFrequency?
@State private var selectedPriority: TaskPriority?
@State private var selectedStatus: TaskStatus?
@State private var dueDate: Date = Date()
@State private var intervalDays: String = ""
@State private var estimatedCost: String = ""
// Validation errors
@State private var titleError: String = ""
// Picker states
@State private var showCategoryPicker = false
@State private var showFrequencyPicker = false
@State private var showPriorityPicker = false
@State private var showStatusPicker = false
enum Field {
case title, description, intervalDays, estimatedCost
}
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading lookup data...")
.foregroundColor(.secondary)
}
} else {
ScrollView {
VStack(spacing: 24) {
// Task Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Task Information")
.font(.headline)
.foregroundColor(.blue)
// Title Field
VStack(alignment: .leading, spacing: 8) {
Text("Task Title *")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., Clean gutters", text: $title)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .title)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
}
// Description Field
VStack(alignment: .leading, spacing: 8) {
Text("Description (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 100)
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
// Category Picker
PickerField(
label: "Category *",
selectedItem: selectedCategory?.name ?? "Select Category",
showPicker: $showCategoryPicker
)
// Frequency Picker
PickerField(
label: "Frequency *",
selectedItem: selectedFrequency?.displayName ?? "Select Frequency",
showPicker: $showFrequencyPicker
)
// Interval Days (if applicable)
if selectedFrequency?.name != "once" {
VStack(alignment: .leading, spacing: 8) {
Text("Custom Interval (days, optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("Leave empty for default", text: $intervalDays)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
}
// Priority Picker
PickerField(
label: "Priority *",
selectedItem: selectedPriority?.displayName ?? "Select Priority",
showPicker: $showPriorityPicker
)
// Status Picker
PickerField(
label: "Status *",
selectedItem: selectedStatus?.displayName ?? "Select Status",
showPicker: $showStatusPicker
)
// Due Date Picker
VStack(alignment: .leading, spacing: 8) {
Text("Due Date *")
.font(.subheadline)
.foregroundColor(.secondary)
DatePicker("", selection: $dueDate, displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
}
// Estimated Cost Field
VStack(alignment: .leading, spacing: 8) {
Text("Estimated Cost (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., 150.00", text: $estimatedCost)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
Spacer()
Button(action: viewModel.clearError) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
// Submit Button
Button(action: submitForm) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Create Task")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.isLoading)
}
.padding()
}
}
}
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
}
.sheet(isPresented: $showCategoryPicker) {
LookupPickerView(
title: "Select Category",
items: lookupsManager.taskCategories.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.name) },
selectedId: selectedCategory?.id,
isPresented: $showCategoryPicker,
onSelect: { id in
selectedCategory = lookupsManager.taskCategories.first { $0.id == id }
}
)
}
.sheet(isPresented: $showFrequencyPicker) {
LookupPickerView(
title: "Select Frequency",
items: lookupsManager.taskFrequencies.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedFrequency?.id,
isPresented: $showFrequencyPicker,
onSelect: { id in
selectedFrequency = lookupsManager.taskFrequencies.first { $0.id == id }
}
)
}
.sheet(isPresented: $showPriorityPicker) {
LookupPickerView(
title: "Select Priority",
items: lookupsManager.taskPriorities.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedPriority?.id,
isPresented: $showPriorityPicker,
onSelect: { id in
selectedPriority = lookupsManager.taskPriorities.first { $0.id == id }
}
)
}
.sheet(isPresented: $showStatusPicker) {
LookupPickerView(
title: "Select Status",
items: lookupsManager.taskStatuses.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedStatus?.id,
isPresented: $showStatusPicker,
onSelect: { id in
selectedStatus = lookupsManager.taskStatuses.first { $0.id == id }
}
)
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
}
private func setDefaults() {
// Set default values if not already set
if selectedCategory == nil && !lookupsManager.taskCategories.isEmpty {
selectedCategory = lookupsManager.taskCategories.first
}
if selectedFrequency == nil && !lookupsManager.taskFrequencies.isEmpty {
// Default to "once"
selectedFrequency = lookupsManager.taskFrequencies.first { $0.name == "once" } ?? lookupsManager.taskFrequencies.first
}
if selectedPriority == nil && !lookupsManager.taskPriorities.isEmpty {
// Default to "medium"
selectedPriority = lookupsManager.taskPriorities.first { $0.name == "medium" } ?? lookupsManager.taskPriorities.first
}
if selectedStatus == nil && !lookupsManager.taskStatuses.isEmpty {
// Default to "pending"
selectedStatus = lookupsManager.taskStatuses.first { $0.name == "pending" } ?? lookupsManager.taskStatuses.first
}
}
private func validateForm() -> Bool {
var isValid = true
if title.isEmpty {
titleError = "Title is required"
isValid = false
} else {
titleError = ""
}
if selectedCategory == nil {
viewModel.errorMessage = "Please select a category"
isValid = false
}
if selectedFrequency == nil {
viewModel.errorMessage = "Please select a frequency"
isValid = false
}
if selectedPriority == nil {
viewModel.errorMessage = "Please select a priority"
isValid = false
}
if selectedStatus == nil {
viewModel.errorMessage = "Please select a status"
isValid = false
}
return isValid
}
private func submitForm() {
guard validateForm() else { return }
guard let category = selectedCategory,
let frequency = selectedFrequency,
let priority = selectedPriority,
let status = selectedStatus else {
return
}
// Format date as yyyy-MM-dd
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dueDateString = dateFormatter.string(from: dueDate)
let request = TaskCreateRequest(
residence: residenceId,
title: title,
description: description.isEmpty ? nil : description,
category: Int32(category.id),
frequency: Int32(frequency.id),
intervalDays: intervalDays.isEmpty ? nil : Int32(intervalDays) as? KotlinInt,
priority: Int32(priority.id),
status: Int32(status.id),
dueDate: dueDateString,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)
viewModel.createTask(request: request) { success in
if success {
// View will dismiss automatically via onChange
}
}
}
}
// MARK: - Supporting Views
struct PickerField: View {
let label: String
let selectedItem: String
@Binding var showPicker: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
showPicker = true
}) {
HStack {
Text(selectedItem)
.foregroundColor(selectedItem.contains("Select") ? .gray : .primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
}
}
struct LookupItem: Identifiable {
let id: Int32
let name: String
let displayName: String
}
struct LookupPickerView: View {
let title: String
let items: [LookupItem]
let selectedId: Int32?
@Binding var isPresented: Bool
let onSelect: (Int32) -> Void
var body: some View {
NavigationView {
List(items) { item in
Button(action: {
onSelect(item.id)
isPresented = false
}) {
HStack {
Text(item.displayName)
.foregroundColor(.primary)
Spacer()
if selectedId == item.id {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
isPresented = false
}
}
}
}
}
}
#Preview {
AddTaskView(residenceId: 1, isPresented: .constant(true))
}

View File

@@ -0,0 +1,60 @@
import Foundation
import ComposeApp
import Combine
@MainActor
class TaskViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var taskCreated: Bool = false
// MARK: - Private Properties
private let taskApi: TaskApi
private let tokenStorage: TokenStorage
// MARK: - Initialization
init() {
self.taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
self.tokenStorage = TokenStorage()
self.tokenStorage.initialize(manager: TokenManager.init())
}
// MARK: - Public Methods
func createTask(request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskCreated = false
taskApi.createTask(token: token, request: request) { result, error in
if result is ApiResultSuccess<TaskDetail> {
self.isLoading = false
self.taskCreated = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func clearError() {
errorMessage = nil
}
func resetState() {
taskCreated = false
errorMessage = nil
}
}