Files
KisaniCal/KisaniCal/Views/TaskContextMenu.swift
kutesir 48e2f4fc10 Widgets/notifications: tracked-event auto-advance, lock-screen dots, Snooze labels, period milestones
- Bars widget: tracked ("Track Countdown") events now also advance — a completed
  or past (non-recurring) tracked event falls through to the next-nearest, and the
  refresh boundary covers the tracked event's expiry. Recurring tracked events
  still roll forward. Completes the KC-26 auto-advance for all paths.
- Progress Dots on the lock screen (accessoryRectangular): show 28 larger dots
  instead of 100 tiny ones, render with .primary for the vibrant tint, and use
  AccessoryWidgetBackground so it's legible (Home Screen keeps the 100-dot grid).
- Notifications: snooze action labels now read "Snooze 15 min/30 min/1 hour/
  2 hours" (lock screen) and "Snooze 15 Minutes…" (in-app Postpone menu).
- New period milestone notifications: recurring "One more week/month/year passed"
  at end of week (Sun 20:00), month (1st 09:00), and year (Jan 1 09:00); gated by
  a kisani.periodMilestones flag, scheduled via the existing reschedule path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 02:12:52 +03:00

174 lines
6.8 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 }
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") }
}
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
}
}