Files
KisaniCal/KisaniCalWidgets/EventCountdownWidget.swift
kutesir 0606d9c3aa Bars widget: fix empty progress bar + dead-feeling date pickers (KC-18)
- 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>
2026-06-11 18:16:43 +03:00

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 (01).
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])
}
}