- New EventBarsWidget ("Event Countdown (Bars)") using BarGrid + EventBarsView,
with a fine "X hr, Y min left" label; registered in the bundle.
- Re-themed every home-screen widget to the shared WidgetTheme (dark slate
background + brand orange): Day Progress, Tasks Done Today, Event Countdown
(dots), My Tasks. Lock-screen widgets keep .thinMaterial (system-tinted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
284 lines
11 KiB
Swift
284 lines
11 KiB
Swift
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
|
|
|
|
@Parameter(title: "Event date", default: Date.now)
|
|
var targetDate: Date
|
|
|
|
@Parameter(title: "Counting from", default: Date.now)
|
|
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<EventEntry> {
|
|
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))
|
|
}
|
|
|
|
/// 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
|
|
|
|
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
|
|
} else {
|
|
name = "No upcoming event"
|
|
target = Date().addingTimeInterval(86400)
|
|
}
|
|
} else if let ev = config.event, let due = ev.dueDate {
|
|
// A specifically picked event supplies its own name + date.
|
|
name = ev.title
|
|
target = due
|
|
} else {
|
|
// Fully custom event.
|
|
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
|
target = config.targetDate
|
|
}
|
|
|
|
// Counting anchor: custom mode honors the picker; auto mode counts from
|
|
// "now" so the grid fills toward the event. Never start after the target.
|
|
let anchor = config.upcomingQueue ? Date() : config.startDate
|
|
let start = min(anchor, 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])
|
|
}
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
}
|