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) -> 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 customEvent static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode" static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [ .day: "This Day", .week: "This Week", .month: "This Month", .customEvent: "Custom Event", ] } struct ProgressDotConfigIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Progress Dots" static var description = IntentDescription("Track day, week, month, or a KisaniCal 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 { 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 .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 } 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 recurrenceSpan(label: "Yearly", due: due, now: now) } guard task.isRecurring, let label = task.recurrenceLabel else { return nil } return recurrenceSpan(label: label, due: due, now: now) } private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? { let cal = Calendar.current let comp: Calendar.Component let step: Int switch label { case "Daily", "Every Weekday": comp = .day; step = 1 case "Weekly": comp = .day; step = 7 case "Monthly": comp = .month; step = 1 case "Yearly": comp = .year; step = 1 default: return nil } var next = due var guardRail = 0 if next > now { while let prev = cal.date(byAdding: comp, value: -step, to: next), prev > now, guardRail < 4000 { next = prev guardRail += 1 } } else { while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next), guardRail < 4000 { next = candidate guardRail += 1 } } guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil } return (prev, next) } 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) } } 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() } }