wip
This commit is contained in:
432
iosApp/iosApp/Task/AddTaskView.swift
Normal file
432
iosApp/iosApp/Task/AddTaskView.swift
Normal 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))
|
||||
}
|
||||
60
iosApp/iosApp/Task/TaskViewModel.swift
Normal file
60
iosApp/iosApp/Task/TaskViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user