516 lines
19 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|