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:
@@ -223,7 +223,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
||||
|
||||
// ── Attempt to restore persisted state ──
|
||||
if let data = UserDefaults.standard.data(forKey: kPrograms),
|
||||
if let data = UserDefaults.kisani.data(forKey: kPrograms),
|
||||
let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data),
|
||||
!saved.isEmpty {
|
||||
|
||||
@@ -254,7 +254,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
: saved
|
||||
|
||||
var sched: [Int: UUID] = [:]
|
||||
if let dict = UserDefaults.standard.dictionary(forKey: kSchedule) as? [String: String] {
|
||||
if let dict = UserDefaults.kisani.dictionary(forKey: kSchedule) as? [String: String] {
|
||||
for (k, v) in dict {
|
||||
if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid }
|
||||
}
|
||||
@@ -262,7 +262,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
schedule = sched
|
||||
|
||||
var pid = saved[0].id
|
||||
if let str = UserDefaults.standard.string(forKey: kActivePid),
|
||||
if let str = UserDefaults.kisani.string(forKey: kActivePid),
|
||||
let uid = UUID(uuidString: str),
|
||||
saved.contains(where: { $0.id == uid }) { pid = uid }
|
||||
activeProgramId = pid
|
||||
@@ -283,18 +283,18 @@ class WorkoutViewModel: ObservableObject {
|
||||
activeProgramId = blank.id
|
||||
activeSections = blank.sections
|
||||
}
|
||||
workoutDates = UserDefaults.standard.stringArray(forKey: kWorkoutDates) ?? []
|
||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(programs) {
|
||||
UserDefaults.standard.set(data, forKey: kPrograms)
|
||||
UserDefaults.kisani.set(data, forKey: kPrograms)
|
||||
CloudSyncManager.shared.push(data: data, forKey: kPrograms)
|
||||
}
|
||||
let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString }
|
||||
UserDefaults.standard.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.standard.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.standard.set(workoutDates, forKey: kWorkoutDates)
|
||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||
@@ -326,8 +326,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
@MainActor
|
||||
func checkPendingHealthConfirm() {
|
||||
let key = "kisani.workout.pendingConfirm"
|
||||
guard let dateStr = UserDefaults.standard.string(forKey: key) else { return }
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: key)
|
||||
guard !workoutDates.contains(dateStr) else { return }
|
||||
workoutDates.append(dateStr)
|
||||
save()
|
||||
@@ -363,6 +363,10 @@ class WorkoutViewModel: ObservableObject {
|
||||
var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } }
|
||||
var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 }
|
||||
|
||||
/// True if today already counts toward the streak — either marked complete in-app
|
||||
/// or detected from Apple Health (both land in `workoutDates`).
|
||||
var isTodayRecorded: Bool { workoutDates.contains(iso.string(from: Date())) }
|
||||
|
||||
// MARK: - Program management
|
||||
func switchProgram(to id: UUID) {
|
||||
syncBack()
|
||||
@@ -480,6 +484,34 @@ class WorkoutViewModel: ObservableObject {
|
||||
syncBack()
|
||||
}
|
||||
|
||||
/// Relocate an exercise (within or across sections) for any program via drag-and-drop.
|
||||
/// Drops the exercise just before `beforeId`; pass nil to append to the target section.
|
||||
func moveExercise(programId: UUID, exerciseId: UUID, toSection targetSectionId: UUID, before beforeId: UUID?) {
|
||||
guard exerciseId != beforeId else { return }
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
|
||||
// Remove from its current section.
|
||||
var moved: Exercise?
|
||||
for si in programs[pi].sections.indices {
|
||||
if let ei = programs[pi].sections[si].exercises.firstIndex(where: { $0.id == exerciseId }) {
|
||||
moved = programs[pi].sections[si].exercises.remove(at: ei)
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let ex = moved,
|
||||
let ti = programs[pi].sections.firstIndex(where: { $0.id == targetSectionId }) else { return }
|
||||
|
||||
// Insert before the target row, or append if none specified.
|
||||
if let beforeId, let idx = programs[pi].sections[ti].exercises.firstIndex(where: { $0.id == beforeId }) {
|
||||
programs[pi].sections[ti].exercises.insert(ex, at: idx)
|
||||
} else {
|
||||
programs[pi].sections[ti].exercises.append(ex)
|
||||
}
|
||||
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Universal (any program) operations
|
||||
func addExercise(programId: UUID, sectionId: UUID, name: String, setsCount: Int, repsCount: Int) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||||
@@ -494,6 +526,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
Exercise(name: name, sfSymbol: symbol, colorIndex: colorIdx, sets: sets, isDumbbell: isDumbb)
|
||||
)
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteExercise(programId: UUID, sectionId: UUID, exerciseId: UUID) {
|
||||
@@ -502,18 +535,21 @@ class WorkoutViewModel: ObservableObject {
|
||||
else { return }
|
||||
programs[pi].sections[si].exercises.removeAll { $0.id == exerciseId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func addSection(to programId: UUID, name: String) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.append(WorkoutSection(name: name, exercises: []))
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSection(from programId: UUID, sectionId: UUID) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.removeAll { $0.id == sectionId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func renameSection(in programId: UUID, sectionId: UUID, name: String) {
|
||||
@@ -522,6 +558,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
else { return }
|
||||
programs[pi].sections[si].name = name
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleExpanded(sectionId: UUID, exerciseId: UUID) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Task Model
|
||||
struct TaskItem: Identifiable, Codable, Equatable {
|
||||
@@ -129,8 +130,16 @@ class TaskViewModel: ObservableObject {
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
self.persistKey = "kisani.tasks.v2.\(uid)"
|
||||
// One-time migration: tasks used to live in UserDefaults.standard, but the
|
||||
// widgets read from the shared App Group. Move legacy data over if present.
|
||||
if UserDefaults.kisani.data(forKey: persistKey) == nil,
|
||||
let legacy = UserDefaults.standard.data(forKey: persistKey) {
|
||||
UserDefaults.kisani.set(legacy, forKey: persistKey)
|
||||
UserDefaults.standard.removeObject(forKey: persistKey)
|
||||
}
|
||||
// Restore from iCloud only if the App Group still has nothing (fresh install / new device).
|
||||
CloudSyncManager.shared.restoreIfMissing(key: persistKey)
|
||||
if let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
if let data = UserDefaults.kisani.data(forKey: persistKey),
|
||||
let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty {
|
||||
tasks = saved
|
||||
} else {
|
||||
@@ -139,7 +148,7 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func reload() {
|
||||
guard let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
guard let data = UserDefaults.kisani.data(forKey: persistKey),
|
||||
let saved = try? JSONDecoder().decode([TaskItem].self, from: data)
|
||||
else { return }
|
||||
tasks = saved
|
||||
@@ -147,8 +156,9 @@ class TaskViewModel: ObservableObject {
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.standard.set(data, forKey: persistKey)
|
||||
UserDefaults.kisani.set(data, forKey: persistKey)
|
||||
CloudSyncManager.shared.push(data: data, forKey: persistKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user