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

@@ -40,7 +40,10 @@ private struct TabBarScrollModifier: ViewModifier {
}
)
.onPreferenceChange(ScrollYKey.self) { y in
tabState.report(scrollY: y)
// Defer off the current view-update cycle: this callback can fire during
// layout, and mutating tabState's @Published state there triggers
// "Modifying state during view update".
Task { @MainActor in tabState.report(scrollY: y) }
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import WidgetKit
struct ContentView: View {
@StateObject private var taskVM = TaskViewModel()
@@ -86,7 +87,13 @@ struct ContentView: View {
if !hasOnboarded { showOnboarding = true }
CloudSyncManager.shared.restoreSettings()
CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
@@ -94,11 +101,15 @@ struct ContentView: View {
workoutVM.checkPendingHealthConfirm()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task { await workoutVM.syncFromHealthKit() }
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
}
.onChange(of: taskVM.tasks) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
WidgetCenter.shared.reloadAllTimelines()
}
.onChange(of: workoutVM.doneSets) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)

View File

@@ -10,5 +10,9 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.kutesir.KisaniCal</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
import Foundation
let kisaniAppGroupID = "group.com.kutesir.KisaniCal"
extension UserDefaults {
/// Shared container readable by both the main app and the widget extension.
static var kisani: UserDefaults {
UserDefaults(suiteName: kisaniAppGroupID) ?? .standard
}
}

View File

@@ -28,7 +28,7 @@ final class AuthManager: NSObject, ObservableObject {
super.init()
currentUser = loadUser()
if let uid = currentUser?.id {
UserDefaults.standard.set(uid, forKey: Self.activeUserIdKey)
UserDefaults.kisani.set(uid, forKey: Self.activeUserIdKey)
}
}
@@ -75,7 +75,7 @@ final class AuthManager: NSObject, ObservableObject {
func signOut() {
currentUser = nil
UserDefaults.standard.removeObject(forKey: Self.activeUserIdKey)
UserDefaults.kisani.removeObject(forKey: Self.activeUserIdKey)
deleteKeychain(key: keychainUser)
}
@@ -83,7 +83,7 @@ final class AuthManager: NSObject, ObservableObject {
private func persist(_ user: AppUser) {
currentUser = user
UserDefaults.standard.set(user.id, forKey: Self.activeUserIdKey)
UserDefaults.kisani.set(user.id, forKey: Self.activeUserIdKey)
if let data = try? JSONEncoder().encode(user) {
saveKeychain(key: keychainUser, data: data)
}

View File

@@ -49,10 +49,10 @@ final class CloudSyncManager {
/// Copy iCloud value into UserDefaults ONLY when UserDefaults has no value yet.
/// This safely restores data after a fresh install or new build without overwriting live data.
func restoreIfMissing(key: String) {
guard UserDefaults.standard.object(forKey: key) == nil,
guard UserDefaults.kisani.object(forKey: key) == nil,
let icloudValue = kv.object(forKey: key)
else { return }
UserDefaults.standard.set(icloudValue, forKey: key)
UserDefaults.kisani.set(icloudValue, forKey: key)
}
// MARK: - Bulk helpers
@@ -60,7 +60,7 @@ final class CloudSyncManager {
/// Backup all small settings keys from UserDefaults iCloud
func backupSettings() {
for key in settingsKeys {
if let val = UserDefaults.standard.object(forKey: key) {
if let val = UserDefaults.kisani.object(forKey: key) {
kv.set(val, forKey: key)
}
}
@@ -85,7 +85,7 @@ final class CloudSyncManager {
for key in changedKeys {
if let val = kv.object(forKey: key) {
UserDefaults.standard.set(val, forKey: key)
UserDefaults.kisani.set(val, forKey: key)
}
}

View File

@@ -14,6 +14,11 @@ final class HealthKitManager: ObservableObject {
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
// Persists across launches: HealthKit can't report read-authorization status,
// so we remember that the user opted in and re-establish on launch.
private let connectedKey = "kisani.health.connected"
var isConnected: Bool { UserDefaults.kisani.bool(forKey: connectedKey) }
private init() {}
// MARK: - Auth
@@ -23,6 +28,7 @@ final class HealthKitManager: ObservableObject {
do {
try await store.requestAuthorization(toShare: shareTypes, read: readTypes)
authorized = true
UserDefaults.kisani.set(true, forKey: connectedKey)
await refresh()
return true
} catch {
@@ -30,6 +36,23 @@ final class HealthKitManager: ObservableObject {
}
}
/// Re-establish authorization on app launch if the user previously connected Health.
func bootstrap() {
guard isAvailable, isConnected else { return }
authorized = true
Task { await refresh() }
}
/// Turn the integration off: hides the dashboard and stops Health reads.
func disconnect() {
authorized = false
UserDefaults.kisani.set(false, forKey: connectedKey)
stepsToday = 0
activeCaloriesToday = 0
restingHeartRate = 0
workoutsThisWeek = 0
}
// MARK: - Refresh all stats
func refresh() async {

View File

@@ -40,7 +40,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
// MARK: - Complete task directly in UserDefaults (called from background)
private func markTaskComplete(taskId: String, userId: String) {
let key = "kisani.tasks.v2.\(userId)"
guard let data = UserDefaults.standard.data(forKey: key),
guard let data = UserDefaults.kisani.data(forKey: key),
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
let uuid = UUID(uuidString: taskId),
let idx = tasks.firstIndex(where: { $0.id == uuid })
@@ -48,7 +48,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
tasks[idx].isComplete = true
tasks[idx].completedAt = Date()
if let encoded = try? JSONEncoder().encode(tasks) {
UserDefaults.standard.set(encoded, forKey: key)
UserDefaults.kisani.set(encoded, forKey: key)
}
}
@@ -77,6 +77,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let schedule = workoutVM.schedule
let programs = workoutVM.programs
let progress = workoutVM.progress
let recorded = workoutVM.isTodayRecorded
let tasks = taskVM.tasks
center.getNotificationSettings { [weak self] settings in
@@ -84,7 +85,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let ok = settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional
guard ok else { return }
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress)
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
self.scheduleTasks(snapshot: tasks)
self.updateBadge(snapshot: tasks)
@@ -105,9 +106,10 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
// MARK: - Workout reminders + check-in
private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double) {
let ud = UserDefaults.standard
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] }
private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double, recordedToday: Bool) {
let ud = UserDefaults.kisani
let markCompleteId = "kisani.workout.markcomplete"
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } + [markCompleteId]
center.removePendingNotificationRequests(withIdentifiers: existingIds)
let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true
@@ -118,9 +120,13 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30
let todayWD = Calendar.current.component(.weekday, from: Date())
// In-app completion vs. "recorded" (in-app OR detected from Apple Health).
let inAppDoneToday = progress >= 1.0
for (weekday, pid) in schedule {
guard let program = programs.first(where: { $0.id == pid }) else { continue }
let doneToday = weekday == todayWD && progress >= 1.0
// Suppress "time to work out" if today is already done by either path.
let doneToday = weekday == todayWD && (inAppDoneToday || recordedToday)
if reminderOn && !doneToday {
let content = UNMutableNotificationContent()
@@ -152,12 +158,34 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
))
}
}
// Mark-complete nudge: Apple Health recorded a workout today (streak is safe),
// but the user hasn't marked their exercises complete in the app yet.
if recordedToday && !inAppDoneToday {
let cal = Calendar.current
var fireComps = cal.dateComponents([.year, .month, .day], from: Date())
fireComps.hour = rHour; fireComps.minute = rMinute
var fire = cal.date(from: fireComps) ?? Date()
if fire <= Date() { fire = Date().addingTimeInterval(3600) } // reminder time passed nudge in 1h
let content = UNMutableNotificationContent()
content.title = "Mark your workout complete"
content.body = "We logged today's workout from Apple Health — mark your exercises complete to update your log."
content.sound = .default
content.userInfo = ["deeplink": "workout"]
let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fire)
center.add(UNNotificationRequest(
identifier: markCompleteId,
content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false)
))
}
}
// MARK: - Workout completion confirmation
private func scheduleWorkoutConfirm(schedule: [Int: UUID], programs: [WorkoutProgram]) {
let ud = UserDefaults.standard
let ud = UserDefaults.kisani
guard ud.object(forKey: "workoutConfirmEnabled") as? Bool ?? false else {
center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" })
return
@@ -277,7 +305,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
let userId = UserDefaults.kisani.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
let info = response.notification.request.content.userInfo
let deeplink = info["deeplink"] as? String
let taskId = info["taskId"] as? String
@@ -290,7 +318,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
case Self.logWorkoutActId:
// "Yes, log it" from workout confirm notification store date for WorkoutViewModel to pick up on next foreground
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
UserDefaults.standard.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }

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

View File

@@ -15,6 +15,8 @@ struct ExercisePickerSheet: View {
@State private var setsText = "3"
@State private var repsText = "10"
@State private var showCustom = false
@State private var addedCount = 0
@State private var flashedId: UUID? = nil
@FocusState private var customFocused: Bool
private func addExercise(name: String) {
@@ -25,27 +27,48 @@ struct ExercisePickerSheet: View {
} else {
vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps)
}
addedCount += 1
}
// Briefly mark a template row as "added" so the user gets feedback while keeping the sheet open.
private func flash(_ id: UUID) {
withAnimation(KisaniSpring.micro) { flashedId = id }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
if flashedId == id { withAnimation(KisaniSpring.micro) { flashedId = nil } }
}
}
private var filtered: [ExerciseTemplate] {
let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle }
guard !searchText.isEmpty else { return byMuscle }
return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
// When searching, look across the whole library (ignore the muscle tab).
guard searchText.isEmpty else {
return exerciseLibrary.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
return exerciseLibrary.filter { $0.muscle == selectedMuscle }
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
HStack(spacing: 8) {
Text("Add Exercise")
.font(AppFonts.sans(17, weight: .bold))
.foregroundColor(AppColors.text(cs))
if addedCount > 0 {
Text("\(addedCount) added")
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.green)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(AppColors.greenSoft)
.clipShape(Capsule())
.transition(.scale.combined(with: .opacity))
}
Spacer()
Button("Cancel") { dismiss() }
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text3(cs))
Button(addedCount > 0 ? "Done" : "Cancel") { dismiss() }
.font(AppFonts.sans(13, weight: addedCount > 0 ? .bold : .regular))
.foregroundColor(addedCount > 0 ? AppColors.accent : AppColors.text3(cs))
.buttonStyle(.plain)
}
.animation(KisaniSpring.snappy, value: addedCount)
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
Divider().background(AppColors.border(cs))
@@ -114,7 +137,8 @@ struct ExercisePickerSheet: View {
let n = customName.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty else { return }
addExercise(name: n)
dismiss()
customName = ""
customFocused = true
} label: {
Text("Add Custom Exercise")
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white)
@@ -134,9 +158,9 @@ struct ExercisePickerSheet: View {
// Library exercises
ForEach(filtered) { template in
ExerciseTemplateRow(template: template) {
ExerciseTemplateRow(template: template, added: flashedId == template.id) {
addExercise(name: template.name)
dismiss()
flash(template.id)
}
Divider().background(AppColors.border(cs)).padding(.leading, 60)
}
@@ -188,6 +212,7 @@ struct ExercisePickerSheet: View {
private struct ExerciseTemplateRow: View {
@Environment(\.colorScheme) private var cs
let template: ExerciseTemplate
var added: Bool = false
let onAdd: () -> Void
var body: some View {
@@ -206,9 +231,10 @@ private struct ExerciseTemplateRow: View {
Spacer()
Image(systemName: "plus.circle.fill")
Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill")
.font(.system(size: 18))
.foregroundColor(AppColors.accent.opacity(0.7))
.foregroundColor(added ? AppColors.green : AppColors.accent.opacity(0.7))
.scaleEffect(added ? 1.15 : 1)
}
.padding(.vertical, 10)
.contentShape(Rectangle())

View File

@@ -22,9 +22,13 @@ final class CalendarStore: ObservableObject {
init() {
authStatus = EKEventStore.authorizationStatus(for: .event)
hiddenCalendarIDs = Set(UserDefaults.standard.stringArray(forKey: "kisani.cal.hiddenIDs") ?? [])
localCalendarsEnabled = UserDefaults.standard.object(forKey: "kisani.cal.localEnabled") as? Bool ?? true
doNotDisturb = UserDefaults.standard.bool(forKey: "kisani.cal.dnd")
// Migrate legacy prefs from UserDefaults.standard into the App Group (one-time).
Self.migrateCalPrefIfNeeded(hiddenIDsKey)
Self.migrateCalPrefIfNeeded(calEnabledKey)
Self.migrateCalPrefIfNeeded(dndKey)
hiddenCalendarIDs = Set(UserDefaults.kisani.stringArray(forKey: hiddenIDsKey) ?? [])
localCalendarsEnabled = UserDefaults.kisani.object(forKey: calEnabledKey) as? Bool ?? true
doNotDisturb = UserDefaults.kisani.bool(forKey: dndKey)
changeToken = NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged,
object: ekStore,
@@ -45,7 +49,10 @@ final class CalendarStore: ObservableObject {
let current = EKEventStore.authorizationStatus(for: .event)
guard current != authStatus else { return }
authStatus = current
if isAuthorized { fetchMonth(containing: fetchedMonth ?? Date()) }
if isAuthorized {
fetchMonth(containing: fetchedMonth ?? Date())
loadLocalCalendars()
}
}
deinit {
@@ -108,6 +115,10 @@ final class CalendarStore: ObservableObject {
return authStatus == .authorized
}
/// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings.
var canRequest: Bool { authStatus == .notDetermined }
var isDenied: Bool { authStatus == .denied || authStatus == .restricted }
// MARK: - Local Calendar Management
func loadLocalCalendars() {
@@ -137,12 +148,23 @@ final class CalendarStore: ObservableObject {
func setDoNotDisturb(_ on: Bool) {
doNotDisturb = on
UserDefaults.standard.set(on, forKey: dndKey)
UserDefaults.kisani.set(on, forKey: dndKey)
CloudSyncManager.shared.push(value: on, forKey: dndKey)
}
private func saveCalPrefs() {
UserDefaults.standard.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
UserDefaults.standard.set(localCalendarsEnabled, forKey: calEnabledKey)
UserDefaults.kisani.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
UserDefaults.kisani.set(localCalendarsEnabled, forKey: calEnabledKey)
CloudSyncManager.shared.push(value: Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
CloudSyncManager.shared.push(value: localCalendarsEnabled, forKey: calEnabledKey)
}
private static func migrateCalPrefIfNeeded(_ key: String) {
if UserDefaults.kisani.object(forKey: key) == nil,
let legacy = UserDefaults.standard.object(forKey: key) {
UserDefaults.kisani.set(legacy, forKey: key)
UserDefaults.standard.removeObject(forKey: key)
}
}
func groupedCalendars() -> [CalendarGroup] {
@@ -890,6 +912,8 @@ struct CalendarConnectSheet: View {
@ObservedObject var calStore: CalendarStore
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) private var openURL
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack {
@@ -906,10 +930,19 @@ struct CalendarConnectSheet: View {
.clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
Text(calStore.isDenied
? "Access denied — enable in Settings"
: "Tap to connect and show events")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Button("Connect") { calStore.requestAccess() }
Button(calStore.isDenied ? "Settings" : "Connect") {
if calStore.canRequest {
calStore.requestAccess()
} else if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 10).padding(.vertical, 5)
@@ -992,6 +1025,10 @@ struct CalendarConnectSheet: View {
.foregroundColor(AppColors.accent)
}
}
.onAppear { calStore.refreshStatus() }
.onChange(of: scenePhase) { phase in
if phase == .active { calStore.refreshStatus() }
}
}
}
}

View File

@@ -27,6 +27,7 @@ struct OnboardingView: View {
@State private var heightStr = ""
@State private var selectedDays: Set<Int> = []
@State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
@ObservedObject private var hk = HealthKitManager.shared
@FocusState private var focusedField: Field?
private let ekStore = EKEventStore()
@@ -284,6 +285,50 @@ struct OnboardingView: View {
.padding(.horizontal, 20)
.padding(.bottom, 28)
//
// MARK: Health
//
if hk.isAvailable {
OnboardingSectionHeader(title: "Health")
HStack(spacing: 12) {
Image(systemName: hk.authorized ? "heart.fill" : "heart")
.font(.system(size: 15))
.foregroundColor(hk.authorized ? AppColors.green : AppColors.accent)
.frame(width: 32, height: 32)
.background(hk.authorized ? AppColors.greenSoft : AppColors.accentSoft)
.clipShape(RoundedRectangle(cornerRadius: 8))
.animation(KisaniSpring.micro, value: hk.authorized)
VStack(alignment: .leading, spacing: 2) {
Text("Apple Health")
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Text(hk.authorized
? "Steps, calories & workouts on your dashboard"
: "Show your activity stats above today's tasks")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
}
Spacer()
Toggle("", isOn: Binding(
get: { hk.authorized },
set: { on in
if on { Task { _ = await hk.requestAuthorization() } }
else { hk.disconnect() }
}
))
.labelsHidden()
.tint(AppColors.accent)
}
.padding(.horizontal, 14).padding(.vertical, 14)
.cardStyle()
.padding(.horizontal, 20)
.padding(.bottom, 28)
}
//
// MARK: Workout
//

View File

@@ -9,7 +9,6 @@ struct SettingsView: View {
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@AppStorage("soundsOn") private var soundsOn = true
@AppStorage("healthOn") private var healthOn = false
@AppStorage("showMatrixTab") private var showMatrixTab = true
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
@AppStorage("dateFormat") private var dateFormat = 0
@@ -116,7 +115,7 @@ struct SettingsView: View {
SttSection(label: "Fitness") {
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
HealthToggleRow(isOn: $healthOn, isLast: true)
HealthToggleRow(isLast: true)
}
// ACCOUNT
@@ -176,7 +175,6 @@ struct SettingsView: View {
// MARK: - Health Toggle Row (requests auth when turned on)
private struct HealthToggleRow: View {
@Environment(\.colorScheme) private var cs
@Binding var isOn: Bool
var isLast: Bool = false
@ObservedObject private var hk = HealthKitManager.shared
@@ -193,25 +191,26 @@ private struct HealthToggleRow: View {
Text("Health Integration")
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text(cs))
if isOn && hk.authorized {
if hk.authorized {
Text("Connected")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.green)
} else if isOn && !hk.isAvailable {
} else if !hk.isAvailable {
Text("Not available on this device")
.font(AppFonts.mono(9))
.foregroundColor(AppColors.text3(cs))
}
}
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(AppColors.green)
.onChange(of: isOn) { enabled in
if enabled {
Task { await HealthKitManager.shared.requestAuthorization() }
}
Toggle("", isOn: Binding(
get: { hk.authorized },
set: { on in
if on { Task { _ = await hk.requestAuthorization() } }
else { hk.disconnect() }
}
))
.labelsHidden()
.tint(AppColors.green)
}
.padding(.horizontal, 14).padding(.vertical, 11)
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
@@ -1031,7 +1030,6 @@ private struct ProfileStatsSheet: View {
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@AppStorage("streakGoal") private var streakGoal = 5
@AppStorage("healthOn") private var healthOn = false
@ObservedObject private var hk = HealthKitManager.shared
@ObservedObject private var auth = AuthManager.shared
@@ -1225,7 +1223,7 @@ private struct ProfileStatsSheet: View {
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
// Health card
if healthOn && hk.authorized {
if hk.authorized {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Text("HEALTH")

View File

@@ -1039,11 +1039,19 @@ struct ProgramDetailSheet: View {
ForEach(prog.sections) { section in
Section {
if section.exercises.isEmpty {
Text("No exercises — tap + to add")
Text("No exercises — tap + or drag one here")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
.frame(maxWidth: .infinity).padding(.vertical, 10)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: nil)
}
return true
}
} else {
ForEach(section.exercises) { exercise in
HStack(spacing: 10) {
@@ -1065,6 +1073,15 @@ struct ProgramDetailSheet: View {
.padding(.vertical, 4)
.listRowBackground(AppColors.surface(cs))
.listRowSeparator(.hidden)
.draggable(exercise.id.uuidString)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: exercise.id)
}
return true
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id)
@@ -1093,6 +1110,14 @@ struct ProgramDetailSheet: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.vertical, 2)
.dropDestination(for: String.self) { items, _ in
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
withAnimation(KisaniSpring.snappy) {
vm.moveExercise(programId: programId, exerciseId: id,
toSection: section.id, before: nil)
}
return true
}
} header: {
HStack {