Complete iOS document form implementation and improve login error handling
This commit completes the DRY refactoring by implementing the missing document form functionality and enhancing user experience with better error messages. ## iOS Document Forms - Implemented complete createDocument() method in DocumentViewModel: - Support for all warranty-specific fields (itemName, modelNumber, serialNumber, provider, etc.) - Multiple image uploads with JPEG compression - Proper UIImage to KotlinByteArray conversion - Async completion handlers - Implemented updateDocument() method with full field support - Completed DocumentFormView submitForm() implementation with proper API calls - Fixed type conversion issues (Bool/KotlinBoolean, Int32/KotlinInt) - Added proper error handling and user feedback ## iOS Login Error Handling - Enhanced error messages to be user-friendly and concise - Added specific messages for common HTTP error codes (400, 401, 403, 404, 500+) - Implemented cleanErrorMessage() helper to remove technical jargon - Added network-specific error handling (connection, timeout) - Fixed MainActor isolation warnings with proper Task wrapping ## Code Quality - Removed ~4,086 lines of duplicate code through form consolidation - Added 429 lines of new shared form components - Fixed Swift compiler performance issues - Ensured both iOS and Android builds succeed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
283
iosApp/iosApp/Task/TaskFormView.swift
Normal file
283
iosApp/iosApp/Task/TaskFormView.swift
Normal file
@@ -0,0 +1,283 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct TaskFormView: View {
|
||||
let residenceId: Int32?
|
||||
let residences: [Residence]?
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@StateObject private var lookupsManager = LookupsManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private var needsResidenceSelection: Bool {
|
||||
residenceId == nil
|
||||
}
|
||||
|
||||
// Form fields
|
||||
@State private var selectedResidence: Residence?
|
||||
@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 = ""
|
||||
@State private var residenceError: String = ""
|
||||
|
||||
enum Field {
|
||||
case title, description, intervalDays, estimatedCost
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if lookupsManager.isLoading {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Loading...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
// Residence Picker (only if needed)
|
||||
if needsResidenceSelection, let residences = residences {
|
||||
Section(header: Text("Property")) {
|
||||
Picker("Property", selection: $selectedResidence) {
|
||||
Text("Select Property").tag(nil as Residence?)
|
||||
ForEach(residences, id: \.id) { residence in
|
||||
Text(residence.name).tag(residence as Residence?)
|
||||
}
|
||||
}
|
||||
|
||||
if !residenceError.isEmpty {
|
||||
Text(residenceError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Task Details")) {
|
||||
TextField("Title", text: $title)
|
||||
.focused($focusedField, equals: .title)
|
||||
|
||||
if !titleError.isEmpty {
|
||||
Text(titleError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
|
||||
TextField("Description (optional)", text: $description, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
.focused($focusedField, equals: .description)
|
||||
}
|
||||
|
||||
Section(header: Text("Category")) {
|
||||
Picker("Category", selection: $selectedCategory) {
|
||||
Text("Select Category").tag(nil as TaskCategory?)
|
||||
ForEach(lookupsManager.taskCategories, id: \.id) { category in
|
||||
Text(category.name.capitalized).tag(category as TaskCategory?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Scheduling")) {
|
||||
Picker("Frequency", selection: $selectedFrequency) {
|
||||
Text("Select Frequency").tag(nil as TaskFrequency?)
|
||||
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
|
||||
Text(frequency.displayName).tag(frequency as TaskFrequency?)
|
||||
}
|
||||
}
|
||||
|
||||
if selectedFrequency?.name != "once" {
|
||||
TextField("Custom Interval (days, optional)", text: $intervalDays)
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .intervalDays)
|
||||
}
|
||||
|
||||
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
|
||||
}
|
||||
|
||||
Section(header: Text("Priority & Status")) {
|
||||
Picker("Priority", selection: $selectedPriority) {
|
||||
Text("Select Priority").tag(nil as TaskPriority?)
|
||||
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
|
||||
Text(priority.displayName).tag(priority as TaskPriority?)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Status", selection: $selectedStatus) {
|
||||
Text("Select Status").tag(nil as TaskStatus?)
|
||||
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
|
||||
Text(status.displayName).tag(status as TaskStatus?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Cost")) {
|
||||
TextField("Estimated Cost (optional)", text: $estimatedCost)
|
||||
.keyboardType(.decimalPad)
|
||||
.focused($focusedField, equals: .estimatedCost)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Add Task")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
submitForm()
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
|
||||
// Set default residence if provided
|
||||
if needsResidenceSelection && selectedResidence == nil, let residences = residences, !residences.isEmpty {
|
||||
selectedResidence = residences.first
|
||||
}
|
||||
}
|
||||
|
||||
private func validateForm() -> Bool {
|
||||
var isValid = true
|
||||
|
||||
if title.isEmpty {
|
||||
titleError = "Title is required"
|
||||
isValid = false
|
||||
} else {
|
||||
titleError = ""
|
||||
}
|
||||
|
||||
if needsResidenceSelection && selectedResidence == nil {
|
||||
residenceError = "Property is required"
|
||||
isValid = false
|
||||
} else {
|
||||
residenceError = ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Determine the actual residence ID to use
|
||||
let actualResidenceId: Int32
|
||||
if let providedId = residenceId {
|
||||
actualResidenceId = providedId
|
||||
} else if let selected = selectedResidence {
|
||||
actualResidenceId = Int32(selected.id)
|
||||
} 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: actualResidenceId,
|
||||
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: selectedStatus.map { KotlinInt(value: $0.id) },
|
||||
dueDate: dueDateString,
|
||||
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost,
|
||||
archived: false
|
||||
)
|
||||
|
||||
viewModel.createTask(request: request) { success in
|
||||
if success {
|
||||
// View will dismiss automatically via onChange
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("With Residence ID") {
|
||||
TaskFormView(residenceId: 1, residences: nil, isPresented: .constant(true))
|
||||
}
|
||||
|
||||
#Preview("With Residence Selection") {
|
||||
TaskFormView(residenceId: nil, residences: [], isPresented: .constant(true))
|
||||
}
|
||||
Reference in New Issue
Block a user