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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user