Files
KisaniCal/KisaniCal/Views/MatrixView.swift
kutesir 2f1bc5d1c3 Matrix: classic Eisenhower — you set importance, deadline sets urgency
Quadrant is now computed (importance x urgency) instead of a fixed
assignment. Importance comes from the task's assigned row (Q1/Q2 vs
Q3/Q4); urgency is derived from the (next active) due date being within
urgentWindowDays (7). So important tasks slide Q2->Q1 as they near,
unimportant slide Q4->Q3, and recurring tasks roll forward (a completed
occurrence is replaced by the next, never piling up in a quadrant).

- VM: displayQuadrant/isUrgent/effectiveDue; matrixTasks groups by the
  computed quadrant and uses each recurring task's single next occurrence.
- MatrixView grid + drill-down (overdue/later/completed) group by it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:00:29 +03:00

861 lines
41 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = false
@State private var showUserGuide = false
@State private var drillQuadrant: Quadrant? = nil
@State private var showDrill = false
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
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)
}
.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 onMove: ((TaskItem, Quadrant) -> Void)? = nil
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
var onSetPriority: ((TaskItem, Priority) -> 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))
}
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) },
onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } },
onSetPriority: { onSetPriority?($0, $1) },
onSetCategory: { onSetCategory?($0, $1) },
onLiveActivity:{ LiveActivityManager.toggle(for: $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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
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) {
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()
}
}