- Persist a per-event "first tracked" anchor in the App Group instead of
resetting the progress start to now on every refresh (auto mode), so
the bar actually fills toward the event. Explicit "Counting from" wins
when it's before the event date.
- Make Event date / Counting from optional ("Choose") — the same-second
defaults produced a zero-length span (always 0%) and looked broken.
- Pre-render timeline entries every 30 min (12h horizon) so the bar and
time-left label move between refreshes instead of only at midnight.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
12 KiB
Swift
301 lines
12 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
|
|
|
|
// 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<EventEntry> {
|
|
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])
|
|
}
|
|
}
|