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
|
2026-07-08 22:59:23 +03:00
|
|
|
|
case year
|
2026-06-15 12:40:39 +03:00
|
|
|
|
case customEvent
|
|
|
|
|
|
|
|
|
|
|
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
|
|
|
|
|
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
|
|
|
|
|
.day: "This Day",
|
|
|
|
|
|
.week: "This Week",
|
|
|
|
|
|
.month: "This Month",
|
2026-07-08 22:59:23 +03:00
|
|
|
|
.year: "This Year",
|
2026-06-15 12:40:39 +03:00
|
|
|
|
.customEvent: "Custom Event",
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
|
|
|
|
|
static var title: LocalizedStringResource = "Progress Dots"
|
Rebrand app to "Wenza" (display name + user-facing copy)
Renames the user-facing app to Wenza without changing identity:
- CFBundleDisplayName → "Wenza" (app) and "Wenza Widgets" (widget extension).
- Calendar/Health permission prompts now reference Wenza.
- In-app copy updated: sidebar title, Auth + Onboarding screens, Settings
about/help rows, feedback email subject, tutorial text, widget description.
Bundle identifier, App Group, UserDefaults storage keys, team ID, and the
internal Xcode target/project names are intentionally unchanged, so this stays
the same App Store app with no user data loss. App Store listing name must be
changed separately in App Store Connect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:31:51 +03:00
|
|
|
|
static var description = IntentDescription("Track day, week, month, or a Wenza event.")
|
2026-06-15 12:40:39 +03:00
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-07-08 22:59:23 +03:00
|
|
|
|
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)
|
2026-06-15 12:40:39 +03:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 23:56:58 +03:00
|
|
|
|
// Recurrence-window math itself lives in RecurrenceRule (Shared/) so this
|
|
|
|
|
|
// widget's countdown can never drift from the main app's occurrence logic
|
|
|
|
|
|
// or from the other countdown widget's copy of the same calculation.
|
2026-06-15 12:40:39 +03:00
|
|
|
|
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" {
|
2026-07-11 23:56:58 +03:00
|
|
|
|
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
|
2026-06-15 12:40:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
2026-07-11 23:56:58 +03:00
|
|
|
|
return RecurrenceRule.span(label: label, due: due, now: now)
|
2026-06-15 12:40:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-07-08 22:59:23 +03:00
|
|
|
|
|
|
|
|
|
|
private static func yearTitle(for date: Date) -> String {
|
|
|
|
|
|
let formatter = DateFormatter()
|
|
|
|
|
|
formatter.dateFormat = "yyyy"
|
|
|
|
|
|
return formatter.string(from: date)
|
|
|
|
|
|
}
|
2026-06-15 12:40:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|