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:
Robin Kutesa
2026-06-03 12:27:45 +03:00
parent e5bd739499
commit 8b094691f5
23 changed files with 1272 additions and 115 deletions

View File

@@ -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) {

View File

@@ -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()
}
}