diff --git a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate index 92bd29e..4784b70 100644 Binary files a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate and b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme index 9db359c..675309e 100644 --- a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme +++ b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2620" + version = "1.3"> + buildImplicitDependencies = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 6d0639d..1e3b7d9 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -91,8 +91,10 @@ struct ContentView: View { .onChange(of: scenePhase) { phase in if phase == .active { taskVM.reload() + workoutVM.checkPendingHealthConfirm() CloudSyncManager.shared.backupSettings() NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) + Task { await workoutVM.syncFromHealthKit() } } } .onChange(of: taskVM.tasks) { _ in diff --git a/KisaniCal/Managers/HealthKitManager.swift b/KisaniCal/Managers/HealthKitManager.swift new file mode 100644 index 0000000..7471b2b --- /dev/null +++ b/KisaniCal/Managers/HealthKitManager.swift @@ -0,0 +1,156 @@ +import HealthKit +import SwiftUI + +@MainActor +final class HealthKitManager: ObservableObject { + static let shared = HealthKitManager() + private let store = HKHealthStore() + + @Published var authorized = false + @Published var stepsToday: Int = 0 + @Published var activeCaloriesToday: Int = 0 + @Published var restingHeartRate: Int = 0 + @Published var workoutsThisWeek: Int = 0 + + var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() } + + private init() {} + + // MARK: - Auth + + func requestAuthorization() async -> Bool { + guard isAvailable else { return false } + do { + try await store.requestAuthorization(toShare: shareTypes, read: readTypes) + authorized = true + await refresh() + return true + } catch { + return false + } + } + + // MARK: - Refresh all stats + + func refresh() async { + await withTaskGroup(of: Void.self) { g in + g.addTask { await self.fetchSteps() } + g.addTask { await self.fetchCalories() } + g.addTask { await self.fetchHeartRate() } + g.addTask { await self.fetchWorkoutsThisWeek() } + } + } + + // MARK: - Read workout dates from Health (for streak sync) + + func fetchWorkoutDates(from start: Date, to end: Date) async -> [String] { + guard authorized, isAvailable else { return [] } + let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" + let cal = Calendar.current + let datePred = HKQuery.predicateForSamples(withStart: start, end: end) + let durationPred = HKQuery.predicateForWorkouts(with: .greaterThan, duration: 60) + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, durationPred]) + let workouts: [HKWorkout] = await withCheckedContinuation { cont in + let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), + predicate: pred, limit: HKObjectQueryNoLimit, + sortDescriptors: nil) { _, samples, _ in + cont.resume(returning: (samples as? [HKWorkout]) ?? []) + } + store.execute(q) + } + let dates = Set(workouts.map { w -> String in + fmt.string(from: cal.startOfDay(for: w.startDate)) + }) + return Array(dates) + } + + // MARK: - Save workout to Health + + func saveWorkout(start: Date, end: Date) async { + guard authorized, isAvailable else { return } + let workout = HKWorkout( + activityType: .traditionalStrengthTraining, + start: start, + end: end + ) + do { try await store.save(workout) } catch {} + } + + // MARK: - Fetch helpers + + private func fetchSteps() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return } + let start = Calendar.current.startOfDay(for: Date()) + let pred = HKQuery.predicateForSamples(withStart: start, end: Date()) + let val = await sumQuery(type: type, unit: .count(), predicate: pred) + stepsToday = Int(val) + } + + private func fetchCalories() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned) else { return } + let start = Calendar.current.startOfDay(for: Date()) + let pred = HKQuery.predicateForSamples(withStart: start, end: Date()) + let val = await sumQuery(type: type, unit: .kilocalorie(), predicate: pred) + activeCaloriesToday = Int(val) + } + + private func fetchHeartRate() async { + guard let type = HKQuantityType.quantityType(forIdentifier: .restingHeartRate) else { return } + let val = await latestQuery(type: type, unit: HKUnit(from: "count/min")) + restingHeartRate = Int(val) + } + + private func fetchWorkoutsThisWeek() async { + let cal = Calendar.current + guard let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) else { return } + let datePred = HKQuery.predicateForSamples(withStart: weekStart, end: Date()) + let durationPred = HKQuery.predicateForWorkouts(with: .greaterThan, duration: 0) + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, durationPred]) + + let count: Int = await withCheckedContinuation { cont in + let q = HKSampleQuery(sampleType: HKObjectType.workoutType(), predicate: pred, + limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, samples, _ in + cont.resume(returning: samples?.count ?? 0) + } + store.execute(q) + } + workoutsThisWeek = count + } + + private func sumQuery(type: HKQuantityType, unit: HKUnit, predicate: NSPredicate) async -> Double { + await withCheckedContinuation { cont in + let q = HKStatisticsQuery(quantityType: type, quantitySamplePredicate: predicate, options: .cumulativeSum) { _, stats, _ in + cont.resume(returning: stats?.sumQuantity()?.doubleValue(for: unit) ?? 0) + } + store.execute(q) + } + } + + private func latestQuery(type: HKQuantityType, unit: HKUnit) async -> Double { + await withCheckedContinuation { cont in + let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + let q = HKSampleQuery(sampleType: type, predicate: nil, limit: 1, sortDescriptors: [sort]) { _, samples, _ in + let val = (samples?.first as? HKQuantitySample)?.quantity.doubleValue(for: unit) ?? 0 + cont.resume(returning: val) + } + store.execute(q) + } + } + + // MARK: - Type sets + + private var readTypes: Set { + let ids: [HKQuantityTypeIdentifier] = [ + .stepCount, .activeEnergyBurned, .restingHeartRate, .bodyMass, .height + ] + var set: Set = Set(ids.compactMap { HKQuantityType.quantityType(forIdentifier: $0) }) + set.insert(HKObjectType.workoutType()) + return set + } + + private var shareTypes: Set { + var set: Set = [HKObjectType.workoutType()] + if let bm = HKQuantityType.quantityType(forIdentifier: .bodyMass) { set.insert(bm) } + return set + } +} diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index b1424fd..238cabf 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -7,12 +7,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable @Published var isAuthorized = false private let center = UNUserNotificationCenter.current() - private let workoutPrefix = "kisani.workout.weekday" - private let checkInPrefix = "kisani.workout.checkin" - private let tasksId = "kisani.tasks.evening" + private let workoutPrefix = "kisani.workout.weekday" + private let checkInPrefix = "kisani.workout.checkin" + private let confirmPrefix = "kisani.workout.confirm" + private let tasksId = "kisani.tasks.evening" - private static let categoryId = "TASK_REMINDER" - private static let actionMarkId = "MARK_COMPLETE" + private static let categoryId = "TASK_REMINDER" + private static let actionMarkId = "MARK_COMPLETE" + private static let confirmCatId = "WORKOUT_CONFIRM" + private static let logWorkoutActId = "LOG_WORKOUT" override private init() { super.init() @@ -22,18 +25,16 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable } private func registerCategories() { - let markAction = UNNotificationAction( - identifier: Self.actionMarkId, - title: "Mark Complete", - options: [] - ) - let category = UNNotificationCategory( - identifier: Self.categoryId, - actions: [markAction], - intentIdentifiers: [], - options: .customDismissAction - ) - center.setNotificationCategories([category]) + let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: []) + let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction], + intentIdentifiers: [], options: .customDismissAction) + + let logAction = UNNotificationAction(identifier: Self.logWorkoutActId, + title: "Yes, log it ✓", options: []) + let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction], + intentIdentifiers: [], options: .customDismissAction) + + center.setNotificationCategories([taskCat, confirmCat]) } // MARK: - Complete task directly in UserDefaults (called from background) @@ -84,6 +85,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable || settings.authorizationStatus == .provisional guard ok else { return } self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) + self.scheduleWorkoutConfirm(schedule: schedule, programs: programs) self.scheduleTasks(snapshot: tasks) self.updateBadge(snapshot: tasks) } @@ -152,6 +154,36 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable } } + // MARK: - Workout completion confirmation + + private func scheduleWorkoutConfirm(schedule: [Int: UUID], programs: [WorkoutProgram]) { + let ud = UserDefaults.standard + guard ud.object(forKey: "workoutConfirmEnabled") as? Bool ?? false else { + center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" }) + return + } + let cHour = ud.object(forKey: "workoutConfirmHour") as? Int ?? 20 + let cMinute = ud.object(forKey: "workoutConfirmMinute") as? Int ?? 0 + center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" }) + + for (weekday, pid) in schedule { + guard let program = programs.first(where: { $0.id == pid }) else { continue } + let content = UNMutableNotificationContent() + content.title = "Did you work out today?" + content.body = "\(program.name) — tap 'Yes, log it' to record your streak, or dismiss if you skipped." + content.sound = .default + content.categoryIdentifier = Self.confirmCatId + content.userInfo = ["deeplink": "workout"] + var comps = DateComponents() + comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute + center.add(UNNotificationRequest( + identifier: "\(confirmPrefix).\(weekday)", + content: content, + trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + )) + } + } + // MARK: - Per-task reminders + evening check-in private func scheduleTasks(snapshot: [TaskItem]) { @@ -253,11 +285,14 @@ extension NotificationManager: UNUserNotificationCenterDelegate { switch response.actionIdentifier { case Self.actionMarkId: - // Swiped action: mark complete, don't open app if let taskId { markTaskComplete(taskId: taskId, userId: userId) } + 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") + default: - // Tapped notification body if let taskId { markTaskComplete(taskId: taskId, userId: userId) } Task { @MainActor in switch deeplink { diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index be3efdd..1960d91 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -310,6 +310,35 @@ class WorkoutViewModel: ObservableObject { sessionStartDate = nil } + // Pull completed workout dates from Apple Health and merge into local streak + @MainActor + func syncFromHealthKit() async { + guard HealthKitManager.shared.authorized else { return } + guard let ninetyDaysAgo = Calendar.current.date(byAdding: .day, value: -90, to: Date()) else { return } + let hkDates = await HealthKitManager.shared.fetchWorkoutDates(from: ninetyDaysAgo, to: Date()) + guard !hkDates.isEmpty else { return } + var newSet = Set(workoutDates); var updated = false + for d in hkDates where !newSet.contains(d) { newSet.insert(d); updated = true } + if updated { workoutDates = Array(newSet); save() } + } + + // Called on foreground — picks up "Yes I did it" tapped in notification + @MainActor + func checkPendingHealthConfirm() { + let key = "kisani.workout.pendingConfirm" + guard let dateStr = UserDefaults.standard.string(forKey: key) else { return } + UserDefaults.standard.removeObject(forKey: key) + guard !workoutDates.contains(dateStr) else { return } + workoutDates.append(dateStr) + save() + let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd" + if let date = fmt.date(from: dateStr) { + let s = Calendar.current.startOfDay(for: date) + let e = Calendar.current.date(byAdding: .hour, value: 1, to: s) ?? s + Task { await HealthKitManager.shared.saveWorkout(start: s, end: e) } + } + } + func seedStreak(days: Int) { let cal = Calendar.current var newDates = Set(workoutDates) diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index f9a1750..de39543 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -129,11 +129,15 @@ struct MatrixView: View { } .frame(maxHeight: .infinity) .padding(.horizontal, 12) - .padding(.bottom, 8) + .padding(.bottom, 4) + + // Explicit clearance so grid stops above custom tab bar + Color.clear.frame(height: 76) } .frame(maxHeight: .infinity) + .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showAddTask) { - AddTaskSheet() + AddTaskSheet(prefilledQuadrant: showDrill ? (drillQuadrant ?? .urgent) : .urgent) .environmentObject(taskVM) .presentationDetents([.height(150)]) .presentationDragIndicator(.visible) @@ -147,6 +151,7 @@ struct MatrixView: View { if let q = drillQuadrant { QuadrantDetailView(quadrant: q) .environmentObject(taskVM) + .toolbar(.visible, for: .navigationBar) } } } // NavigationStack @@ -442,7 +447,6 @@ struct QuadrantDetailView: View { let quadrant: Quadrant @EnvironmentObject var taskVM: TaskViewModel @Environment(\.colorScheme) var cs - @State private var showAddTask = false @State private var editingTask: TaskItem? = nil @State private var overdueExpanded = true @State private var laterExpanded = true @@ -535,19 +539,10 @@ struct QuadrantDetailView: View { .padding(.top, 16) } - FAB { showAddTask = true } - .padding(.trailing, 20) - .padding(.bottom, 10) } .background(AppColors.background(cs).ignoresSafeArea()) .navigationTitle(quadrant.matrixLabel) .navigationBarTitleDisplayMode(.large) - .sheet(isPresented: $showAddTask) { - AddTaskSheet(prefilledQuadrant: quadrant) - .environmentObject(taskVM) - .presentationDetents([.height(150)]) - .presentationDragIndicator(.visible) - } .sheet(item: $editingTask) { task in TaskEditSheet(task: task) .environmentObject(taskVM) diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index c6c75da..c95f538 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -165,7 +165,7 @@ struct SettingsView: View { .sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) } - .sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).presentationDetents([.large]).presentationDragIndicator(.visible) } + .sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) } .sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) } @@ -583,6 +583,7 @@ private struct BackupSheet: View { // MARK: - Workout Settings Sheet private struct WorkoutSettingsSheet: View { + @EnvironmentObject var workoutVM: WorkoutViewModel @Binding var unit: Int @Binding var sets: Int @Binding var reps: Int @@ -591,7 +592,10 @@ private struct WorkoutSettingsSheet: View { @AppStorage("workoutReminderEnabled") private var workoutReminderEnabled: Bool = true @AppStorage("workoutHour") private var workoutHour: Int = 18 @AppStorage("workoutMinute") private var workoutMinute: Int = 0 - @AppStorage("workoutCheckInEnabled") private var workoutCheckInEnabled: Bool = false + @AppStorage("workoutCheckInEnabled") private var workoutCheckInEnabled: Bool = false + @AppStorage("workoutConfirmEnabled") private var workoutConfirmEnabled: Bool = false + @AppStorage("workoutConfirmHour") private var workoutConfirmHour: Int = 20 + @AppStorage("workoutConfirmMinute") private var workoutConfirmMinute: Int = 0 @AppStorage("checkInHour") private var checkInHour: Int = 19 @AppStorage("checkInMinute") private var checkInMinute: Int = 30 @AppStorage("streakGoal") private var streakGoal: Int = 5 @@ -599,8 +603,9 @@ private struct WorkoutSettingsSheet: View { @State private var bwStr = "" @State private var htStr = "" - @State private var workoutTime: Date = Date() - @State private var checkInTime: Date = Date() + @State private var workoutTime: Date = Date() + @State private var checkInTime: Date = Date() + @State private var confirmTime: Date = Date() var body: some View { VStack(spacing: 0) { @@ -671,6 +676,67 @@ private struct WorkoutSettingsSheet: View { .animation(KisaniSpring.snappy, value: workoutCheckInEnabled) } + // ── Workout Confirmation ── + VStack(alignment: .leading, spacing: 8) { + Text("HEALTH SYNC").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) + VStack(spacing: 0) { + HStack(spacing: 11) { + Image(systemName: "figure.strengthtraining.traditional") + .font(.system(size: 13)).foregroundColor(.white) + .frame(width: 28, height: 28).background(AppColors.blue).cornerRadius(8) + VStack(alignment: .leading, spacing: 1) { + Text("Sync streak from Apple Health").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Text("Import past workouts into your streak") + .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) + } + Spacer() + Button { + Task { await workoutVM.syncFromHealthKit() } + } label: { + Text("Sync now") + .font(AppFonts.mono(10, weight: .bold)) + .foregroundColor(AppColors.blue) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(AppColors.blueSoft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14).padding(.vertical, 12) + + Divider().background(AppColors.border(cs)).padding(.leading, 53) + + HStack(spacing: 11) { + Image(systemName: "bell.badge.fill") + .font(.system(size: 13)).foregroundColor(.white) + .frame(width: 28, height: 28).background(AppColors.accent).cornerRadius(8) + Text("Workout confirmation reminder").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) + Spacer() + Toggle("", isOn: $workoutConfirmEnabled).labelsHidden().tint(AppColors.accent) + } + .padding(.horizontal, 14).padding(.vertical, 11) + + if workoutConfirmEnabled { + Divider().background(AppColors.border(cs)).padding(.leading, 53) + HStack { + Text("Ask me at") + .font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)) + Spacer() + DatePicker("", selection: $confirmTime, displayedComponents: .hourAndMinute) + .labelsHidden().tint(AppColors.accent) + .onChange(of: confirmTime) { v in + let c = Calendar.current.dateComponents([.hour, .minute], from: v) + workoutConfirmHour = c.hour ?? 20 + workoutConfirmMinute = c.minute ?? 0 + } + } + .padding(.horizontal, 14).padding(.vertical, 8) + } + } + .cardStyle() + .animation(KisaniSpring.snappy, value: workoutConfirmEnabled) + } + // ── Streak Goal ── VStack(alignment: .leading, spacing: 8) { Text("STREAK GOAL").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) @@ -788,6 +854,8 @@ private struct WorkoutSettingsSheet: View { workoutTime = Calendar.current.date(from: wc) ?? Date() var cc = today; cc.hour = checkInHour; cc.minute = checkInMinute checkInTime = Calendar.current.date(from: cc) ?? Date() + var cf = today; cf.hour = workoutConfirmHour; cf.minute = workoutConfirmMinute + confirmTime = Calendar.current.date(from: cf) ?? Date() } } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index c5c411a..e69889c 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -11,6 +11,7 @@ struct TodayView: View { @State private var showWorkoutSession = false @AppStorage("todayHideCompleted") private var hideCompleted = false @ObservedObject private var tm = TutorialManager.shared + @ObservedObject private var hk = HealthKitManager.shared private let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f @@ -69,6 +70,13 @@ struct TodayView: View { .padding(.horizontal, 18) .padding(.bottom, 8) + // ── Health stats strip ── + if hk.authorized { + TodayHealthStrip(hk: hk, workoutVM: workoutVM) + .padding(.horizontal, 18) + .padding(.bottom, 10) + } + // ── Overdue card (prominent, above timeline) ── if !taskVM.overdueTasks.isEmpty { OverdueCard( @@ -173,9 +181,13 @@ struct TodayView: View { .onAppear { calStore.refreshStatus() calStore.fetchMonth(containing: Date()) + if hk.authorized { Task { await hk.refresh() } } } .onChange(of: scenePhase) { phase in - if phase == .active { calStore.refreshStatus() } + if phase == .active { + calStore.refreshStatus() + if hk.authorized { Task { await hk.refresh() } } + } } } } @@ -2190,3 +2202,51 @@ struct DotGridProgress: View { .animation(KisaniSpring.entrance, value: progress) } } + +// MARK: - Today Health Strip + +struct TodayHealthStrip: View { + @ObservedObject var hk: HealthKitManager + @ObservedObject var workoutVM: WorkoutViewModel + @Environment(\.colorScheme) var cs + + var body: some View { + HStack(spacing: 8) { + statPill(icon: "figure.walk", value: stepsLabel, label: "steps", color: AppColors.blue) + statPill(icon: "flame.fill", value: calsLabel, label: "kcal", color: AppColors.accent) + statPill(icon: "heart.fill", value: hrLabel, label: "bpm", color: .red) + statPill(icon: "dumbbell.fill", value: "\(workoutVM.streakDays)", label: "streak", color: AppColors.green) + } + } + + private func statPill(icon: String, value: String, label: String, color: Color) -> some View { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(color) + VStack(alignment: .leading, spacing: 0) { + Text(value) + .font(AppFonts.mono(12, weight: .bold)) + .foregroundColor(AppColors.text(cs)) + Text(label) + .font(AppFonts.mono(8)) + .foregroundColor(AppColors.text3(cs)) + } + } + .padding(.horizontal, 10).padding(.vertical, 7) + .frame(maxWidth: .infinity) + .background(AppColors.surface(cs)) + .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) + .overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1)) + } + + private var stepsLabel: String { + hk.stepsToday >= 1000 + ? String(format: "%.1fk", Double(hk.stepsToday) / 1000) + : "\(hk.stepsToday)" + } + private var calsLabel: String { "\(hk.activeCaloriesToday)" } + private var hrLabel: String { + hk.restingHeartRate > 0 ? "\(hk.restingHeartRate)" : "--" + } +}