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"?>
|
<?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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
@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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)" : "--"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user