Files
KisaniCal/KisaniCalWidgets/KisaniCalWidgets.swift
kutesir b7af2dd76b tasks: add "every N weeks/months/days/years" recurrence (KC-70)
Recurrence previously only supported 5 fixed rules with no interval.
Adds TaskItem.recurrenceInterval (optional, nil = 1, so every existing
task is unaffected), a stepper in the shared create/edit sheet, quick-add
NLP for phrases like "every 2 weeks"/"biweekly"/"fortnightly", and
threads the interval through both countdown widgets so their progress
bars size correctly for biweekly/etc tasks too.

Built on KC-69's consolidated RecurrenceRule engine. Verified two things
by actually compiling and running standalone swiftc drivers rather than
just reading: the core interval math (44/44, unchanged from KC-69) and
the new quick-add regex patterns against realistic phrases including
"look into tru housing options every two weeks" (11/11 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

305 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, interval: task.recurrenceInterval ?? 1, 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()
}
}