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:
@@ -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