Add unified task menu, Live Activity, and calendar event actions

Task context menu (new shared TaskMenuItems) consistent across Today,
Matrix, and Calendar: Pin · Date · Move · Priority · Tags · Add to Live
Activity · Delete. Adds TaskViewModel.setPriority.

Live Activity (ActivityKit):
- TaskActivityAttributes shared between app and widget targets
- LiveActivityManager (start/update/end/toggle, iOS 16.1+)
- TaskLiveActivity widget: lock-screen banner + Dynamic Island
- NSSupportsLiveActivities via project.yml; project regenerated

Calendar events (non-subscription, writable only):
- Edit via system EKEventEditViewController (EventEditView wrapper)
- Delete via new CalendarStore.deleteEvent

Also sweeps in prior in-progress edits already present in the working
tree (AuthManager, NotificationManager, TaskItem, task views) and the
updated ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-06 19:29:25 +03:00
parent a92e9d4c09
commit d0d982f4f5
15 changed files with 525 additions and 111 deletions

View File

@@ -1,5 +1,6 @@
import SwiftUI
import EventKit
import EventKitUI
// MARK: - Calendar Store
@MainActor
@@ -115,6 +116,20 @@ final class CalendarStore: ObservableObject {
return authStatus == .authorized
}
/// Exposed so the edit sheet can hand it to EKEventEditViewController.
var eventStore: EKEventStore { ekStore }
/// Delete a real (writable) calendar event, then refresh the month cache.
func deleteEvent(_ event: EKEvent) {
guard event.calendar?.allowsContentModifications == true else { return }
do {
try ekStore.remove(event, span: .thisEvent)
fetchMonth(containing: event.startDate ?? Date())
} catch {
print("Delete event failed: \(error.localizedDescription)")
}
}
/// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings.
var canRequest: Bool { authStatus == .notDetermined }
var isDenied: Bool { authStatus == .denied || authStatus == .restricted }
@@ -227,6 +242,7 @@ struct CalendarView: View {
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
@State private var editingEvent: EditableEvent? = nil
private let cal = Calendar.current
private let weekdays = ["S","M","T","W","T","F","S"]
@@ -334,6 +350,12 @@ struct CalendarView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(item: $editingEvent) { wrapper in
EventEditView(event: wrapper.event, store: calStore.eventStore) {
calStore.fetchMonth(containing: selectedDate)
}
.ignoresSafeArea()
}
.onAppear {
calStore.refreshStatus()
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
@@ -517,7 +539,15 @@ struct CalendarView: View {
},
onToggle: { task in
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
}
},
onPin: { taskVM.pin($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) },
onEditEvent: { editingEvent = EditableEvent(event: $0) },
onDeleteEvent: { calStore.deleteEvent($0) }
)
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20)
}
@@ -1442,6 +1472,7 @@ private struct DayTLEntry: Identifiable {
var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue
var taskRef: TaskItem?
var workoutRef: WorkoutProgram?
var eventRef: EKEvent?
}
struct DayTimelineView: View {
@@ -1458,6 +1489,13 @@ struct DayTimelineView: View {
var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
// Calendar events (non-subscription only): edit/delete in the real calendar.
var onEditEvent: ((EKEvent) -> Void)? = nil
var onDeleteEvent: ((EKEvent) -> Void)? = nil
private let cal = Calendar.current
private static let overdueFmt: DateFormatter = {
@@ -1485,7 +1523,8 @@ struct DayTimelineView: View {
timeLabel: "All Day",
title: ev.title ?? "Event",
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0,
eventRef: ev
))
}
@@ -1533,7 +1572,8 @@ struct DayTimelineView: View {
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
title: ev.title ?? "Event", subtitle: endLabel,
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0,
eventRef: ev
))
}
@@ -1587,6 +1627,11 @@ struct DayTimelineView: View {
)
if let t = entry.taskRef {
row.contextMenu { taskContextMenu(t) }
} else if let ev = entry.eventRef,
onEditEvent != nil || onDeleteEvent != nil,
ev.calendar?.type != .subscription,
ev.calendar?.allowsContentModifications == true {
row.contextMenu { eventContextMenu(ev) }
} else {
row
}
@@ -1597,26 +1642,30 @@ struct DayTimelineView: View {
@ViewBuilder
private func taskContextMenu(_ task: TaskItem) -> some View {
Button { onToggle?(task) } label: {
Label(task.isComplete ? "Mark Incomplete" : "Mark Complete", systemImage: "checkmark.circle")
TaskMenuItems(
task: task,
onPin: { onPin?($0) },
onSetDate: { onSetDate?($0, $1) },
onMove: { onMove?($0, $1) },
onSetPriority: { onSetPriority?($0, $1) },
onSetCategory: { onSetCategory?($0, $1) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { onDelete?($0) }
)
}
// Edit / Delete for a real (non-subscription) calendar event.
@ViewBuilder
private func eventContextMenu(_ event: EKEvent) -> some View {
if let onEditEvent {
Button { onEditEvent(event) } label: { Label("Edit", systemImage: "pencil") }
}
if let onPostpone {
Button { onPostpone(task) } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
}
if let onPin {
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
}
if let onEdit {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
}
ShareLink(item: task.title) { Label("Share", systemImage: "square.and.arrow.up") }
if let onDelete {
if let onDeleteEvent {
Divider()
Button(role: .destructive) { onDelete(task) } label: { Label("Delete", systemImage: "trash") }
Button(role: .destructive) { onDeleteEvent(event) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
@@ -1816,3 +1865,40 @@ struct WorkoutSectionBar: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Event Edit Sheet
/// Identifiable wrapper so an EKEvent can drive `.sheet(item:)`.
struct EditableEvent: Identifiable {
let id = UUID()
let event: EKEvent
}
/// Wraps the system event editor (EKEventEditViewController).
struct EventEditView: UIViewControllerRepresentable {
let event: EKEvent
let store: EKEventStore
var onDone: () -> Void
func makeUIViewController(context: Context) -> EKEventEditViewController {
let vc = EKEventEditViewController()
vc.eventStore = store
vc.event = event
vc.editViewDelegate = context.coordinator
return vc
}
func updateUIViewController(_ vc: EKEventEditViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onDone: onDone) }
final class Coordinator: NSObject, EKEventEditViewDelegate {
let onDone: () -> Void
init(onDone: @escaping () -> Void) { self.onDone = onDone }
func eventEditViewController(_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction) {
controller.dismiss(animated: true)
onDone()
}
}
}

View File

@@ -66,6 +66,7 @@ struct MatrixView: View {
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onDelete: { taskVM.delete($0) },
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
)
@@ -83,6 +84,7 @@ struct MatrixView: View {
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onDelete: { taskVM.delete($0) },
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
)
@@ -104,6 +106,7 @@ struct MatrixView: View {
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onDelete: { taskVM.delete($0) },
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
)
@@ -121,6 +124,7 @@ struct MatrixView: View {
onSetDate: { taskVM.setDate($0, date: $1) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onDelete: { taskVM.delete($0) },
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
)
@@ -183,6 +187,7 @@ struct QuadrantCard: View {
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
var onTapHeader: (() -> Void)? = nil
@@ -278,68 +283,17 @@ struct QuadrantCard: View {
.padding(.leading, 2)
.draggable(task.id.uuidString)
.contextMenu {
Button {
withAnimation(KisaniSpring.snappy) { onPin?(task) }
} label: {
Label(task.isPinned ? "Unpin" : "Pin",
systemImage: task.isPinned ? "pin.slash" : "pin")
}
Menu {
Button {
onSetDate?(task, Calendar.current.startOfDay(for: Date()))
} label: { Label("Today", systemImage: "sun.max") }
Button {
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
onSetDate?(task, Calendar.current.startOfDay(for: tomorrow))
} label: { Label("Tomorrow", systemImage: "sunrise") }
Button {
let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date())!
onSetDate?(task, Calendar.current.startOfDay(for: nextWeek))
} label: { Label("Next Week", systemImage: "calendar") }
Divider()
Button {
onSetDate?(task, nil)
} label: { Label("Remove Date", systemImage: "xmark.circle") }
} label: {
Label("Date", systemImage: "calendar.badge.clock")
}
Menu {
ForEach(Quadrant.allCases.filter { $0 != quadrant }) { q in
Button {
withAnimation(KisaniSpring.snappy) { onMove?(task, q) }
} label: {
Text(q.rawValue)
}
}
} label: {
Label("Move", systemImage: "arrow.right.square")
}
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 { } label: {
Label("Add to Live Activity", systemImage: "pin.circle")
}
Divider()
Button(role: .destructive) {
withAnimation(KisaniSpring.snappy) { onDelete?(task) }
} label: {
Label("Delete", systemImage: "trash")
}
TaskMenuItems(
task: task,
currentQuadrant: quadrant,
onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } },
onSetDate: { onSetDate?($0, $1) },
onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } },
onSetPriority: { onSetPriority?($0, $1) },
onSetCategory: { onSetCategory?($0, $1) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
)
}
}
}
@@ -484,6 +438,20 @@ struct QuadrantDetailView: View {
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
)
}
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
}
}
@@ -495,6 +463,20 @@ struct QuadrantDetailView: View {
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($0) }
)
}
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
}
}
@@ -507,6 +489,20 @@ struct QuadrantDetailView: View {
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) },
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
onSetPriority: { taskVM.setPriority($0, priority: $1) },
onSetCategory: { taskVM.setCategory($0, category: $1) },
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 {
@@ -682,18 +678,20 @@ struct TaskEditSheet: View {
@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)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
_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)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
_constantReminder = State(initialValue: task.constantReminder)
}
private var dateSummary: String {
@@ -839,7 +837,8 @@ struct TaskEditSheet: View {
recurrenceLabel: $recurrenceLabel,
endDate: $endDate,
isAllDay: $isAllDay,
reminderDate: $reminderDate
reminderDate: $reminderDate,
constantReminder: $constantReminder
)
}
}
@@ -849,7 +848,8 @@ struct TaskEditSheet: View {
guard !t.isEmpty else { return }
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate)
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
constantReminder: constantReminder)
dismiss()
}
}

View File

@@ -0,0 +1,80 @@
import SwiftUI
/// 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 onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: (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, cal.startOfDay(for: Date())) } label: {
Label("Today", systemImage: "sun.max")
}
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()
Button { onSetDate(task, nil) } label: {
Label("Remove Date", systemImage: "xmark.circle")
}
} label: { Label("Date", systemImage: "calendar.badge.clock") }
Menu {
ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in
Button { onMove(task, q) } label: { Text(q.rawValue) }
}
} label: { Label("Move", systemImage: "arrow.right.square") }
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("Add to Live Activity", systemImage: "pin.circle")
}
if let onEdit {
Button { onEdit(task) } label: { Label("Edit", systemImage: "pencil") }
}
Divider()
Button(role: .destructive) { onDelete(task) } label: {
Label("Delete", systemImage: "trash")
}
}
}

View File

@@ -100,7 +100,11 @@ struct TodayView: View {
onPin: { taskVM.pin($0) },
onPostpone: { taskVM.postpone($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)