Matrix: TickTick-style Priority×deadline view + Progress Dots widget (KC-21)
Matrix (KC-21): the Eisenhower Matrix is now a computed view of tasks. Importance = Priority (High = top row), urgency = deadline proximity, so tasks auto-place into Q1–Q4. Added a persisted `matrixOverride` so a manual drag pins the task and syncs its Priority to that row (top → High, moving down demotes High → Medium) and never gets forced back; editing Priority, due date, or the editor clears the override to re-place live. New tasks get KisaniCal priority defaults (birthday/domain/annual/exam/subscription → High, workout → Medium). AddTaskSheet drops the manual quadrant picker for a Priority menu (pick Priority + Date only). Also includes in-progress Progress Dots widget work (day/week/month/custom event modes, App-Group anchor, recurrence-aware spans). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,20 +47,23 @@ struct TaskMenuItems: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button { onSetDate(task, cal.startOfDay(for: Date())) } label: {
|
||||
Label("Today", systemImage: "sun.max")
|
||||
Button { onSetDate(task, preservingTime(on: Date())) } label: {
|
||||
Label("Today", systemImage: "calendar")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
||||
Label("Tomorrow", systemImage: "sun.max")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: nextMonday())) } label: {
|
||||
Label("Next Monday", systemImage: "calendar.badge.clock")
|
||||
}
|
||||
Button {
|
||||
let d = cal.date(byAdding: .day, value: 1, to: Date())!
|
||||
onSetDate(task, cal.startOfDay(for: d))
|
||||
} label: { Label("Tomorrow", systemImage: "sunrise") }
|
||||
Button {
|
||||
let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())!
|
||||
onSetDate(task, cal.startOfDay(for: d))
|
||||
} label: { Label("Next Week", systemImage: "calendar") }
|
||||
Divider()
|
||||
if let onEdit {
|
||||
Button { onEdit(task) } label: {
|
||||
Label("Pick Date", systemImage: "calendar.badge.plus")
|
||||
}
|
||||
}
|
||||
Button { onSetDate(task, nil) } label: {
|
||||
Label("Remove Date", systemImage: "xmark.circle")
|
||||
Label("Clear", systemImage: "xmark.square")
|
||||
}
|
||||
} label: { Label("Date", systemImage: "calendar.badge.clock") }
|
||||
|
||||
@@ -87,7 +90,7 @@ struct TaskMenuItems: View {
|
||||
} label: { Label("Tags", systemImage: "tag") }
|
||||
|
||||
Button { onLiveActivity(task) } label: {
|
||||
Label("Add to Live Activity", systemImage: "pin.circle")
|
||||
Label(liveActivityTitle, systemImage: liveActivityIcon)
|
||||
}
|
||||
|
||||
Button { CountdownTracker.toggle(task) } label: {
|
||||
@@ -105,4 +108,36 @@ struct TaskMenuItems: View {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "Remove from Live Activity"
|
||||
}
|
||||
return "Add to Live Activity"
|
||||
}
|
||||
|
||||
private var liveActivityIcon: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "pin.slash"
|
||||
}
|
||||
return "pin.circle"
|
||||
}
|
||||
|
||||
private func dayOffset(_ value: Int) -> Date {
|
||||
cal.date(byAdding: .day, value: value, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
private func nextMonday() -> Date {
|
||||
var comps = DateComponents()
|
||||
comps.weekday = 2
|
||||
return cal.nextDate(after: Date(), matching: comps, matchingPolicy: .nextTimePreservingSmallerComponents)
|
||||
?? dayOffset(7)
|
||||
}
|
||||
|
||||
private func preservingTime(on day: Date) -> Date {
|
||||
let base = cal.startOfDay(for: day)
|
||||
guard task.hasTime, let due = task.dueDate else { return base }
|
||||
let parts = cal.dateComponents([.hour, .minute], from: due)
|
||||
return cal.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: 0, of: base) ?? base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,11 @@ struct TodayView: View {
|
||||
onPostponeTask: { taskVM.postpone($0) },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 10)
|
||||
@@ -139,7 +143,13 @@ struct TodayView: View {
|
||||
tasks: taskVM.tomorrowTasks,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onPostpone: { taskVM.postpone($0) },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
onPin: { taskVM.pin($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,7 +160,13 @@ struct TodayView: View {
|
||||
tasks: taskVM.next7DaysTasks,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onPostpone: { taskVM.postpone($0) },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
onPin: { taskVM.pin($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -161,7 +177,13 @@ struct TodayView: View {
|
||||
tasks: taskVM.laterTasks,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onPostpone: { taskVM.postpone($0) },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
onPin: { taskVM.pin($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -512,6 +534,10 @@ struct OverdueCard: View {
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onEdit: (TaskItem) -> Void = { _ in }
|
||||
var onDelete: (TaskItem) -> Void = { _ in }
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
|
||||
@State private var expanded = true
|
||||
|
||||
@@ -599,25 +625,24 @@ struct OverdueCard: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func taskMenu(_ task: TaskItem) -> some View {
|
||||
Button { withAnimation { onToggle(task) } } label: {
|
||||
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
|
||||
Label("Mark Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { onPostponeTask(task) } label: {
|
||||
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
|
||||
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Button { onPin(task) } label: {
|
||||
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
Button { onEdit(task) } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
ShareLink(item: task.title) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { onDelete(task) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
onPin: onPin,
|
||||
onSetDate: onSetDate,
|
||||
onMove: onMove,
|
||||
onSetPriority: onSetPriority,
|
||||
onSetCategory: onSetCategory,
|
||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,7 +944,13 @@ struct UpcomingSection: View {
|
||||
let tasks: [TaskItem]
|
||||
let onToggle: (TaskItem) -> Void
|
||||
let onPostpone: (TaskItem) -> Void
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onEdit: (TaskItem) -> Void = { _ in }
|
||||
let onDelete: (TaskItem) -> Void
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
|
||||
@State private var collapsed = false
|
||||
@State private var showAll = false
|
||||
@@ -972,13 +1003,21 @@ struct UpcomingSection: View {
|
||||
withAnimation(KisaniSpring.snappy) { onToggle(task) }
|
||||
}
|
||||
.contextMenu {
|
||||
Button { withAnimation { onPostpone(task) } } label: {
|
||||
Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
|
||||
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { withAnimation { onDelete(task) } } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
onPin: onPin,
|
||||
onSetDate: onSetDate,
|
||||
onMove: onMove,
|
||||
onSetPriority: onSetPriority,
|
||||
onSetCategory: onSetCategory,
|
||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit,
|
||||
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 5)
|
||||
@@ -1682,7 +1721,7 @@ struct AddTaskSheet: View {
|
||||
|
||||
@State private var rawText = ""
|
||||
@State private var parsed = NLParsed()
|
||||
@State private var selectedQuadrant: Quadrant
|
||||
@State private var selectedPriority: Priority = .none
|
||||
@State private var selectedCategory: TaskCategory = .reminder
|
||||
@State private var reminderDate: Date? = nil
|
||||
@State private var constantReminder = false
|
||||
@@ -1694,7 +1733,9 @@ struct AddTaskSheet: View {
|
||||
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
|
||||
self.prefilledDate = prefilledDate
|
||||
self.prefilledQuadrant = prefilledQuadrant
|
||||
_selectedQuadrant = State(initialValue: prefilledQuadrant)
|
||||
// Adding from a Matrix quadrant: seed the Priority that lands the task there.
|
||||
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
|
||||
_selectedPriority = State(initialValue: importantRow ? .high : .none)
|
||||
var p = NLParsed()
|
||||
p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
|
||||
_parsed = State(initialValue: p)
|
||||
@@ -1762,27 +1803,18 @@ struct AddTaskSheet: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
// Quadrant
|
||||
// Priority — drives Matrix placement (importance axis), TickTick-style
|
||||
Menu {
|
||||
ForEach(Quadrant.allCases) { q in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
|
||||
Label {
|
||||
Text(q.matrixLabel)
|
||||
} icon: {
|
||||
Image(uiImage: quadrantBadgeImage(q))
|
||||
}
|
||||
ForEach(Priority.allCases) { p in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
|
||||
Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(selectedQuadrant.roman)
|
||||
.font(AppFonts.mono(8, weight: .heavy))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 14, height: 14)
|
||||
.background(selectedQuadrant.color)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.frame(width: 42, height: 44)
|
||||
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
|
||||
.frame(width: 42, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -1858,35 +1890,14 @@ struct AddTaskSheet: View {
|
||||
private func submit() {
|
||||
guard canAdd else { return }
|
||||
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
|
||||
quadrant: selectedQuadrant, category: selectedCategory,
|
||||
quadrant: prefilledQuadrant, category: selectedCategory,
|
||||
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
|
||||
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
|
||||
priority: selectedPriority,
|
||||
reminderDate: reminderDate, constantReminder: constantReminder,
|
||||
recurrenceEnd: parsed.recurrenceEnd)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
|
||||
let uiColor: UIColor
|
||||
switch q {
|
||||
case .urgent: uiColor = UIColor(AppColors.accent)
|
||||
case .schedule: uiColor = UIColor(AppColors.yellow)
|
||||
case .delegate_: uiColor = UIColor(AppColors.blue)
|
||||
case .eliminate: uiColor = UIColor(AppColors.green)
|
||||
}
|
||||
let size = CGSize(width: 20, height: 20)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let img = renderer.image { ctx in
|
||||
uiColor.setFill()
|
||||
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
|
||||
let label = q.roman
|
||||
let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white]
|
||||
let ts = label.size(withAttributes: attrs)
|
||||
label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs)
|
||||
}
|
||||
return img.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Date Picker Sheet
|
||||
|
||||
@@ -186,6 +186,7 @@ struct InViewTutorialCard: View {
|
||||
@Binding var step: Int
|
||||
let onAdvance: () -> Void
|
||||
let onSkip: () -> Void
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private var current: PageTip { tips[min(step, tips.count - 1)] }
|
||||
private var isLast: Bool { step == tips.count - 1 }
|
||||
@@ -203,17 +204,17 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
Text(current.title)
|
||||
.font(AppFonts.sans(15, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Skip") { onSkip() }
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(.white.opacity(0.45))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text(current.body)
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(.white.opacity(0.80))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineSpacing(2)
|
||||
|
||||
@@ -221,7 +222,7 @@ struct InViewTutorialCard: View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<tips.count, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20))
|
||||
.fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
|
||||
.frame(width: i == step ? 18 : 6, height: 6)
|
||||
.animation(KisaniSpring.micro, value: step)
|
||||
}
|
||||
@@ -243,8 +244,9 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
|
||||
.background(AppColors.surface(cs), in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
.shadow(color: Color.black.opacity(cs == .dark ? 0.28 : 0.08), radius: 18, x: 0, y: 10)
|
||||
.padding(.horizontal, 20)
|
||||
.id(step)
|
||||
.transition(.asymmetric(
|
||||
|
||||
Reference in New Issue
Block a user