- 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>
174 lines
6.8 KiB
Swift
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
|
|
}
|
|
}
|