Some checks failed
CI / build-and-test (push) Has been cancelled
Recurring tasks only had Delete (erases the task + its entire completion history) as a way to stop a series -- no in-between option to wind one down while keeping its record. TaskViewModel.completeAndStopSeries(_🔛) marks the occurrence complete and sets recurrenceEnd to that day -- a field that already existed in the model and was already respected by isOccurrence, just had no UI. Task, title, and full completedOccurrences history stay intact; only future occurrences stop. New menu item in the shared TaskMenuItems (Today/Matrix/Calendar), shown only for recurring tasks. Wired through all 8 call sites, several via a new optional closure threaded through intermediate wrapper views (QuadrantCard, DayTimelineView, OverdueCard, UpcomingSection). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
7.3 KiB
Swift
183 lines
7.3 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
/// Tracks one event for the Event Countdown (Bars) widget via App Group storage.
|
|
/// The widget reads these keys directly — no manual widget configuration needed.
|
|
enum CountdownTracker {
|
|
static let idKey = "kisani.widget.trackedEvent"
|
|
static let sinceKey = "kisani.widget.trackedSince"
|
|
|
|
static func isTracked(_ task: TaskItem) -> Bool {
|
|
UserDefaults.kisani.string(forKey: idKey) == task.id.uuidString
|
|
}
|
|
|
|
static func toggle(_ task: TaskItem) {
|
|
if isTracked(task) {
|
|
UserDefaults.kisani.removeObject(forKey: idKey)
|
|
UserDefaults.kisani.removeObject(forKey: sinceKey)
|
|
} else {
|
|
UserDefaults.kisani.set(task.id.uuidString, forKey: idKey)
|
|
UserDefaults.kisani.set(Date(), forKey: sinceKey) // Mode A anchor
|
|
}
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|
|
|
|
/// The unified long-press menu for a task, shared across Today, Matrix, and Calendar.
|
|
/// Pass only the closures a given screen needs; the rest default to no-ops.
|
|
struct TaskMenuItems: View {
|
|
let task: TaskItem
|
|
/// The quadrant the row currently lives in (so "Move" hides the current one).
|
|
var currentQuadrant: Quadrant? = nil
|
|
|
|
var onPin: (TaskItem) -> Void = { _ in }
|
|
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
|
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
|
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
|
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
|
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
|
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
|
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
|
|
var onEdit: ((TaskItem) -> Void)? = nil
|
|
var onDelete: (TaskItem) -> Void = { _ in }
|
|
/// Complete today's occurrence AND stop the series — keeps the task and its
|
|
/// full completion history (unlike Delete, which erases both).
|
|
var onCompleteAndStopSeries: (TaskItem) -> Void = { _ in }
|
|
|
|
private var cal: Calendar { .current }
|
|
|
|
var body: some View {
|
|
Button { onPin(task) } label: {
|
|
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
|
|
}
|
|
|
|
Menu {
|
|
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")
|
|
}
|
|
Divider()
|
|
if let onEdit {
|
|
Button { onEdit(task) } label: {
|
|
Label("Pick Date", systemImage: "calendar.badge.plus")
|
|
}
|
|
}
|
|
Button { onSetDate(task, nil) } label: {
|
|
Label("Clear", systemImage: "xmark.square")
|
|
}
|
|
} label: { Label("Date", systemImage: "calendar.badge.clock") }
|
|
|
|
Menu {
|
|
Button { onPostponeMinutes(task, 15) } label: {
|
|
Label("Snooze 15 Minutes", systemImage: "clock")
|
|
}
|
|
Button { onPostponeMinutes(task, 30) } label: {
|
|
Label("Snooze 30 Minutes", systemImage: "clock")
|
|
}
|
|
Button { onPostponeMinutes(task, 60) } label: {
|
|
Label("Snooze 1 Hour", systemImage: "clock")
|
|
}
|
|
Button { onPostponeMinutes(task, 120) } label: {
|
|
Label("Snooze 2 Hours", systemImage: "clock")
|
|
}
|
|
Divider()
|
|
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
|
Label("Tomorrow", systemImage: "sun.max")
|
|
}
|
|
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
|
|
|
|
Menu {
|
|
// Only the quadrants the task is NOT currently in — you can't move it to where it sits.
|
|
ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in
|
|
Button { onMove(task, q) } label: {
|
|
Label(q.matrixLabel, systemImage: "arrow.right")
|
|
}
|
|
}
|
|
} label: { Label("Move", systemImage: "arrow.right.square") }
|
|
|
|
if task.urgencyOverride != nil, let onResetMatrixUrgency {
|
|
Button { onResetMatrixUrgency(task) } label: {
|
|
Label("Reset Matrix Urgency", systemImage: "arrow.counterclockwise")
|
|
}
|
|
}
|
|
|
|
Menu {
|
|
ForEach(Priority.allCases) { p in
|
|
Button { onSetPriority(task, p) } label: {
|
|
Label(p.label, systemImage: task.priority == p ? "checkmark" : "flag")
|
|
}
|
|
}
|
|
} label: { Label("Priority", systemImage: "flag") }
|
|
|
|
Menu {
|
|
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in
|
|
Button { onSetCategory(task, cat) } label: {
|
|
Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag")
|
|
}
|
|
}
|
|
} label: { Label("Tags", systemImage: "tag") }
|
|
|
|
Button { onLiveActivity(task) } label: {
|
|
Label(liveActivityTitle, systemImage: liveActivityIcon)
|
|
}
|
|
|
|
Button { CountdownTracker.toggle(task) } label: {
|
|
Label(CountdownTracker.isTracked(task) ? "Stop Tracking Countdown" : "Track Countdown",
|
|
systemImage: "timer")
|
|
}
|
|
|
|
if let onEdit {
|
|
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
|
|
}
|
|
|
|
if task.isRecurring {
|
|
Button { onCompleteAndStopSeries(task) } label: {
|
|
Label("Complete & Stop Series", systemImage: "checkmark.circle.badge.xmark")
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button(role: .destructive) { onDelete(task) } label: {
|
|
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
|
|
}
|
|
}
|