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:
Binary file not shown.
@@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -27,8 +26,7 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -40,8 +38,6 @@
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -63,8 +59,6 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -82,8 +76,6 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
@@ -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
|
||||
|
||||
156
KisaniCal/Managers/HealthKitManager.swift
Normal file
156
KisaniCal/Managers/HealthKitManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)" : "--"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user