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:
kutesir
2026-06-15 12:40:39 +03:00
parent 2b48509a35
commit 3592d2a9d8
11 changed files with 568 additions and 197 deletions

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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(