Files
KisaniCal/KisaniCal/Views/MatrixView.swift
kutesir ac147c55b0 Matrix/menus: Pick Date everywhere, time-based reminders, notification snooze (KC-21)
- 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>
2026-06-15 18:14:10 +03:00

912 lines
44 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 = 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()
}
}