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? 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) } } } // 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 base = entry(for: configuration) // Live progress: pre-render an entry every 30 minutes for the next 12 hours // so the bar and time-left label keep moving between refreshes. let entries: [EventEntry] = (0..<24).map { step in EventEntry(date: Date().addingTimeInterval(Double(step) * 1800), eventName: base.eventName, start: base.start, target: base.target, unitMode: base.unitMode) } let next = Date().addingTimeInterval(24 * 1800) return Timeline(entries: entries, policy: .after(next)) } /// Upcoming events (incomplete tasks with a future due date), nearest first. private func upcomingEvents() -> [EventEntity] { loadWidgetTasks() .filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() } .sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) } .map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate) } } private func entry(for config: EventConfigIntent) -> EventEntry { var name: String var target: Date var anchorKey: String if config.upcomingQueue { // Auto-track the Nth nearest upcoming event. let upcoming = upcomingEvents() let idx = max(0, config.autoSelect.rawValue - 1) if idx < upcoming.count, let due = upcoming[idx].dueDate { name = upcoming[idx].title target = due anchorKey = upcoming[idx].id } else { name = "No upcoming event" target = Date().addingTimeInterval(86400) anchorKey = "none" } } else if let ev = config.event, let due = ev.dueDate { // A specifically picked event supplies its own name + date. name = ev.title target = due anchorKey = ev.id } else { // Fully custom event. No date picked yet → count to tomorrow as a stub. name = config.eventName.isEmpty ? "My Event" : config.eventName target = config.targetDate ?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400) anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))" } // Counting anchor: an explicit "Counting from" wins when it's before the // event; otherwise remember when this event was first tracked so the bar // fills steadily instead of resetting to "now" on every refresh. let start: Date if let s = config.startDate, s < target { start = s } else { start = Self.storedAnchor(key: anchorKey, target: target) } let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey) 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 } } // 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]) } }