Files
KisaniCal/KisaniCalWidgets/KisaniCalWidgets.swift
kutesir 7bfa16aec4
Some checks failed
CI / build-and-test (push) Has been cancelled
widgets: add 'This Year' mode to Progress Dots (KC-53)
The Progress Dots lock-screen widget already supports Day/Week/Month via its
configuration picker — no code needed for those. Year was the only mode
missing. Added ProgressDotMode.year, a matching provider branch
(Calendar.dateInterval(of: .year)), and yearTitle(for:), mirroring the
existing day/week/month pattern exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:59:23 +03:00

334 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Shared Entry
struct KisaniEntry: TimelineEntry {
let date: Date
let task: WidgetTask?
let allTasks: [WidgetTask]
let streak: Int
}
// MARK: - Shared Provider
struct KisaniProvider: TimelineProvider {
func placeholder(in context: Context) -> KisaniEntry {
let sample = WidgetTask(id: UUID(), title: "Mums Birthday", dueDate: Date().addingTimeInterval(86400 * 60),
isComplete: false, quadrant: "Urgent + Important", category: "birthday")
return KisaniEntry(date: .now, task: sample, allTasks: [sample], streak: 7)
}
func getSnapshot(in context: Context, completion: @escaping (KisaniEntry) -> Void) {
let tasks = loadWidgetTasks()
completion(KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<KisaniEntry>) -> Void) {
let tasks = loadWidgetTasks()
let entry = KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak())
// Refresh every 30 minutes or when app triggers WidgetCenter.reloadAllTimelines()
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
// MARK: - Day Progress Widget ("Day done %")
enum ProgressDotMode: String, AppEnum {
case day
case week
case month
case year
case customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
.day: "This Day",
.week: "This Week",
.month: "This Month",
.year: "This Year",
.customEvent: "Custom Event",
]
}
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Progress Dots"
static var description = IntentDescription("Track day, week, month, or a Wenza event.")
@Parameter(title: "Mode", default: .month)
var mode: ProgressDotMode
@Parameter(title: "Event")
var event: EventEntity?
static var parameterSummary: some ParameterSummary {
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
Summary("Show \(\.$mode) for \(\.$event)")
} otherwise: {
Summary("Show \(\.$mode)")
}
}
}
struct ProgressDotEntry: TimelineEntry {
let date: Date
let title: String
let progress: Double
let totalDots: Int
}
struct ProgressDotProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> ProgressDotEntry {
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
}
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
entry(for: configuration, at: .now)
}
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
let interval: TimeInterval = 5 * 60
let entries = (0..<72).map { step in
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
}
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
}
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
switch configuration.mode {
case .day:
let start = Calendar.current.startOfDay(for: date)
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .week:
let start = Self.startOfISOWeek(containing: date)
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .month:
let interval = Calendar.current.dateInterval(of: .month, for: date)
let start = interval?.start ?? date
let end = interval?.end ?? date
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .year:
let interval = Calendar.current.dateInterval(of: .year, for: date)
let start = interval?.start ?? date
let end = interval?.end ?? date
return ProgressDotEntry(date: date, title: Self.yearTitle(for: date),
progress: Self.progress(now: date, start: start, end: end),
totalDots: 100)
case .customEvent:
guard let event = configuration.event,
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
let due = task.dueDate
else {
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
}
let start = Self.eventStartDate(for: task, due: due, now: date)
let computedProgress = Self.progress(now: date, start: start, end: due)
let storedProgress = task.progress.map(Self.clamp) ?? 0
return ProgressDotEntry(date: date, title: task.title,
progress: max(storedProgress, computedProgress),
totalDots: 100)
}
}
private static func progress(now: Date, start: Date, end: Date) -> Double {
let total = end.timeIntervalSince(start)
guard total > 0 else { return end <= now ? 1 : 0 }
return clamp(now.timeIntervalSince(start) / total)
}
private static func clamp(_ value: Double) -> Double {
min(1, max(0, value))
}
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
if let span = recurrenceSpan(for: task, due: due, now: now) {
return span.prev
}
let start = [task.createdAt, task.reminderDate]
.compactMap { $0 }
.filter { $0 < due }
.min()
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
}
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
let userDefaults = UserDefaults.kisani
let key = "kisani.widget.progressDots.anchor.\(key)"
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
return saved
}
userDefaults.set(now, forKey: key)
return now
}
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
let candidate = explicit ?? stored
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
let remaining = target.timeIntervalSince(now)
let elapsed = now.timeIntervalSince(candidate)
let total = target.timeIntervalSince(candidate)
let progress = total > 0 ? elapsed / total : 0
let looksLikeUpgradeAnchor = remaining > 14 * 86400
&& elapsed >= 0
&& elapsed < 3 * 86400
&& progress < (1.0 / 100.0)
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
}
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
let totalDays = min(365, max(30, remainingDays * 2))
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
}
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
let category = task.category.lowercased()
if category == "birthday" || category == "annual" {
return recurrenceSpan(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now)
}
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
private static func startOfISOWeek(containing date: Date) -> Date {
let cal = Calendar(identifier: .iso8601)
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
}
private static func dayTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE d"
return formatter.string(from: date)
}
private static func weekTitle(for start: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "Week of \(formatter.string(from: start))"
}
private static func monthTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter.string(from: date)
}
private static func yearTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: date)
}
}
struct DayProgressWidget: Widget {
let kind = "KisaniDayProgress"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
}
.configurationDisplayName("Progress Dots")
.description("Track day, week, month, or a selected event as a dot grid.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
}
}
// MARK: - Lock Screen Widgets
struct LockScreenWidget: Widget {
let kind = "KisaniLockScreen"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
if let task = entry.task {
LockScreenRectView(task: task)
} else {
LockScreenRectView(task: WidgetTask(id: UUID(), title: "No tasks",
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
}
}
.configurationDisplayName("Task (Lock Screen)")
.description("Glass countdown on your lock screen.")
.supportedFamilies([.accessoryRectangular])
}
}
struct LockScreenCircularWidget: Widget {
let kind = "KisaniLockCircle"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
if let task = entry.task {
LockScreenCircularView(task: task)
} else {
LockScreenCircularView(task: WidgetTask(id: UUID(), title: "",
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
}
}
.configurationDisplayName("Days Left (Lock Screen)")
.description("Circular countdown on your lock screen.")
.supportedFamilies([.accessoryCircular])
}
}
// MARK: - Widget Bundle
@main
struct KisaniWidgetBundle: WidgetBundle {
var body: some Widget {
EventBarsWidget()
MyTasksWidget()
DayProgressWidget()
LockScreenWidget()
LockScreenCircularWidget()
TaskLiveActivity()
}
}