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. struct EventConfigIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Event Countdown" static var description = IntentDescription("Count down to any upcoming event with a dot grid.") // Pick one of your existing events (tasks with a due date). When set, it // overrides the manual name/date fields below. @Parameter(title: "Track event") var event: EventEntity? @Parameter(title: "Or event name", default: "My Event") var eventName: String @Parameter(title: "Event date", default: Date.now) var targetDate: Date @Parameter(title: "Counting from", default: Date.now) var startDate: Date } // 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) } /// 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 { EventEntry(date: .now, eventName: "Mum's Birthday", start: Date().addingTimeInterval(-86400 * 320), target: Date().addingTimeInterval(86400 * 60)) } func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry { entry(for: configuration) } func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline { let entry = entry(for: configuration) // Recompute at the next midnight so the grid advances one dot per day. let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400)) return Timeline(entries: [entry], policy: .after(nextMidnight)) } private func entry(for config: EventConfigIntent) -> EventEntry { let name: String let target: Date if let ev = config.event, let due = ev.dueDate { // A picked event supplies its own name + date. name = ev.title target = due } else { name = config.eventName.isEmpty ? "My Event" : config.eventName target = config.targetDate } // Never let the start sit after the target. let start = min(config.startDate, target) let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey) return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode) } } // MARK: - Widget struct EventCountdownWidget: Widget { let kind = "KisaniEventCountdown" var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in EventCountdownView(entry: entry) } .configurationDisplayName("Event Countdown") .description("Track the days until any upcoming event with a dot grid.") .supportedFamilies([.systemMedium, .systemSmall]) } }