Before adding "every N weeks/months" support, found the recurrence window math was independently hand-written in three places (main app's TaskItem.isOccurrence, and two separate widget extensions' countdown progress calculations) with no shared code to keep them in sync. Extracted the math into Shared/RecurrenceRule.swift (pure Foundation, no framework deps) compiled into both the app and widget extension targets via a new project.yml Shared/ source path, and pointed all three call sites at it. Verified every case is identical to prior behavior at interval==1 by compiling and running a standalone test driver directly against the real file with swiftc - 38/38 passing, including Jan-31-monthly and Feb-29-leap-year-yearly edge cases. Groundwork only - no recurrenceInterval field on TaskItem yet, the actual interval feature comes next on top of this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
305 lines
12 KiB
Swift
305 lines
12 KiB
Swift
import WidgetKit
|
||
import SwiftUI
|
||
import AppIntents
|
||
|
||
// MARK: - Shared Entry
|
||
|
||
struct KisaniEntry: TimelineEntry {
|
||
let date: Date
|
||
let task: WidgetTask?
|
||
let allTasks: [WidgetTask]
|
||
let streak: Int
|
||
}
|
||
|
||
// MARK: - Shared Provider
|
||
|
||
struct KisaniProvider: TimelineProvider {
|
||
func placeholder(in context: Context) -> KisaniEntry {
|
||
let sample = WidgetTask(id: UUID(), title: "Mums Birthday", dueDate: Date().addingTimeInterval(86400 * 60),
|
||
isComplete: false, quadrant: "Urgent + Important", category: "birthday")
|
||
return KisaniEntry(date: .now, task: sample, allTasks: [sample], streak: 7)
|
||
}
|
||
|
||
func getSnapshot(in context: Context, completion: @escaping (KisaniEntry) -> Void) {
|
||
let tasks = loadWidgetTasks()
|
||
completion(KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak()))
|
||
}
|
||
|
||
func getTimeline(in context: Context, completion: @escaping (Timeline<KisaniEntry>) -> Void) {
|
||
let tasks = loadWidgetTasks()
|
||
let entry = KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak())
|
||
// Refresh every 30 minutes or when app triggers WidgetCenter.reloadAllTimelines()
|
||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
|
||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||
}
|
||
}
|
||
|
||
// MARK: - Day Progress Widget ("Day done %")
|
||
|
||
enum ProgressDotMode: String, AppEnum {
|
||
case day
|
||
case week
|
||
case month
|
||
case year
|
||
case customEvent
|
||
|
||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
||
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
||
.day: "This Day",
|
||
.week: "This Week",
|
||
.month: "This Month",
|
||
.year: "This Year",
|
||
.customEvent: "Custom Event",
|
||
]
|
||
}
|
||
|
||
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
||
static var title: LocalizedStringResource = "Progress Dots"
|
||
static var description = IntentDescription("Track day, week, month, or a Wenza event.")
|
||
|
||
@Parameter(title: "Mode", default: .month)
|
||
var mode: ProgressDotMode
|
||
|
||
@Parameter(title: "Event")
|
||
var event: EventEntity?
|
||
|
||
static var parameterSummary: some ParameterSummary {
|
||
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
|
||
Summary("Show \(\.$mode) for \(\.$event)")
|
||
} otherwise: {
|
||
Summary("Show \(\.$mode)")
|
||
}
|
||
}
|
||
}
|
||
|
||
struct ProgressDotEntry: TimelineEntry {
|
||
let date: Date
|
||
let title: String
|
||
let progress: Double
|
||
let totalDots: Int
|
||
}
|
||
|
||
struct ProgressDotProvider: AppIntentTimelineProvider {
|
||
func placeholder(in context: Context) -> ProgressDotEntry {
|
||
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
|
||
}
|
||
|
||
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
|
||
entry(for: configuration, at: .now)
|
||
}
|
||
|
||
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
|
||
let interval: TimeInterval = 5 * 60
|
||
let entries = (0..<72).map { step in
|
||
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
|
||
}
|
||
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
|
||
}
|
||
|
||
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
|
||
switch configuration.mode {
|
||
case .day:
|
||
let start = Calendar.current.startOfDay(for: date)
|
||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
|
||
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
|
||
progress: Self.progress(now: date, start: start, end: end),
|
||
totalDots: 100)
|
||
case .week:
|
||
let start = Self.startOfISOWeek(containing: date)
|
||
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
|
||
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
|
||
progress: Self.progress(now: date, start: start, end: end),
|
||
totalDots: 100)
|
||
case .month:
|
||
let interval = Calendar.current.dateInterval(of: .month, for: date)
|
||
let start = interval?.start ?? date
|
||
let end = interval?.end ?? date
|
||
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
||
progress: Self.progress(now: date, start: start, end: end),
|
||
totalDots: 100)
|
||
case .year:
|
||
let interval = Calendar.current.dateInterval(of: .year, for: date)
|
||
let start = interval?.start ?? date
|
||
let end = interval?.end ?? date
|
||
return ProgressDotEntry(date: date, title: Self.yearTitle(for: date),
|
||
progress: Self.progress(now: date, start: start, end: end),
|
||
totalDots: 100)
|
||
case .customEvent:
|
||
guard let event = configuration.event,
|
||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
||
let due = task.dueDate
|
||
else {
|
||
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
|
||
}
|
||
|
||
let start = Self.eventStartDate(for: task, due: due, now: date)
|
||
let computedProgress = Self.progress(now: date, start: start, end: due)
|
||
let storedProgress = task.progress.map(Self.clamp) ?? 0
|
||
return ProgressDotEntry(date: date, title: task.title,
|
||
progress: max(storedProgress, computedProgress),
|
||
totalDots: 100)
|
||
}
|
||
}
|
||
|
||
private static func progress(now: Date, start: Date, end: Date) -> Double {
|
||
let total = end.timeIntervalSince(start)
|
||
guard total > 0 else { return end <= now ? 1 : 0 }
|
||
return clamp(now.timeIntervalSince(start) / total)
|
||
}
|
||
|
||
private static func clamp(_ value: Double) -> Double {
|
||
min(1, max(0, value))
|
||
}
|
||
|
||
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
|
||
if let span = recurrenceSpan(for: task, due: due, now: now) {
|
||
return span.prev
|
||
}
|
||
|
||
let start = [task.createdAt, task.reminderDate]
|
||
.compactMap { $0 }
|
||
.filter { $0 < due }
|
||
.min()
|
||
|
||
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
|
||
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
|
||
}
|
||
|
||
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
|
||
let userDefaults = UserDefaults.kisani
|
||
let key = "kisani.widget.progressDots.anchor.\(key)"
|
||
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
|
||
return saved
|
||
}
|
||
userDefaults.set(now, forKey: key)
|
||
return now
|
||
}
|
||
|
||
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
|
||
let candidate = explicit ?? stored
|
||
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
|
||
|
||
let remaining = target.timeIntervalSince(now)
|
||
let elapsed = now.timeIntervalSince(candidate)
|
||
let total = target.timeIntervalSince(candidate)
|
||
let progress = total > 0 ? elapsed / total : 0
|
||
let looksLikeUpgradeAnchor = remaining > 14 * 86400
|
||
&& elapsed >= 0
|
||
&& elapsed < 3 * 86400
|
||
&& progress < (1.0 / 100.0)
|
||
|
||
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
|
||
}
|
||
|
||
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
|
||
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
|
||
let totalDays = min(365, max(30, remainingDays * 2))
|
||
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
|
||
}
|
||
|
||
// Recurrence-window math itself lives in RecurrenceRule (Shared/) so this
|
||
// widget's countdown can never drift from the main app's occurrence logic
|
||
// or from the other countdown widget's copy of the same calculation.
|
||
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||
let category = task.category.lowercased()
|
||
if category == "birthday" || category == "annual" {
|
||
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
|
||
}
|
||
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
||
return RecurrenceRule.span(label: label, due: due, now: now)
|
||
}
|
||
|
||
private static func startOfISOWeek(containing date: Date) -> Date {
|
||
let cal = Calendar(identifier: .iso8601)
|
||
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
|
||
}
|
||
|
||
private static func dayTitle(for date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEEE d"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private static func weekTitle(for start: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "MMM d"
|
||
return "Week of \(formatter.string(from: start))"
|
||
}
|
||
|
||
private static func monthTitle(for date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "MMMM yyyy"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
private static func yearTitle(for date: Date) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy"
|
||
return formatter.string(from: date)
|
||
}
|
||
}
|
||
|
||
struct DayProgressWidget: Widget {
|
||
let kind = "KisaniDayProgress"
|
||
var body: some WidgetConfiguration {
|
||
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
|
||
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
|
||
}
|
||
.configurationDisplayName("Progress Dots")
|
||
.description("Track day, week, month, or a selected event as a dot grid.")
|
||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||
}
|
||
}
|
||
|
||
// MARK: - Lock Screen Widgets
|
||
|
||
struct LockScreenWidget: Widget {
|
||
let kind = "KisaniLockScreen"
|
||
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
|
||
if let task = entry.task {
|
||
LockScreenRectView(task: task)
|
||
} else {
|
||
LockScreenRectView(task: WidgetTask(id: UUID(), title: "No tasks",
|
||
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
|
||
}
|
||
}
|
||
.configurationDisplayName("Task (Lock Screen)")
|
||
.description("Glass countdown on your lock screen.")
|
||
.supportedFamilies([.accessoryRectangular])
|
||
}
|
||
}
|
||
|
||
struct LockScreenCircularWidget: Widget {
|
||
let kind = "KisaniLockCircle"
|
||
|
||
var body: some WidgetConfiguration {
|
||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
|
||
if let task = entry.task {
|
||
LockScreenCircularView(task: task)
|
||
} else {
|
||
LockScreenCircularView(task: WidgetTask(id: UUID(), title: "–",
|
||
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
|
||
}
|
||
}
|
||
.configurationDisplayName("Days Left (Lock Screen)")
|
||
.description("Circular countdown on your lock screen.")
|
||
.supportedFamilies([.accessoryCircular])
|
||
}
|
||
}
|
||
|
||
// MARK: - Widget Bundle
|
||
|
||
@main
|
||
struct KisaniWidgetBundle: WidgetBundle {
|
||
var body: some Widget {
|
||
EventBarsWidget()
|
||
MyTasksWidget()
|
||
DayProgressWidget()
|
||
LockScreenWidget()
|
||
LockScreenCircularWidget()
|
||
TaskLiveActivity()
|
||
}
|
||
}
|