Files
KisaniCal/KisaniCal/Views/TaskContextMenu.swift
kutesir a5f7bce09f
Some checks failed
CI / build-and-test (push) Has been cancelled
tasks: add 'Complete & Stop Series' for recurring tasks (KC-59)
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>
2026-07-09 03:32:37 +03:00

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