feat: home/lock widgets, Health-aware streaks, workout editor, and entitlement fixes
Widgets (new KisaniCalWidgets extension) - Event Countdown widget: configurable via AppIntent, pick from your events or set a custom name/date; dot-grid progress toward the event. - Day Progress and Tasks-Done-Today widgets; lock screen widgets. - Shared dot grid sizes circles to fill the widget evenly at any count. - Tasks/calendar prefs now persist to the App Group so widgets read live data. Health & streaks - Persist HealthKit authorization and re-establish on launch (fixes the Today dashboard and streak sync disappearing after relaunch). - Add a Health permission toggle to onboarding; unify Settings/Profile on the manager's state. - Day streak counts from a logged Health workout OR marking all exercises complete; nudge to mark exercises complete even when Health logged the workout. Workout editor - Drag to reorder exercises and move them across sections (drag-and-drop); swipe to delete. - Add-exercise sheet: global search and keep-adding-multiple with a running count. Fixes - Restore app entitlements link (Sign in with Apple, HealthKit, App Group, iCloud). - Add HealthKit usage strings; widget Info.plist NSExtension + nested bundle id. - Calendar "Connect" routes to Settings when access was denied. - Bake DEVELOPMENT_TEAM and shared versions into project.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
153
KisaniCalWidgets/EventCountdownWidget.swift
Normal file
153
KisaniCalWidgets/EventCountdownWidget.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
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.
|
||||
|
||||
struct EventConfigIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Event Countdown"
|
||||
static var description = IntentDescription("Count down to any upcoming event with a dot grid.")
|
||||
|
||||
// Pick one of your existing events (tasks with a due date). When set, it
|
||||
// overrides the manual name/date fields below.
|
||||
@Parameter(title: "Track event")
|
||||
var event: EventEntity?
|
||||
|
||||
@Parameter(title: "Or event name", default: "My Event")
|
||||
var eventName: String
|
||||
|
||||
@Parameter(title: "Event date", default: Date.now)
|
||||
var targetDate: Date
|
||||
|
||||
@Parameter(title: "Counting from", default: Date.now)
|
||||
var startDate: Date
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
struct EventEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let eventName: String
|
||||
let start: Date
|
||||
let target: Date
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
struct EventProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> EventEntry {
|
||||
EventEntry(date: .now,
|
||||
eventName: "Mum's Birthday",
|
||||
start: Date().addingTimeInterval(-86400 * 320),
|
||||
target: Date().addingTimeInterval(86400 * 60))
|
||||
}
|
||||
|
||||
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 entry = entry(for: configuration)
|
||||
// Recompute at the next midnight so the grid advances one dot per day.
|
||||
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
||||
return Timeline(entries: [entry], policy: .after(nextMidnight))
|
||||
}
|
||||
|
||||
private func entry(for config: EventConfigIntent) -> EventEntry {
|
||||
let name: String
|
||||
let target: Date
|
||||
if let ev = config.event, let due = ev.dueDate {
|
||||
// A picked event supplies its own name + date.
|
||||
name = ev.title
|
||||
target = due
|
||||
} else {
|
||||
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
target = config.targetDate
|
||||
}
|
||||
// Never let the start sit after the target.
|
||||
let start = min(config.startDate, target)
|
||||
return EventEntry(date: .now, eventName: name, start: start, target: target)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct EventCountdownWidget: Widget {
|
||||
let kind = "KisaniEventCountdown"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
|
||||
EventCountdownView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Event Countdown")
|
||||
.description("Track the days until any upcoming event with a dot grid.")
|
||||
.supportedFamilies([.systemMedium, .systemSmall])
|
||||
}
|
||||
}
|
||||
29
KisaniCalWidgets/Info.plist
Normal file
29
KisaniCalWidgets/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Kisani Cal Widgets</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
KisaniCalWidgets/KisaniCalWidgets.entitlements
Normal file
10
KisaniCalWidgets/KisaniCalWidgets.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.kutesir.KisaniCal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
114
KisaniCalWidgets/KisaniCalWidgets.swift
Normal file
114
KisaniCalWidgets/KisaniCalWidgets.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
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 %")
|
||||
|
||||
struct DayProgressWidget: Widget {
|
||||
let kind = "KisaniDayProgress"
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
|
||||
ProgressDotView(period: .day, accent: Color(red: 0.48, green: 0.87, blue: 0.80))
|
||||
}
|
||||
.configurationDisplayName("Day Progress")
|
||||
.description("How much of today has elapsed, as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tasks Done Today Widget ("Tasks complete %")
|
||||
|
||||
struct TasksCompleteWidget: Widget {
|
||||
let kind = "KisaniTasksComplete"
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
|
||||
TasksCompleteView()
|
||||
}
|
||||
.configurationDisplayName("Tasks Done Today")
|
||||
.description("Percentage of today's tasks you've completed.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
EventCountdownWidget()
|
||||
DayProgressWidget()
|
||||
TasksCompleteWidget()
|
||||
LockScreenWidget()
|
||||
LockScreenCircularWidget()
|
||||
}
|
||||
}
|
||||
120
KisaniCalWidgets/WidgetData.swift
Normal file
120
KisaniCalWidgets/WidgetData.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// Minimal task model shared between widget and main app via App Group
|
||||
struct WidgetTask: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
let isComplete: Bool
|
||||
let quadrant: String // raw value of Quadrant enum
|
||||
let category: String
|
||||
|
||||
// Days remaining until due date
|
||||
var daysLeft: Int? {
|
||||
guard let due = dueDate else { return nil }
|
||||
let d = Calendar.current.dateComponents([.day], from: Calendar.current.startOfDay(for: .now), to: due).day ?? 0
|
||||
return max(0, d)
|
||||
}
|
||||
|
||||
// Hours remaining (for sub-day precision)
|
||||
var hoursLeft: Int? {
|
||||
guard let due = dueDate else { return nil }
|
||||
let h = Calendar.current.dateComponents([.hour], from: .now, to: due).hour ?? 0
|
||||
return max(0, h)
|
||||
}
|
||||
|
||||
var timeLeftLabel: String {
|
||||
guard dueDate != nil else { return "" }
|
||||
if isComplete { return "Done" }
|
||||
let days = daysLeft ?? 0
|
||||
if days == 0 {
|
||||
let hrs = hoursLeft ?? 0
|
||||
return hrs <= 0 ? "Due now" : "\(hrs)hr left"
|
||||
}
|
||||
if days < 30 { return "\(days)d left" }
|
||||
let months = days / 30
|
||||
let remDays = days % 30
|
||||
if remDays == 0 { return "\(months)mth left" }
|
||||
return "\(months)mth \(remDays)d..."
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch quadrant {
|
||||
case "Urgent + Important": return Color(red: 0.96, green: 0.42, blue: 0.20)
|
||||
case "Schedule It": return Color(red: 0.95, green: 0.78, blue: 0.10)
|
||||
case "Delegate": return Color(red: 0.24, green: 0.58, blue: 0.95)
|
||||
case "Eliminate": return Color(red: 0.20, green: 0.78, blue: 0.50)
|
||||
default: return Color(red: 0.96, green: 0.42, blue: 0.20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load tasks from the shared App Group UserDefaults
|
||||
func loadWidgetTasks() -> [WidgetTask] {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
let key = "kisani.tasks.v2.\(uid)"
|
||||
guard let data = UserDefaults.kisani.data(forKey: key),
|
||||
let raw = try? JSONDecoder().decode([RawTask].self, from: data)
|
||||
else { return [] }
|
||||
return raw.map {
|
||||
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial decode of TaskItem — only the fields widgets need
|
||||
private struct RawTask: Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
var isComplete: Bool = false
|
||||
var quadrant: String = "Urgent + Important"
|
||||
var category: String = "reminder"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, dueDate, isComplete, quadrant, category
|
||||
}
|
||||
}
|
||||
|
||||
// Next upcoming task with a due date (for default widget display)
|
||||
func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
|
||||
tasks
|
||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
.first
|
||||
}
|
||||
|
||||
// Today's task completion: (done, total) for tasks due today.
|
||||
func todayTaskCompletion() -> (done: Int, total: Int) {
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: Date())
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
let tasks = loadWidgetTasks()
|
||||
func dueToday(_ t: WidgetTask) -> Bool {
|
||||
guard let due = t.dueDate else { return false }
|
||||
return due >= start && due < end
|
||||
}
|
||||
let active = tasks.filter { !$0.isComplete && dueToday($0) }
|
||||
let completed = tasks.filter { $0.isComplete && dueToday($0) }
|
||||
return (completed.count, active.count + completed.count)
|
||||
}
|
||||
|
||||
// Workout streak count
|
||||
func loadWorkoutStreak() -> Int {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
let key = "kisani.workout.completedDates.\(uid)"
|
||||
let dates = UserDefaults.kisani.stringArray(forKey: key) ?? []
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
let cal = Calendar.current
|
||||
let dateSet = Set(dates)
|
||||
var streak = 0
|
||||
var check = cal.startOfDay(for: Date())
|
||||
if !dateSet.contains(fmt.string(from: check)) {
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
while dateSet.contains(fmt.string(from: check)) {
|
||||
streak += 1
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
return streak
|
||||
}
|
||||
289
KisaniCalWidgets/WidgetViews.swift
Normal file
289
KisaniCalWidgets/WidgetViews.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Dot-grid progress (day / week / month / year)
|
||||
|
||||
enum TimePeriod { case day, week, month, year }
|
||||
|
||||
struct ProgressDotView: View {
|
||||
let period: TimePeriod
|
||||
let accent: Color
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var label: String {
|
||||
switch period {
|
||||
case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date())
|
||||
case .week: return "This week"
|
||||
case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date())
|
||||
case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
let cal = Calendar.current; let now = Date()
|
||||
switch period {
|
||||
case .day:
|
||||
let sod = cal.startOfDay(for: now)
|
||||
return (now.timeIntervalSince(sod)) / 86400
|
||||
case .week:
|
||||
let comps = cal.dateComponents([.weekday], from: now)
|
||||
let wd = (comps.weekday ?? 1) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(wd) * 86400 + secIntoDay) / (7 * 86400)
|
||||
case .month:
|
||||
let range = cal.range(of: .day, in: .month, for: now)!
|
||||
let day = cal.component(.day, from: now) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400)
|
||||
case .year:
|
||||
let start = cal.date(from: cal.dateComponents([.year], from: now))!
|
||||
let end = cal.date(byAdding: .year, value: 1, to: start)!
|
||||
return now.timeIntervalSince(start) / end.timeIntervalSince(start)
|
||||
}
|
||||
}
|
||||
|
||||
private var subLabel: String {
|
||||
let pct = Int(progress * 100)
|
||||
return "\(pct)%"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let isMedium = family == .systemMedium
|
||||
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(label)
|
||||
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(subLabel)
|
||||
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
DotGrid(total: 100, filled: Int(progress * 100), color: accent)
|
||||
}
|
||||
.padding(.horizontal, isMedium ? 4 : 0)
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tasks completed today
|
||||
|
||||
struct TasksCompleteView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
|
||||
|
||||
private var stats: (done: Int, total: Int) { todayTaskCompletion() }
|
||||
private var percent: Int {
|
||||
let s = stats
|
||||
guard s.total > 0 else { return 0 }
|
||||
return Int((Double(s.done) / Double(s.total) * 100).rounded())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let s = stats
|
||||
let isMedium = family == .systemMedium
|
||||
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Today's Tasks")
|
||||
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
if s.total == 0 {
|
||||
Spacer(minLength: 0)
|
||||
Text("No tasks due today")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
Text("\(s.done) of \(s.total) done")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(teal)
|
||||
Spacer(minLength: 2)
|
||||
DotGrid(total: s.total, filled: s.done, color: teal)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isMedium ? 4 : 0)
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular dot grid (event countdown motif)
|
||||
|
||||
struct DotGrid: View {
|
||||
let total: Int
|
||||
let filled: Int
|
||||
let color: Color
|
||||
var gap: CGFloat = 3
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let l = layout(for: total, in: geo.size)
|
||||
VStack(spacing: gap) {
|
||||
ForEach(0..<l.rows, id: \.self) { row in
|
||||
HStack(spacing: gap) {
|
||||
ForEach(0..<l.cols, id: \.self) { col in
|
||||
let idx = row * l.cols + col
|
||||
if idx < total {
|
||||
dot(filled: idx < filled, size: l.size)
|
||||
} else {
|
||||
Color.clear.frame(width: l.size, height: l.size)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func dot(filled: Bool, size: CGFloat) -> some View {
|
||||
if filled {
|
||||
Circle().fill(color).frame(width: size, height: size)
|
||||
} else {
|
||||
Circle()
|
||||
.strokeBorder(color.opacity(0.55), lineWidth: max(0.8, size * 0.13))
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the column count that yields the largest uniform circle that still fits
|
||||
/// every dot inside the available area — an even grid that fills the widget.
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0))
|
||||
for cols in 1...count {
|
||||
let rows = Int(ceil(Double(count) / Double(cols)))
|
||||
let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols)
|
||||
let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
|
||||
let s = min(w, h)
|
||||
if s > best.size { best = (cols, rows, s) }
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Countdown Widget (small + medium)
|
||||
|
||||
struct EventCountdownView: View {
|
||||
let entry: EventEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
|
||||
|
||||
private var percent: Int { Int((entry.progress * 100).rounded()) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if family == .systemSmall {
|
||||
small
|
||||
} else {
|
||||
medium
|
||||
}
|
||||
}
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
|
||||
// Wide layout that mirrors the reference: title + percent, then a dot grid.
|
||||
private var medium: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
DotGrid(total: entry.totalDays,
|
||||
filled: Int(entry.progress * Double(entry.totalDays)),
|
||||
color: teal)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var small: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(2)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(entry.daysLeft)")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundColor(teal)
|
||||
Text(entry.daysLeft == 1 ? "day" : "days")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
Spacer(minLength: 2)
|
||||
DotGrid(total: entry.totalDays,
|
||||
filled: Int(entry.progress * Double(entry.totalDays)),
|
||||
color: teal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen Widget (accessoryRectangular)
|
||||
|
||||
struct LockScreenRectView: View {
|
||||
let task: WidgetTask
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(task.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(task.timeLeftLabel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 22, height: 22)
|
||||
.tint(.white)
|
||||
} else if task.isComplete {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.containerBackground(.thinMaterial, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen Circular
|
||||
|
||||
struct LockScreenCircularView: View {
|
||||
let task: WidgetTask
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if let days = task.daysLeft {
|
||||
Text("\(days)").font(.system(size: 14, weight: .bold))
|
||||
Text("days").font(.system(size: 7))
|
||||
} else {
|
||||
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(.thinMaterial, for: .widget)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user