import WidgetKit import SwiftUI import AppIntents // MARK: - Configuration Intent // // Each widget instance stores its own event. The user sets a name, the day the // countdown starts ("Counting from" — defaults to the day they add the widget, // which acts as the creation anchor) and the event date. Because these values are // persisted per-instance by WidgetKit, the widget is fully self-contained and does // not depend on App Group data sharing. // Which upcoming event to auto-track when the queue is on. enum AutoSelectRank: Int, AppEnum { case first = 1, second, third, fourth, fifth static var typeDisplayRepresentation: TypeDisplayRepresentation = "Auto Select" static var caseDisplayRepresentations: [AutoSelectRank: DisplayRepresentation] = [ .first: "Nearest to finish", .second: "2nd nearest to finish", .third: "3rd nearest to finish", .fourth: "4th nearest to finish", .fifth: "5th nearest to finish", ] } struct EventConfigIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Event Countdown" static var description = IntentDescription("Count down to an event with a dot grid.") // When on, the widget auto-tracks one of your upcoming events. When off, you // track a specific event you pick, or a fully custom name + date. @Parameter(title: "Upcoming queue", default: true) var upcomingQueue: Bool @Parameter(title: "Auto select", default: .first) var autoSelect: AutoSelectRank @Parameter(title: "Track event") var event: EventEntity? @Parameter(title: "Or event name", default: "My Event") var eventName: String // Optional so the rows read "Choose" until the user actually picks a date — // a same-second default for both made the bar span zero and looked broken. @Parameter(title: "Event date") var targetDate: Date? @Parameter(title: "Counting from") var startDate: Date? // Show only the relevant fields depending on the toggle — like a clean, // single-purpose config screen instead of every field at once. static var parameterSummary: some ParameterSummary { When(\.$upcomingQueue, .equalTo, true) { Summary("Track upcoming event") { \.$upcomingQueue \.$autoSelect } } otherwise: { Summary("Track a custom event") { \.$upcomingQueue \.$event \.$eventName \.$targetDate \.$startDate } } } } // MARK: - Event picker (reads your tasks from the App Group) struct EventEntity: AppEntity { let id: String // task UUID string let title: String let dueDate: Date? var isRecurring: Bool = false var recurrenceLabel: String? = nil var recurrenceInterval: Int? = nil var createdAt: Date? = nil static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event" static var defaultQuery = EventQuery() var displayRepresentation: DisplayRepresentation { if let d = dueDate { let f = DateFormatter(); f.dateStyle = .medium return DisplayRepresentation(title: "\(title)", subtitle: "\(f.string(from: d))") } return DisplayRepresentation(title: "\(title)") } } struct EventQuery: EntityQuery { func entities(for identifiers: [String]) async throws -> [EventEntity] { allEvents().filter { identifiers.contains($0.id) } } func suggestedEntities() async throws -> [EventEntity] { allEvents() } private func allEvents() -> [EventEntity] { loadWidgetTasks() .filter { !$0.isComplete && $0.dueDate != nil } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, recurrenceInterval: $0.recurrenceInterval, createdAt: $0.createdAt) } } } // MARK: - Entry // Tapping the countdown label cycles this unit (shared by all event widgets). let countdownUnitKey = "kisani.widget.countdownUnit" // 0 = days, 1 = weeks, 2 = time struct CycleCountdownUnitIntent: AppIntent { static var title: LocalizedStringResource = "Change Countdown Unit" func perform() async throws -> some IntentResult { let cur = UserDefaults.kisani.integer(forKey: countdownUnitKey) UserDefaults.kisani.set((cur + 1) % 3, forKey: countdownUnitKey) return .result() } } struct EventEntry: TimelineEntry { let date: Date let eventName: String let start: Date let target: Date var unitMode: Int = 0 // 0 days, 1 weeks, 2 time /// Fraction of the countdown window that has elapsed (0…1). var progress: Double { let span = target.timeIntervalSince(start) guard span > 0 else { return target <= date ? 1 : 0 } return min(1, max(0, date.timeIntervalSince(start) / span)) } /// Whole days from start to target (the number of dots in the grid). var totalDays: Int { let cal = Calendar.current let d = cal.dateComponents([.day], from: cal.startOfDay(for: start), to: cal.startOfDay(for: target)).day ?? 0 return max(1, d) } /// Days remaining until the event (never negative). var daysLeft: Int { let cal = Calendar.current let d = cal.dateComponents([.day], from: cal.startOfDay(for: date), to: cal.startOfDay(for: target)).day ?? 0 return max(0, d) } /// Granular countdown for the bars widget: days → hr → min as it nears. var fineTimeLeft: String { if target <= date { return "Today" } let secs = target.timeIntervalSince(date) let days = Int(secs / 86400) let hrs = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600) let mins = Int(secs.truncatingRemainder(dividingBy: 3600) / 60) if days >= 1 { return hrs > 0 ? "\(days)d, \(hrs) hr left" : "\(days) day\(days == 1 ? "" : "s") left" } if hrs >= 1 { return "\(hrs) hr, \(mins) min left" } return "\(mins) min left" } /// Countdown from the current date. Tapping the label cycles the unit: /// days → weeks → time remaining. var timeLeftLabel: String { if target <= date { return "Today" } let cal = Calendar.current switch unitMode { case 1: // weeks let days = cal.dateComponents([.day], from: cal.startOfDay(for: date), to: cal.startOfDay(for: target)).day ?? 0 if days < 7 { return "< 1 week left" } let weeks = days / 7 return "\(weeks) week\(weeks == 1 ? "" : "s") left" case 2: // time remaining (detailed) let c = cal.dateComponents([.day, .hour], from: date, to: target) let days = c.day ?? 0, hours = c.hour ?? 0 if days == 0 { return hours <= 0 ? "Due now" : "\(hours) hr left" } return hours > 0 ? "\(days)d, \(hours) hr left" : "\(days) day\(days == 1 ? "" : "s") left" default: // days let days = cal.dateComponents([.day], from: cal.startOfDay(for: date), to: cal.startOfDay(for: target)).day ?? 0 return "\(days) day\(days == 1 ? "" : "s") left" } } } // MARK: - Provider struct EventProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> EventEntry { // ~90-day window, ~60% elapsed → a clear, legible dot grid in the gallery. EventEntry(date: .now, eventName: "IELTS", start: Date().addingTimeInterval(-86400 * 54), target: Date().addingTimeInterval(86400 * 36)) } func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry { entry(for: configuration) } func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline { let now = Date() let step: TimeInterval = 1800 // 30 minutes let count = 24 // ~12-hour pre-render window // Re-resolve the selection at EACH step's own time, so the moment the nearest // event expires the next entry rolls to the next-nearest automatically. let entries: [EventEntry] = (0.. now, expiry < windowEnd { next = expiry.addingTimeInterval(1) } return Timeline(entries: entries, policy: .after(next)) } /// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*), /// nearest first. Passing `now` lets future timeline steps drop just-expired events. private func upcomingEvents(asOf now: Date) -> [EventEntity] { loadWidgetTasks() .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate, isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel, recurrenceInterval: $0.recurrenceInterval, createdAt: $0.createdAt) } } /// The tracked task ("Track Countdown"), but only while it's still live: not /// completed, and either recurring or with a deadline still ahead of `now`. /// Returns nil once it's done/expired so the widget advances to the next event. private func trackedTask(asOf now: Date) -> WidgetTask? { guard let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"), let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }), let due = task.dueDate, !task.isComplete, task.isRecurring || due > now else { return nil } return task } /// Due date the currently-shown event expires at, so the timeline can refresh /// the moment it's done. Covers both the tracked event and the auto-select queue. private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? { if let task = trackedTask(asOf: now) { return task.isRecurring ? nil : task.dueDate } guard config.upcomingQueue else { return nil } let upcoming = upcomingEvents(asOf: now) let idx = max(0, config.autoSelect.rawValue - 1) guard idx < upcoming.count else { return nil } return upcoming[idx].dueDate } /// Resolve the progress window (start → target) for any event. Recurring events /// (Mode B) span the current occurrence period: previous → next occurrence — /// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count /// from an explicit start, the task's real creation date, the tracking date, /// or first-seen, up to the due date — in that priority order. private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil, anchorKey: String, explicitStart: Date?, taskCreatedAt: Date? = nil) -> (start: Date, target: Date) { if isRecurring, let label, let span = Self.recurrenceSpan(label: label, interval: interval ?? 1, due: due) { return (span.prev, span.next) } if let s = explicitStart, s < due { return (s, due) } if let created = taskCreatedAt, created < due { return (created, due) } return (Self.storedAnchor(key: anchorKey, target: due), due) } private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry { let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey) // An event tracked from the app ("Track Countdown") bypasses the widget // configuration entirely. Once it's completed or its deadline has passed // (and it isn't recurring), stop pinning it and fall through to the // next-nearest event below — so the widget always advances. if let task = trackedTask(asOf: now) { let trackedId = task.id.uuidString let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring, label: task.recurrenceLabel, interval: task.recurrenceInterval, anchorKey: trackedId, explicitStart: since, taskCreatedAt: task.createdAt) return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode) } // ── Configured (no tracked event) ── if config.upcomingQueue { let upcoming = upcomingEvents(asOf: now) let idx = max(0, config.autoSelect.rawValue - 1) guard idx < upcoming.count, let due = upcoming[idx].dueDate else { return EventEntry(date: now, eventName: "No upcoming event", start: now, target: now.addingTimeInterval(86400), unitMode: mode) } let ev = upcoming[idx] let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, interval: ev.recurrenceInterval, anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } if let ev = config.event, let due = ev.dueDate { let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel, interval: ev.recurrenceInterval, anchorKey: ev.id, explicitStart: config.startDate, taskCreatedAt: ev.createdAt) return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode) } // Fully custom event (no task behind it). let name = config.eventName.isEmpty ? "My Event" : config.eventName let target = config.targetDate ?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400) let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))" let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil) ?? Self.storedAnchor(key: anchorKey, target: target) return EventEntry(date: now, eventName: name, start: start, target: target, unitMode: mode) } /// First-seen date for a tracked event, persisted in the App Group so progress /// is stable across refreshes (and resets naturally when the event changes). private static func storedAnchor(key: String, target: Date) -> Date { let ud = UserDefaults.kisani let k = "kisani.widget.anchor.\(key)" if let saved = ud.object(forKey: k) as? Date, saved < target { return saved } let now = Date() ud.set(now, forKey: k) return now } /// Mode B window for a recurring event: the occurrence span containing "now" /// (previous occurrence → next occurrence), derived from the rule label. /// The actual math lives in RecurrenceRule (Shared/) — shared with the main /// app's occurrence matching and the other countdown widget's copy of this /// same calculation, so none of the three can independently drift. private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? { RecurrenceRule.span(label: label, interval: interval, due: due, now: Date()) } } // MARK: - Event Countdown (Bars) struct EventBarsWidget: Widget { let kind = "KisaniEventBars" var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in EventBarsView(entry: entry) } .configurationDisplayName("Event Countdown (Bars)") .description("Count down to an event with a bar meter.") .supportedFamilies([.systemMedium]) } }