feat: HealthKit workout sync, confirmation notification, health stats on Today dashboard

- HealthKitManager: add fetchWorkoutDates(from:to:) to read past workouts from Apple Health
- WorkoutViewModel: syncFromHealthKit() merges HealthKit workout dates into streak (runs on every foreground); checkPendingHealthConfirm() picks up confirmed workouts from notification action
- NotificationManager: WORKOUT_CONFIRM category with 'Yes, log it' action; scheduleWorkoutConfirm() fires weekly on scheduled workout days at configurable time (default 8pm); LOG_WORKOUT action writes pendingConfirm to UserDefaults
- SettingsView (Workout Settings): new HEALTH SYNC section with 'Sync now' button and 'Workout confirmation reminder' toggle + time picker
- TodayView: TodayHealthStrip shows steps, active calories, resting HR, and workout streak in a 4-pill row below the date header (only shown when HealthKit is authorized); refreshes on appear and foreground
- FAB: Matrix FAB padding fixed to match Today/Calendar position (.bottom 10)

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-02 13:10:32 +03:00
parent 0647583036
commit e5bd739499
9 changed files with 385 additions and 48 deletions

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1500" LastUpgradeVersion = "2620"
version = "1.7"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES" buildImplicitDependencies = "YES">
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -27,8 +26,7 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES">
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -40,8 +38,6 @@
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
</Testables> </Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
@@ -63,8 +59,6 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -82,8 +76,6 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -91,8 +91,10 @@ struct ContentView: View {
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
if phase == .active { if phase == .active {
taskVM.reload() taskVM.reload()
workoutVM.checkPendingHealthConfirm()
CloudSyncManager.shared.backupSettings() CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task { await workoutVM.syncFromHealthKit() }
} }
} }
.onChange(of: taskVM.tasks) { _ in .onChange(of: taskVM.tasks) { _ in

View File

@@ -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<HKObjectType> {
let ids: [HKQuantityTypeIdentifier] = [
.stepCount, .activeEnergyBurned, .restingHeartRate, .bodyMass, .height
]
var set: Set<HKObjectType> = Set(ids.compactMap { HKQuantityType.quantityType(forIdentifier: $0) })
set.insert(HKObjectType.workoutType())
return set
}
private var shareTypes: Set<HKSampleType> {
var set: Set<HKSampleType> = [HKObjectType.workoutType()]
if let bm = HKQuantityType.quantityType(forIdentifier: .bodyMass) { set.insert(bm) }
return set
}
}

View File

@@ -7,12 +7,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
@Published var isAuthorized = false @Published var isAuthorized = false
private let center = UNUserNotificationCenter.current() private let center = UNUserNotificationCenter.current()
private let workoutPrefix = "kisani.workout.weekday" private let workoutPrefix = "kisani.workout.weekday"
private let checkInPrefix = "kisani.workout.checkin" private let checkInPrefix = "kisani.workout.checkin"
private let tasksId = "kisani.tasks.evening" private let confirmPrefix = "kisani.workout.confirm"
private let tasksId = "kisani.tasks.evening"
private static let categoryId = "TASK_REMINDER" private static let categoryId = "TASK_REMINDER"
private static let actionMarkId = "MARK_COMPLETE" private static let actionMarkId = "MARK_COMPLETE"
private static let confirmCatId = "WORKOUT_CONFIRM"
private static let logWorkoutActId = "LOG_WORKOUT"
override private init() { override private init() {
super.init() super.init()
@@ -22,18 +25,16 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
} }
private func registerCategories() { private func registerCategories() {
let markAction = UNNotificationAction( let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: [])
identifier: Self.actionMarkId, let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction],
title: "Mark Complete", intentIdentifiers: [], options: .customDismissAction)
options: []
) let logAction = UNNotificationAction(identifier: Self.logWorkoutActId,
let category = UNNotificationCategory( title: "Yes, log it ✓", options: [])
identifier: Self.categoryId, let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction],
actions: [markAction], intentIdentifiers: [], options: .customDismissAction)
intentIdentifiers: [],
options: .customDismissAction center.setNotificationCategories([taskCat, confirmCat])
)
center.setNotificationCategories([category])
} }
// MARK: - Complete task directly in UserDefaults (called from background) // MARK: - Complete task directly in UserDefaults (called from background)
@@ -84,6 +85,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|| settings.authorizationStatus == .provisional || settings.authorizationStatus == .provisional
guard ok else { return } guard ok else { return }
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress)
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
self.scheduleTasks(snapshot: tasks) self.scheduleTasks(snapshot: tasks)
self.updateBadge(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 // MARK: - Per-task reminders + evening check-in
private func scheduleTasks(snapshot: [TaskItem]) { private func scheduleTasks(snapshot: [TaskItem]) {
@@ -253,11 +285,14 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
switch response.actionIdentifier { switch response.actionIdentifier {
case Self.actionMarkId: case Self.actionMarkId:
// Swiped action: mark complete, don't open app
if let taskId { markTaskComplete(taskId: taskId, userId: userId) } 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: default:
// Tapped notification body
if let taskId { markTaskComplete(taskId: taskId, userId: userId) } if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
Task { @MainActor in Task { @MainActor in
switch deeplink { switch deeplink {

View File

@@ -310,6 +310,35 @@ class WorkoutViewModel: ObservableObject {
sessionStartDate = nil 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) { func seedStreak(days: Int) {
let cal = Calendar.current let cal = Calendar.current
var newDates = Set(workoutDates) var newDates = Set(workoutDates)

View File

@@ -129,11 +129,15 @@ struct MatrixView: View {
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding(.horizontal, 12) .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) .frame(maxHeight: .infinity)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showAddTask) { .sheet(isPresented: $showAddTask) {
AddTaskSheet() AddTaskSheet(prefilledQuadrant: showDrill ? (drillQuadrant ?? .urgent) : .urgent)
.environmentObject(taskVM) .environmentObject(taskVM)
.presentationDetents([.height(150)]) .presentationDetents([.height(150)])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
@@ -147,6 +151,7 @@ struct MatrixView: View {
if let q = drillQuadrant { if let q = drillQuadrant {
QuadrantDetailView(quadrant: q) QuadrantDetailView(quadrant: q)
.environmentObject(taskVM) .environmentObject(taskVM)
.toolbar(.visible, for: .navigationBar)
} }
} }
} // NavigationStack } // NavigationStack
@@ -442,7 +447,6 @@ struct QuadrantDetailView: View {
let quadrant: Quadrant let quadrant: Quadrant
@EnvironmentObject var taskVM: TaskViewModel @EnvironmentObject var taskVM: TaskViewModel
@Environment(\.colorScheme) var cs @Environment(\.colorScheme) var cs
@State private var showAddTask = false
@State private var editingTask: TaskItem? = nil @State private var editingTask: TaskItem? = nil
@State private var overdueExpanded = true @State private var overdueExpanded = true
@State private var laterExpanded = true @State private var laterExpanded = true
@@ -535,19 +539,10 @@ struct QuadrantDetailView: View {
.padding(.top, 16) .padding(.top, 16)
} }
FAB { showAddTask = true }
.padding(.trailing, 20)
.padding(.bottom, 10)
} }
.background(AppColors.background(cs).ignoresSafeArea()) .background(AppColors.background(cs).ignoresSafeArea())
.navigationTitle(quadrant.matrixLabel) .navigationTitle(quadrant.matrixLabel)
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.sheet(isPresented: $showAddTask) {
AddTaskSheet(prefilledQuadrant: quadrant)
.environmentObject(taskVM)
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.sheet(item: $editingTask) { task in .sheet(item: $editingTask) { task in
TaskEditSheet(task: task) TaskEditSheet(task: task)
.environmentObject(taskVM) .environmentObject(taskVM)

View File

@@ -165,7 +165,7 @@ struct SettingsView: View {
.sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) } .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: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).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: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) } .sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
@@ -583,6 +583,7 @@ private struct BackupSheet: View {
// MARK: - Workout Settings Sheet // MARK: - Workout Settings Sheet
private struct WorkoutSettingsSheet: View { private struct WorkoutSettingsSheet: View {
@EnvironmentObject var workoutVM: WorkoutViewModel
@Binding var unit: Int @Binding var unit: Int
@Binding var sets: Int @Binding var sets: Int
@Binding var reps: Int @Binding var reps: Int
@@ -591,7 +592,10 @@ private struct WorkoutSettingsSheet: View {
@AppStorage("workoutReminderEnabled") private var workoutReminderEnabled: Bool = true @AppStorage("workoutReminderEnabled") private var workoutReminderEnabled: Bool = true
@AppStorage("workoutHour") private var workoutHour: Int = 18 @AppStorage("workoutHour") private var workoutHour: Int = 18
@AppStorage("workoutMinute") private var workoutMinute: Int = 0 @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("checkInHour") private var checkInHour: Int = 19
@AppStorage("checkInMinute") private var checkInMinute: Int = 30 @AppStorage("checkInMinute") private var checkInMinute: Int = 30
@AppStorage("streakGoal") private var streakGoal: Int = 5 @AppStorage("streakGoal") private var streakGoal: Int = 5
@@ -599,8 +603,9 @@ private struct WorkoutSettingsSheet: View {
@State private var bwStr = "" @State private var bwStr = ""
@State private var htStr = "" @State private var htStr = ""
@State private var workoutTime: Date = Date() @State private var workoutTime: Date = Date()
@State private var checkInTime: Date = Date() @State private var checkInTime: Date = Date()
@State private var confirmTime: Date = Date()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -671,6 +676,67 @@ private struct WorkoutSettingsSheet: View {
.animation(KisaniSpring.snappy, value: workoutCheckInEnabled) .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 // Streak Goal
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("STREAK GOAL").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs)) 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() workoutTime = Calendar.current.date(from: wc) ?? Date()
var cc = today; cc.hour = checkInHour; cc.minute = checkInMinute var cc = today; cc.hour = checkInHour; cc.minute = checkInMinute
checkInTime = Calendar.current.date(from: cc) ?? Date() checkInTime = Calendar.current.date(from: cc) ?? Date()
var cf = today; cf.hour = workoutConfirmHour; cf.minute = workoutConfirmMinute
confirmTime = Calendar.current.date(from: cf) ?? Date()
} }
} }
} }

View File

@@ -11,6 +11,7 @@ struct TodayView: View {
@State private var showWorkoutSession = false @State private var showWorkoutSession = false
@AppStorage("todayHideCompleted") private var hideCompleted = false @AppStorage("todayHideCompleted") private var hideCompleted = false
@ObservedObject private var tm = TutorialManager.shared @ObservedObject private var tm = TutorialManager.shared
@ObservedObject private var hk = HealthKitManager.shared
private let dateFmt: DateFormatter = { private let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
@@ -69,6 +70,13 @@ struct TodayView: View {
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.bottom, 8) .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) // Overdue card (prominent, above timeline)
if !taskVM.overdueTasks.isEmpty { if !taskVM.overdueTasks.isEmpty {
OverdueCard( OverdueCard(
@@ -173,9 +181,13 @@ struct TodayView: View {
.onAppear { .onAppear {
calStore.refreshStatus() calStore.refreshStatus()
calStore.fetchMonth(containing: Date()) calStore.fetchMonth(containing: Date())
if hk.authorized { Task { await hk.refresh() } }
} }
.onChange(of: scenePhase) { phase in .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) .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)" : "--"
}
}