Files
honeyDueKMP/iosApp/iosApp/Task/AllTasksView.swift
Trey t b922c4fb88 wip
2025-11-07 14:53:14 -06:00

516 lines
19 KiB
Swift

import SwiftUI
import ComposeApp
struct AllTasksView: View {
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
@State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false
@State private var tasksError: String?
@State private var showAddTask = false
@State private var showEditTask = false
@State private var selectedTaskForEdit: TaskDetail?
@State private var selectedTaskForComplete: TaskDetail?
private var hasNoTasks: Bool {
guard let response = tasksResponse else { return true }
return response.columns.allSatisfy { $0.tasks.isEmpty }
}
private var hasTasks: Bool {
!hasNoTasks
}
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if isLoadingTasks {
ProgressView()
} else if let error = tasksError {
ErrorView(message: error) {
loadAllTasks()
}
} else if let tasksResponse = tasksResponse {
if hasNoTasks {
// Empty state with big button
VStack(spacing: 24) {
Spacer()
Image(systemName: "checklist")
.font(.system(size: 64))
.foregroundStyle(.blue.opacity(0.6))
Text("No tasks yet")
.font(.title2)
.fontWeight(.semibold)
Text("Create your first task to get started")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button(action: {
showAddTask = true
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
Text("Add Task")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 48)
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
Text("Add a property first from the Residences tab")
.font(.caption)
.foregroundColor(.red)
}
Spacer()
}
.padding()
} else {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
// Dynamically create columns from response
ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in
DynamicTaskColumnView(
column: column,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { taskId in
taskViewModel.cancelTask(id: taskId) { _ in
loadAllTasks()
}
},
onUncancelTask: { taskId in
taskViewModel.uncancelTask(id: taskId) { _ in
loadAllTasks()
}
},
onMarkInProgress: { taskId in
taskViewModel.markInProgress(id: taskId) { success in
if success {
loadAllTasks()
}
}
},
onCompleteTask: { task in
selectedTaskForComplete = task
},
onArchiveTask: { taskId in
taskViewModel.archiveTask(id: taskId) { _ in
loadAllTasks()
}
},
onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: taskId) { _ in
loadAllTasks()
}
}
)
.frame(width: geometry.size.width - 48)
}
}
.scrollTargetLayout()
.padding(.horizontal, 16)
}
.scrollTargetBehavior(.viewAligned)
}
}
}
}
.ignoresSafeArea(edges: .bottom)
.navigationTitle("All Tasks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showAddTask = true
}) {
Image(systemName: "plus")
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
}
}
.sheet(isPresented: $showAddTask) {
AddTaskWithResidenceView(
isPresented: $showAddTask,
residences: residenceViewModel.myResidences?.residences.toResidences() ?? [],
)
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.sheet(item: $selectedTaskForComplete) { task in
CompleteTaskView(task: task, isPresented: .constant(true)) {
selectedTaskForComplete = nil
loadAllTasks()
}
}
.onChange(of: showAddTask) { isShowing in
if !isShowing {
loadAllTasks()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadAllTasks()
}
}
.onAppear {
loadAllTasks()
residenceViewModel.loadMyResidences()
}
}
private func loadAllTasks() {
guard let token = TokenStorage.shared.getToken() else { return }
isLoadingTasks = true
tasksError = nil
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
taskApi.getTasks(token: token, days: 30) { result, error in
if let successResult = result as? ApiResultSuccess<TaskColumnsResponse> {
self.tasksResponse = successResult.data
self.isLoadingTasks = false
} else if let errorResult = result as? ApiResultError {
self.tasksError = errorResult.message
self.isLoadingTasks = false
} else if let error = error {
self.tasksError = error.localizedDescription
self.isLoadingTasks = false
}
}
}
}
/// Dynamic task column view that adapts based on the column configuration
struct DynamicTaskColumnView: View {
let column: TaskColumn
let onEditTask: (TaskDetail) -> Void
let onCancelTask: (Int32) -> Void
let onUncancelTask: (Int32) -> Void
let onMarkInProgress: (Int32) -> Void
let onCompleteTask: (TaskDetail) -> Void
let onArchiveTask: (Int32) -> Void
let onUnarchiveTask: (Int32) -> Void
// Get icon from API response, with fallback
private var columnIcon: String {
column.icons["ios"] ?? "list.bullet"
}
private var columnColor: Color {
Color(hex: column.color) ?? .primary
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 16) {
// Header
HStack(spacing: 8) {
Image(systemName: columnIcon)
.font(.headline)
.foregroundColor(columnColor)
Text(column.displayName)
.font(.headline)
.foregroundColor(columnColor)
Spacer()
Text("\(column.count)")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(columnColor)
.cornerRadius(12)
}
if column.tasks.isEmpty {
VStack(spacing: 8) {
Image(systemName: columnIcon)
.font(.system(size: 40))
.foregroundColor(columnColor.opacity(0.3))
Text("No tasks")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
} else {
ForEach(column.tasks, id: \.id) { task in
DynamicTaskCard(
task: task,
buttonTypes: column.buttonTypes,
onEdit: { onEditTask(task) },
onCancel: { onCancelTask(task.id) },
onUncancel: { onUncancelTask(task.id) },
onMarkInProgress: { onMarkInProgress(task.id) },
onComplete: { onCompleteTask(task) },
onArchive: { onArchiveTask(task.id) },
onUnarchive: { onUnarchiveTask(task.id) }
)
}
}
}
}
}
}
}
/// Task card that dynamically renders buttons based on the column's button types
struct DynamicTaskCard: View {
let task: TaskDetail
let buttonTypes: [String]
let onEdit: () -> Void
let onCancel: () -> Void
let onUncancel: () -> Void
let onMarkInProgress: () -> Void
let onComplete: () -> Void
let onArchive: () -> Void
let onUnarchive: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.headline)
.foregroundColor(.primary)
if let status = task.status {
StatusBadge(status: status.name)
}
}
Spacer()
PriorityBadge(priority: task.priority.name)
}
if let description = task.description_, !description.isEmpty {
Text(description)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack {
Label(task.frequency.displayName, systemImage: "repeat")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Label(formatDate(task.dueDate), systemImage: "calendar")
.font(.caption)
.foregroundColor(.secondary)
}
if task.completions.count > 0 {
Divider()
HStack {
Image(systemName: "checkmark.circle")
.foregroundColor(.green)
Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")")
.font(.caption)
.foregroundColor(.secondary)
}
}
// Render buttons based on buttonTypes array
VStack(spacing: 8) {
ForEach(Array(buttonTypes.enumerated()), id: \.offset) { index, buttonType in
renderButton(for: buttonType)
}
}
}
.padding(16)
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 2)
}
private func formatDate(_ dateString: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
if let date = formatter.date(from: dateString) {
formatter.dateStyle = .medium
return formatter.string(from: date)
}
return dateString
}
@ViewBuilder
private func renderButton(for buttonType: String) -> some View {
switch buttonType {
case "mark_in_progress":
MarkInProgressButton(
taskId: task.id,
onCompletion: onMarkInProgress,
onError: { error in
print("Error marking in progress: \(error)")
}
)
case "complete":
CompleteTaskButton(
taskId: task.id,
onCompletion: onComplete,
onError: { error in
print("Error completing task: \(error)")
}
)
case "edit":
EditTaskButton(
taskId: task.id,
onCompletion: onEdit,
onError: { error in
print("Error editing task: \(error)")
}
)
case "cancel":
CancelTaskButton(
taskId: task.id,
onCompletion: onCancel,
onError: { error in
print("Error cancelling task: \(error)")
}
)
case "uncancel":
UncancelTaskButton(
taskId: task.id,
onCompletion: onUncancel,
onError: { error in
print("Error restoring task: \(error)")
}
)
case "archive":
ArchiveTaskButton(
taskId: task.id,
onCompletion: onArchive,
onError: { error in
print("Error archiving task: \(error)")
}
)
case "unarchive":
UnarchiveTaskButton(
taskId: task.id,
onCompletion: onUnarchive,
onError: { error in
print("Error unarchiving task: \(error)")
}
)
default:
EmptyView()
}
}
}
// Extension to apply corner radius to specific corners
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
#Preview {
NavigationView {
AllTasksView()
}
}
extension Array where Element == ResidenceWithTasks {
/// Converts an array of ResidenceWithTasks into an array of Residence.
/// Adjust the mapping inside as needed to match your model initializers.
func toResidences() -> [Residence] {
return self.map { item in
return Residence(
id: item.id,
owner: KotlinInt(value: item.owner),
ownerUsername: item.ownerUsername,
name: item.name,
propertyType: item.propertyType,
streetAddress: item.streetAddress,
apartmentUnit: item.apartmentUnit,
city: item.city,
stateProvince: item.stateProvince,
postalCode: item.postalCode,
country: item.country,
bedrooms: item.bedrooms != nil ? KotlinInt(nonretainedObject: item.bedrooms!) : nil,
bathrooms: item.bathrooms != nil ? KotlinFloat(float: Float(item.bathrooms!)) : nil,
squareFootage: item.squareFootage != nil ? KotlinInt(nonretainedObject: item.squareFootage!) : nil,
lotSize: item.lotSize != nil ? KotlinFloat(float: Float(item.lotSize!)) : nil,
yearBuilt: item.yearBuilt != nil ? KotlinInt(nonretainedObject: item.yearBuilt!) : nil,
description: item.description,
purchaseDate: item.purchaseDate,
purchasePrice: item.purchasePrice,
isPrimary: item.isPrimary,
createdAt: item.createdAt,
updatedAt: item.updatedAt
)
}
}
}
extension Color {
/// Initialize Color from hex string (e.g., "#007AFF" or "007AFF")
init?(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
return nil
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}