2026-06-03 12:27:45 +03:00
|
|
|
|
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 %")
|
|
|
|
|
|
|
2026-06-15 12:40:39 +03:00
|
|
|
|
enum ProgressDotMode: String, AppEnum {
|
|
|
|
|
|
case day
|
|
|
|
|
|
case week
|
|
|
|
|
|
case month
|
|
|
|
|
|
case customEvent
|
|
|
|
|
|
|
|
|
|
|
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
|
|
|
|
|
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
|
|
|
|
|
.day: "This Day",
|
|
|
|
|
|
.week: "This Week",
|
|
|
|
|
|
.month: "This Month",
|
|
|
|
|
|
.customEvent: "Custom Event",
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
|
|
|
|
|
static var title: LocalizedStringResource = "Progress Dots"
|
|
|
|
|
|
static var description = IntentDescription("Track day, week, month, or a KisaniCal 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 .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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 12:27:45 +03:00
|
|
|
|
struct DayProgressWidget: Widget {
|
|
|
|
|
|
let kind = "KisaniDayProgress"
|
|
|
|
|
|
var body: some WidgetConfiguration {
|
2026-06-15 12:40:39 +03:00
|
|
|
|
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
|
|
|
|
|
|
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
|
2026-06-03 12:27:45 +03:00
|
|
|
|
}
|
2026-06-15 12:40:39 +03:00
|
|
|
|
.configurationDisplayName("Progress Dots")
|
|
|
|
|
|
.description("Track day, week, month, or a selected event as a dot grid.")
|
|
|
|
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
2026-06-03 12:27:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-06-10 17:31:55 +03:00
|
|
|
|
EventBarsWidget()
|
2026-06-03 13:31:04 +03:00
|
|
|
|
MyTasksWidget()
|
2026-06-03 12:27:45 +03:00
|
|
|
|
DayProgressWidget()
|
|
|
|
|
|
LockScreenWidget()
|
|
|
|
|
|
LockScreenCircularWidget()
|
2026-06-06 19:29:25 +03:00
|
|
|
|
TaskLiveActivity()
|
2026-06-03 12:27:45 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|