- Date menu now offers "Pick Date" (and Edit) in the Matrix grid cards and the Calendar reschedule/move menu, matching the tasks view — both open the shared TaskEditSheet via a new editingTask sheet. - Reworked the reminder picker (TaskDatePickerSheet, shared by all entry points) from day/week offsets to sensible time-based ones relative to the event time: None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing absolute reminderDates migrate to the nearest minute offset. - Notifications gain Snooze actions (15/30/60/120 min) that reschedule the task reminder; postpone now clears the Matrix urgency override. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
912 lines
44 KiB
Swift
912 lines
44 KiB
Swift
import SwiftUI
|
||
|
||
struct MatrixView: View {
|
||
@Environment(\.colorScheme) var cs
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
@State private var showAddTask = false
|
||
@State private var dropTarget: Quadrant? = nil
|
||
@State private var hideCompleted = true
|
||
@State private var showUserGuide = false
|
||
@State private var drillQuadrant: Quadrant? = nil
|
||
@State private var showDrill = false
|
||
@State private var editingTask: TaskItem? = nil
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
// ── Header ──
|
||
HStack {
|
||
Text("Eisenhower Matrix")
|
||
.font(AppFonts.sans(17, weight: .bold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Menu {
|
||
Button {
|
||
// future edit mode
|
||
} label: {
|
||
Label("Edit", systemImage: "pencil")
|
||
}
|
||
Button {
|
||
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
|
||
} label: {
|
||
Label(hideCompleted ? "Show Completed" : "Hide Completed",
|
||
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
|
||
}
|
||
Button { showUserGuide = true } label: {
|
||
Label("User Guide", systemImage: "questionmark.circle")
|
||
}
|
||
} label: {
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundColor(AppColors.text2(cs))
|
||
.frame(width: 34, height: 34)
|
||
.background(AppColors.surface2(cs))
|
||
.clipShape(Circle())
|
||
.overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1))
|
||
}
|
||
}
|
||
.padding(.horizontal, 18)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 10)
|
||
|
||
// ── 2×2 Grid ──
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
QuadrantCard(
|
||
quadrant: .urgent,
|
||
roman: "I", label: "Urgent & Important",
|
||
badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent,
|
||
tasks: taskVM.matrixTasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
|
||
isDropTarget: dropTarget == .urgent,
|
||
onToggle: { taskVM.toggle($0) },
|
||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } },
|
||
onDropEnter: { dropTarget = .urgent },
|
||
onDropExit: { dropTarget = nil },
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) },
|
||
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
|
||
)
|
||
QuadrantCard(
|
||
quadrant: .schedule,
|
||
roman: "II", label: "Not Urgent & Important",
|
||
badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow,
|
||
tasks: taskVM.matrixTasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
|
||
isDropTarget: dropTarget == .schedule,
|
||
onToggle: { taskVM.toggle($0) },
|
||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } },
|
||
onDropEnter: { dropTarget = .schedule },
|
||
onDropExit: { dropTarget = nil },
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) },
|
||
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
|
||
)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
|
||
HStack(spacing: 8) {
|
||
QuadrantCard(
|
||
quadrant: .delegate_,
|
||
roman: "III", label: "Urgent & Unimportant",
|
||
badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue,
|
||
tasks: taskVM.matrixTasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
|
||
isDropTarget: dropTarget == .delegate_,
|
||
onToggle: { taskVM.toggle($0) },
|
||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } },
|
||
onDropEnter: { dropTarget = .delegate_ },
|
||
onDropExit: { dropTarget = nil },
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) },
|
||
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
|
||
)
|
||
QuadrantCard(
|
||
quadrant: .eliminate,
|
||
roman: "IV", label: "Not Urgent & Unimportant",
|
||
badgeBg: AppColors.greenSoft, badgeColor: AppColors.green,
|
||
tasks: taskVM.matrixTasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
|
||
isDropTarget: dropTarget == .eliminate,
|
||
onToggle: { taskVM.toggle($0) },
|
||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } },
|
||
onDropEnter: { dropTarget = .eliminate },
|
||
onDropExit: { dropTarget = nil },
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) },
|
||
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
|
||
)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 4)
|
||
|
||
// Explicit clearance so grid stops above custom tab bar
|
||
Color.clear.frame(height: 76)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.toolbar(.hidden, for: .navigationBar)
|
||
.sheet(isPresented: $showAddTask) {
|
||
AddTaskSheet(prefilledQuadrant: showDrill ? (drillQuadrant ?? .urgent) : .urgent)
|
||
.environmentObject(taskVM)
|
||
.presentationDetents([.height(150)])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.sheet(isPresented: $showUserGuide) {
|
||
MatrixUserGuideSheet()
|
||
.presentationDetents([.medium])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.sheet(item: $editingTask) { task in
|
||
TaskEditSheet(task: task)
|
||
.environmentObject(taskVM)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
.navigationDestination(isPresented: $showDrill) {
|
||
if let q = drillQuadrant {
|
||
QuadrantDetailView(quadrant: q)
|
||
.environmentObject(taskVM)
|
||
.toolbar(.visible, for: .navigationBar)
|
||
}
|
||
}
|
||
} // NavigationStack
|
||
|
||
FAB { showAddTask = true }
|
||
.padding(.trailing, 20)
|
||
.padding(.bottom, 10)
|
||
} // outer ZStack
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Quadrant Card
|
||
|
||
struct QuadrantCard: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let quadrant: Quadrant
|
||
let roman: String
|
||
let label: String
|
||
let badgeBg: Color
|
||
let badgeColor: Color
|
||
let tasks: [TaskItem]
|
||
let isDropTarget: Bool
|
||
let onToggle: (TaskItem) -> Void
|
||
let onDrop: (UUID) -> Void
|
||
let onDropEnter: () -> Void
|
||
let onDropExit: () -> Void
|
||
var onPin: ((TaskItem) -> Void)? = nil
|
||
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
||
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
||
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
||
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
|
||
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
|
||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||
var onEdit: ((TaskItem) -> Void)? = nil
|
||
var onDelete: ((TaskItem) -> Void)? = nil
|
||
var onTapHeader: (() -> Void)? = nil
|
||
|
||
private let shortFmt: DateFormatter = {
|
||
let f = DateFormatter(); f.dateFormat = "d MMM"; return f
|
||
}()
|
||
private let shortFmtYear: DateFormatter = {
|
||
let f = DateFormatter(); f.dateFormat = "d/MM/yyyy"; return f
|
||
}()
|
||
private func dateLabel(_ date: Date) -> String {
|
||
Calendar.current.isDateInThisYear(date) ? shortFmt.string(from: date) : shortFmtYear.string(from: date)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
// ── Header badge ──
|
||
HStack(spacing: 0) {
|
||
HStack(spacing: 5) {
|
||
Text(roman)
|
||
.font(AppFonts.mono(8, weight: .heavy))
|
||
.foregroundColor(.white)
|
||
.frame(width: 15, height: 15)
|
||
.background(badgeColor)
|
||
.clipShape(Circle())
|
||
Text(label.uppercased())
|
||
.font(AppFonts.mono(8.5, weight: .bold))
|
||
.foregroundColor(badgeColor)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.65)
|
||
if onTapHeader != nil {
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 7, weight: .bold))
|
||
.foregroundColor(badgeColor.opacity(0.7))
|
||
}
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(badgeBg)
|
||
.clipShape(Capsule())
|
||
.contentShape(Capsule())
|
||
.onTapGesture { onTapHeader?() }
|
||
Spacer()
|
||
}
|
||
.padding(.bottom, 8)
|
||
|
||
// ── Task list ──
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Color.clear.frame(height: 0).trackScrollForTabBar()
|
||
ForEach(tasks) { task in
|
||
HStack(alignment: .top, spacing: 8) {
|
||
// Circle checkbox
|
||
ZStack {
|
||
Circle()
|
||
.fill(task.isComplete ? badgeColor.opacity(0.15) : badgeBg)
|
||
.frame(width: 16, height: 16)
|
||
Circle()
|
||
.stroke(task.isComplete ? badgeColor.opacity(0.4) : badgeColor, lineWidth: 1.5)
|
||
.frame(width: 16, height: 16)
|
||
if task.isComplete {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 7, weight: .bold))
|
||
.foregroundColor(badgeColor)
|
||
}
|
||
}
|
||
.padding(.top, 1)
|
||
.onTapGesture {
|
||
withAnimation(KisaniSpring.bounce) { onToggle(task) }
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
if task.isPinned {
|
||
Image(systemName: "pin.fill")
|
||
.font(.system(size: 7, weight: .bold))
|
||
.foregroundColor(badgeColor.opacity(0.7))
|
||
}
|
||
if task.urgencyOverride != nil {
|
||
Text("Manual")
|
||
.font(AppFonts.mono(7, weight: .bold))
|
||
.foregroundColor(badgeColor)
|
||
.padding(.horizontal, 4)
|
||
.padding(.vertical, 2)
|
||
.background(badgeBg)
|
||
.clipShape(Capsule())
|
||
}
|
||
Text(task.title)
|
||
.font(AppFonts.sans(12, weight: .medium))
|
||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||
.lineLimit(2)
|
||
}
|
||
if let d = task.dueDate {
|
||
Text(dateLabel(d))
|
||
.font(AppFonts.mono(9.5))
|
||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : badgeColor)
|
||
}
|
||
}
|
||
}
|
||
.opacity(task.isComplete ? 0.4 : 1)
|
||
.padding(.vertical, 4)
|
||
.padding(.leading, 2)
|
||
.draggable(task.id.uuidString)
|
||
.contextMenu {
|
||
TaskMenuItems(
|
||
task: task,
|
||
currentQuadrant: quadrant,
|
||
onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } },
|
||
onSetDate: { onSetDate?($0, $1) },
|
||
onPostponeMinutes: { onPostponeMinutes?($0, $1) },
|
||
onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } },
|
||
onSetPriority: { onSetPriority?($0, $1) },
|
||
onSetCategory: { onSetCategory?($0, $1) },
|
||
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
|
||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||
onEdit: onEdit.map { cb in { cb($0) } },
|
||
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 10)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
.background(AppColors.surface(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.medium))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: AppRadius.medium)
|
||
.stroke(isDropTarget ? badgeColor : AppColors.border(cs),
|
||
lineWidth: isDropTarget ? 2 : 1)
|
||
)
|
||
.scaleEffect(isDropTarget ? 1.02 : 1)
|
||
.animation(KisaniSpring.micro, value: isDropTarget)
|
||
.dropDestination(for: String.self) { items, _ in
|
||
guard let idString = items.first,
|
||
let id = UUID(uuidString: idString) else { return false }
|
||
onDrop(id)
|
||
return true
|
||
} isTargeted: { targeted in
|
||
if targeted { onDropEnter() } else { onDropExit() }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - User Guide Sheet
|
||
|
||
private struct MatrixUserGuideSheet: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
private let quadrants: [(String, String, Color, Color, String)] = [
|
||
("I", "Urgent & Important", AppColors.accent, AppColors.accentSoft, "Do it now. Deadlines, crises, important tasks with time pressure."),
|
||
("II", "Not Urgent & Important", AppColors.yellow, AppColors.yellowSoft, "Schedule it. Planning, growth, and goals that matter long-term."),
|
||
("III", "Urgent & Unimportant", AppColors.blue, AppColors.blueSoft, "Delegate it. Interruptions and requests that others can handle."),
|
||
("IV", "Not Urgent & Unimportant", AppColors.green, AppColors.greenSoft, "Eliminate it. Time-wasters and low-value activities.")
|
||
]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("User Guide")
|
||
.font(AppFonts.sans(17, weight: .bold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Button { dismiss() } label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(AppColors.text2(cs))
|
||
.frame(width: 28, height: 28)
|
||
.background(AppColors.surface2(cs))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 16)
|
||
|
||
VStack(spacing: 10) {
|
||
ForEach(quadrants, id: \.0) { roman, label, color, bg, desc in
|
||
HStack(alignment: .top, spacing: 12) {
|
||
Text(roman)
|
||
.font(AppFonts.mono(9, weight: .heavy))
|
||
.foregroundColor(.white)
|
||
.frame(width: 20, height: 20)
|
||
.background(color)
|
||
.clipShape(Circle())
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(label)
|
||
.font(AppFonts.sans(13, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Text(desc)
|
||
.font(AppFonts.sans(12))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(bg)
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
private extension Calendar {
|
||
func isDateInThisYear(_ date: Date) -> Bool {
|
||
component(.year, from: date) == component(.year, from: Date())
|
||
}
|
||
}
|
||
|
||
// MARK: - Quadrant Detail View
|
||
|
||
struct QuadrantDetailView: View {
|
||
let quadrant: Quadrant
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
@Environment(\.colorScheme) var cs
|
||
@State private var editingTask: TaskItem? = nil
|
||
@State private var overdueExpanded = true
|
||
@State private var laterExpanded = true
|
||
@State private var completedExpanded = true
|
||
@State private var showAllCompleted = false
|
||
private let completedLimit = 5
|
||
|
||
// Grouped by the *computed* quadrant (importance × deadline urgency); recurring
|
||
// tasks contribute their single next-active occurrence.
|
||
private var overdueTasks: [TaskItem] {
|
||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||
return taskVM.matrixTasks(for: quadrant)
|
||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||
}
|
||
private var laterTasks: [TaskItem] {
|
||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||
return taskVM.matrixTasks(for: quadrant)
|
||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
|
||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||
}
|
||
private var completedTasks: [TaskItem] {
|
||
taskVM.matrixTasks(for: quadrant)
|
||
.filter { $0.isComplete }
|
||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||
}
|
||
private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty }
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(spacing: 12) {
|
||
if !overdueTasks.isEmpty {
|
||
QDSectionCard(title: "Overdue", count: overdueTasks.count,
|
||
expanded: $overdueExpanded, titleColor: .red) {
|
||
ForEach(overdueTasks) { task in
|
||
QDTaskRow(task: task, quadrant: quadrant,
|
||
onTap: { editingTask = task },
|
||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||
.contextMenu {
|
||
TaskMenuItems(
|
||
task: task,
|
||
currentQuadrant: quadrant,
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) }
|
||
)
|
||
}
|
||
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
|
||
}
|
||
}
|
||
}
|
||
if !laterTasks.isEmpty {
|
||
QDSectionCard(title: "Later", count: laterTasks.count,
|
||
expanded: $laterExpanded, titleColor: AppColors.text(cs)) {
|
||
ForEach(laterTasks) { task in
|
||
QDTaskRow(task: task, quadrant: quadrant,
|
||
onTap: { editingTask = task },
|
||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||
.contextMenu {
|
||
TaskMenuItems(
|
||
task: task,
|
||
currentQuadrant: quadrant,
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) }
|
||
)
|
||
}
|
||
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
|
||
}
|
||
}
|
||
}
|
||
if !completedTasks.isEmpty {
|
||
let displayed = showAllCompleted ? completedTasks : Array(completedTasks.prefix(completedLimit))
|
||
QDSectionCard(title: "Completed", count: completedTasks.count,
|
||
expanded: $completedExpanded, titleColor: AppColors.text2(cs)) {
|
||
ForEach(displayed) { task in
|
||
QDTaskRow(task: task, quadrant: quadrant,
|
||
onTap: { editingTask = task },
|
||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||
.contextMenu {
|
||
TaskMenuItems(
|
||
task: task,
|
||
currentQuadrant: quadrant,
|
||
onPin: { taskVM.pin($0) },
|
||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||
onEdit: { editingTask = $0 },
|
||
onDelete: { taskVM.delete($0) }
|
||
)
|
||
}
|
||
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
|
||
}
|
||
if completedTasks.count > completedLimit && !showAllCompleted {
|
||
Button { withAnimation(KisaniSpring.snappy) { showAllCompleted = true } } label: {
|
||
Text("View More")
|
||
.font(AppFonts.sans(13))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
if allEmpty {
|
||
VStack(spacing: 8) {
|
||
Text("No tasks")
|
||
.font(AppFonts.sans(15, weight: .semibold))
|
||
.foregroundColor(AppColors.text2(cs))
|
||
Text("Tap + to add a task to this quadrant")
|
||
.font(AppFonts.sans(12))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.top, 80)
|
||
}
|
||
Spacer(minLength: 100)
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 16)
|
||
}
|
||
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
.navigationTitle(quadrant.matrixLabel)
|
||
.navigationBarTitleDisplayMode(.large)
|
||
.sheet(item: $editingTask) { task in
|
||
TaskEditSheet(task: task)
|
||
.environmentObject(taskVM)
|
||
.presentationDetents([.medium, .large])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Section Card
|
||
|
||
private struct QDSectionCard<Content: View>: View {
|
||
@Environment(\.colorScheme) var cs
|
||
let title: String
|
||
let count: Int
|
||
@Binding var expanded: Bool
|
||
let titleColor: Color
|
||
@ViewBuilder let content: Content
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Button { withAnimation(KisaniSpring.snappy) { expanded.toggle() } } label: {
|
||
HStack {
|
||
Text(title)
|
||
.font(AppFonts.sans(15, weight: .semibold))
|
||
.foregroundColor(titleColor)
|
||
Spacer()
|
||
Text("\(count)")
|
||
.font(AppFonts.mono(12))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
.rotationEffect(.degrees(expanded ? 0 : -90))
|
||
.animation(KisaniSpring.micro, value: expanded)
|
||
}
|
||
.padding(.horizontal, 16).padding(.vertical, 14)
|
||
}
|
||
.buttonStyle(.plain)
|
||
if expanded {
|
||
Divider().padding(.leading, 16)
|
||
content.padding(.horizontal, 12).padding(.bottom, 6)
|
||
}
|
||
}
|
||
.background(AppColors.surface(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(AppColors.border(cs), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
// MARK: - Task Row in Detail
|
||
|
||
private struct QDTaskRow: View {
|
||
@Environment(\.colorScheme) var cs
|
||
let task: TaskItem
|
||
let quadrant: Quadrant
|
||
let onTap: () -> Void
|
||
let onToggle: () -> Void
|
||
|
||
private var isOverdue: Bool {
|
||
!task.isComplete && (task.dueDate ?? .distantFuture) < Calendar.current.startOfDay(for: Date())
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Button(action: onToggle) {
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.stroke(task.isComplete ? quadrant.color.opacity(0.3) : quadrant.color, lineWidth: 1.5)
|
||
.frame(width: 22, height: 22)
|
||
.overlay {
|
||
if task.isComplete {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 9, weight: .bold))
|
||
.foregroundColor(quadrant.color.opacity(0.5))
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button(action: onTap) {
|
||
HStack(alignment: .top) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if task.urgencyOverride != nil {
|
||
Text("Manual")
|
||
.font(AppFonts.mono(8, weight: .bold))
|
||
.foregroundColor(quadrant.color)
|
||
.padding(.horizontal, 5)
|
||
.padding(.vertical, 2)
|
||
.background(quadrant.softColor)
|
||
.clipShape(Capsule())
|
||
}
|
||
Text(task.title)
|
||
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
|
||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||
.lineLimit(2)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
VStack(alignment: .trailing, spacing: 3) {
|
||
if let d = task.dueDate {
|
||
Text(dateStr(d))
|
||
.font(AppFonts.mono(11))
|
||
.foregroundColor(isOverdue ? .red : (task.isComplete ? AppColors.text3(cs) : quadrant.color))
|
||
}
|
||
HStack(spacing: 4) {
|
||
if task.reminderDate != nil {
|
||
Image(systemName: "alarm")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
if task.isRecurring {
|
||
Image(systemName: "arrow.clockwise")
|
||
.font(.system(size: 9))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.vertical, 10)
|
||
.opacity(task.isComplete ? 0.55 : 1)
|
||
}
|
||
|
||
private func dateStr(_ d: Date) -> String {
|
||
let f = DateFormatter()
|
||
f.dateFormat = Calendar.current.isDateInThisYear(d) ? "MMM d" : "dd/MM/yyyy"
|
||
return f.string(from: d)
|
||
}
|
||
}
|
||
|
||
// MARK: - Task Edit Sheet
|
||
|
||
struct TaskEditSheet: View {
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
@Environment(\.colorScheme) var cs
|
||
@Environment(\.dismiss) var dismiss
|
||
|
||
let task: TaskItem
|
||
|
||
@State private var title: String
|
||
@State private var dueDate: Date?
|
||
@State private var hasTime: Bool
|
||
@State private var isRecurring: Bool
|
||
@State private var recurrenceLabel: String?
|
||
@State private var recurrenceEnd: Date?
|
||
@State private var endDate: Date?
|
||
@State private var isAllDay: Bool
|
||
@State private var reminderDate: Date?
|
||
@State private var constantReminder: Bool
|
||
@State private var showDatePicker = false
|
||
|
||
init(task: TaskItem) {
|
||
self.task = task
|
||
_title = State(initialValue: task.title)
|
||
_dueDate = State(initialValue: task.dueDate)
|
||
_hasTime = State(initialValue: task.hasTime)
|
||
_isRecurring = State(initialValue: task.isRecurring)
|
||
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
||
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
|
||
_endDate = State(initialValue: task.endDate)
|
||
_isAllDay = State(initialValue: task.isAllDay)
|
||
_reminderDate = State(initialValue: task.reminderDate)
|
||
_constantReminder = State(initialValue: task.constantReminder)
|
||
}
|
||
|
||
private var dateSummary: String {
|
||
guard let d = dueDate else { return "No Date" }
|
||
let cal = Calendar.current
|
||
let fmt = DateFormatter()
|
||
if hasTime {
|
||
fmt.dateFormat = cal.isDateInToday(d) ? "'Today,' HH:mm"
|
||
: cal.isDateInTomorrow(d) ? "'Tomorrow,' HH:mm" : "EEE, MMM d, HH:mm"
|
||
} else {
|
||
if cal.isDateInToday(d) { return "Today" }
|
||
if cal.isDateInTomorrow(d) { return "Tomorrow" }
|
||
fmt.dateFormat = "EEE, MMM d"
|
||
}
|
||
return fmt.string(from: d)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
VStack(spacing: 0) {
|
||
// ── Quadrant label ──
|
||
HStack {
|
||
HStack(spacing: 5) {
|
||
Text(task.quadrant.roman)
|
||
.font(AppFonts.mono(8, weight: .heavy))
|
||
.foregroundColor(.white)
|
||
.frame(width: 14, height: 14)
|
||
.background(task.quadrant.color)
|
||
.clipShape(Circle())
|
||
Text(task.quadrant.matrixLabel.uppercased())
|
||
.font(AppFonts.mono(8, weight: .bold))
|
||
.foregroundColor(task.quadrant.color)
|
||
}
|
||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||
.background(task.quadrant.softColor)
|
||
.clipShape(Capsule())
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 12)
|
||
|
||
// ── Date / reminder / recurrence row ──
|
||
if dueDate != nil || reminderDate != nil || isRecurring {
|
||
Button { showDatePicker = true } label: {
|
||
HStack(spacing: 8) {
|
||
Text(dateSummary)
|
||
.font(AppFonts.sans(13, weight: .semibold))
|
||
.foregroundColor(task.quadrant.color)
|
||
if reminderDate != nil {
|
||
Image(systemName: "alarm")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(task.quadrant.color)
|
||
}
|
||
if isRecurring {
|
||
Image(systemName: "arrow.clockwise")
|
||
.font(.system(size: 12))
|
||
.foregroundColor(task.quadrant.color)
|
||
if let lbl = recurrenceLabel {
|
||
Text(lbl)
|
||
.font(AppFonts.sans(12))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 20).padding(.bottom, 8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
Divider().padding(.horizontal, 20)
|
||
|
||
// ── Title ──
|
||
TextField("Task title", text: $title, axis: .vertical)
|
||
.font(AppFonts.sans(22, weight: .bold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
.padding(.horizontal, 20)
|
||
.padding(.top, 16)
|
||
.lineLimit(1...5)
|
||
|
||
Spacer()
|
||
|
||
// ── Bottom toolbar ──
|
||
Divider()
|
||
HStack(spacing: 20) {
|
||
Button { showDatePicker = true } label: {
|
||
Image(systemName: dueDate != nil ? "calendar.badge.checkmark" : "calendar")
|
||
.font(.system(size: 18))
|
||
.foregroundColor(dueDate != nil ? task.quadrant.color : AppColors.text3(cs))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button { showDatePicker = true } label: {
|
||
Image(systemName: "alarm")
|
||
.font(.system(size: 18))
|
||
.foregroundColor(reminderDate != nil ? task.quadrant.color : AppColors.text3(cs))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button { showDatePicker = true } label: {
|
||
Image(systemName: "arrow.clockwise")
|
||
.font(.system(size: 18))
|
||
.foregroundColor(isRecurring ? task.quadrant.color : AppColors.text3(cs))
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Spacer()
|
||
|
||
Button(action: save) {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 14, weight: .bold))
|
||
.foregroundColor(.white)
|
||
.frame(width: 36, height: 36)
|
||
.background(title.trimmingCharacters(in: .whitespaces).isEmpty ? Color.gray : task.quadrant.color)
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 12)
|
||
.background(AppColors.surface(cs).ignoresSafeArea())
|
||
}
|
||
.background(AppColors.surface(cs).ignoresSafeArea())
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button { dismiss() } label: {
|
||
Image(systemName: "xmark")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundColor(AppColors.text2(cs))
|
||
.frame(width: 28, height: 28)
|
||
.background(AppColors.surface2(cs))
|
||
.clipShape(Circle())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showDatePicker) {
|
||
TaskDatePickerSheet(
|
||
selectedDate: $dueDate,
|
||
hasTime: $hasTime,
|
||
isRecurring: $isRecurring,
|
||
recurrenceLabel: $recurrenceLabel,
|
||
endDate: $endDate,
|
||
isAllDay: $isAllDay,
|
||
reminderDate: $reminderDate,
|
||
constantReminder: $constantReminder,
|
||
recurrenceEnd: $recurrenceEnd
|
||
)
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let t = title.trimmingCharacters(in: .whitespaces)
|
||
guard !t.isEmpty else { return }
|
||
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
|
||
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
|
||
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
|
||
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
|
||
dismiss()
|
||
}
|
||
}
|