Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0979af288d | ||
|
|
acdf66bd84 |
@@ -1,5 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>SwimBuddy needs access to your health data to track your swim workouts.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>SwimBuddy needs to save your swim workout data to Health.</string>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -415,6 +415,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"End Workout" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"kcal" : {
|
"kcal" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -838,6 +841,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Pause" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Recent" : {
|
"Recent" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -966,6 +972,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Rest %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Resume" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Saved Workouts" : {
|
"Saved Workouts" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
|
|||||||
@@ -4,18 +4,22 @@ struct ActiveWorkoutView: View {
|
|||||||
@ObservedObject var workoutManager: WorkoutManager
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
TabView {
|
||||||
if workoutManager.isResting {
|
if workoutManager.isResting {
|
||||||
RestView(workoutManager: workoutManager)
|
RestPage(workoutManager: workoutManager)
|
||||||
} else {
|
} else {
|
||||||
SwimView(workoutManager: workoutManager)
|
ActivePage(workoutManager: workoutManager)
|
||||||
}
|
}
|
||||||
|
WorkoutOverviewPage(workoutManager: workoutManager)
|
||||||
|
ControlsPage(workoutManager: workoutManager)
|
||||||
}
|
}
|
||||||
.padding()
|
.tabViewStyle(.verticalPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SwimView: View {
|
// MARK: - Page 1: Active / Rest
|
||||||
|
|
||||||
|
struct ActivePage: View {
|
||||||
@ObservedObject var workoutManager: WorkoutManager
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -82,24 +86,6 @@ struct SwimView: View {
|
|||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
workoutManager.completeLap()
|
|
||||||
} label: {
|
|
||||||
Text("Complete Lap")
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.cyan)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("End") {
|
|
||||||
workoutManager.endWorkout()
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +96,7 @@ struct SwimView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RestView: View {
|
struct RestPage: View {
|
||||||
@ObservedObject var workoutManager: WorkoutManager
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -141,8 +127,8 @@ struct RestView: View {
|
|||||||
}
|
}
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "flame.fill")
|
Image(systemName: "flame.fill")
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
|
.font(.caption)
|
||||||
Text("\(Int(workoutManager.activeCalories))kcal")
|
Text("\(Int(workoutManager.activeCalories))kcal")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
@@ -157,3 +143,97 @@ struct RestView: View {
|
|||||||
return String(format: "%d:%02d", mins, secs)
|
return String(format: "%d:%02d", mins, secs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Page 2: Workout Overview
|
||||||
|
|
||||||
|
struct WorkoutOverviewPage: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if workoutManager.isResting {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "pause.circle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Rest \(formatTime(workoutManager.restTimeRemaining))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let workout = workoutManager.selectedWorkout {
|
||||||
|
ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in
|
||||||
|
HStack {
|
||||||
|
Circle()
|
||||||
|
.fill(i < workoutManager.currentSetIndex ? .green :
|
||||||
|
i == workoutManager.currentSetIndex ? .cyan : .gray.opacity(0.4))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text("\(i + 1). \(set.stroke)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(i <= workoutManager.currentSetIndex ? .primary : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(set.repCount)x\(set.distancePerRep)m")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||||
|
let mins = Int(seconds) / 60
|
||||||
|
let secs = Int(seconds) % 60
|
||||||
|
return String(format: "%d:%02d", mins, secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Page 3: Controls
|
||||||
|
|
||||||
|
struct ControlsPage: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if workoutManager.isPaused {
|
||||||
|
workoutManager.resumeWorkout()
|
||||||
|
} else {
|
||||||
|
workoutManager.pauseWorkout()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: workoutManager.isPaused ? "play.fill" : "pause.fill")
|
||||||
|
.font(.title2)
|
||||||
|
Text(workoutManager.isPaused ? "Resume" : "Pause")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
workoutManager.endWorkout()
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
.font(.title2)
|
||||||
|
Text("End Workout")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import WatchKit
|
|||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
import CoreMotion
|
import CoreMotion
|
||||||
|
|
||||||
class WorkoutManager: ObservableObject {
|
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
||||||
@Published var isWorkoutActive = false
|
@Published var isWorkoutActive = false
|
||||||
@Published var savedWorkouts: [StoredWorkout] = []
|
@Published var savedWorkouts: [StoredWorkout] = []
|
||||||
@Published var completedWorkouts: [StoredWorkout] = []
|
@Published var completedWorkouts: [StoredWorkout] = []
|
||||||
@@ -39,9 +39,42 @@ class WorkoutManager: ObservableObject {
|
|||||||
private var wasStationary = false
|
private var wasStationary = false
|
||||||
private var stationarySince: Date?
|
private var stationarySince: Date?
|
||||||
@Published var lapDetected = false
|
@Published var lapDetected = false
|
||||||
|
@Published var healthKitAuthorized = false
|
||||||
|
@Published var isPaused = false
|
||||||
|
|
||||||
init() {
|
override init() {
|
||||||
|
super.init()
|
||||||
healthStore = HKHealthStore()
|
healthStore = HKHealthStore()
|
||||||
|
requestHealthKitAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestHealthKitAuthorization() {
|
||||||
|
guard let healthStore = healthStore else { return }
|
||||||
|
let typesToRead: Set<HKObjectType> = [
|
||||||
|
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
|
||||||
|
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
|
||||||
|
HKObjectType.workoutType()
|
||||||
|
]
|
||||||
|
let typesToWrite: Set<HKSampleType> = [
|
||||||
|
HKObjectType.workoutType()
|
||||||
|
]
|
||||||
|
|
||||||
|
if healthStore.authorizationStatus(for: .workoutType()) == .sharingAuthorized {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.healthKitAuthorized = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
healthStore.requestAuthorization(toShare: typesToWrite, read: typesToRead) { [weak self] success, error in
|
||||||
|
if let error = error {
|
||||||
|
print("HealthKit authorization failed: \(error)")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.healthKitAuthorized = success
|
||||||
|
print("HealthKit authorization: \(success)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Persistence
|
// MARK: - Data Persistence
|
||||||
@@ -106,6 +139,12 @@ class WorkoutManager: ObservableObject {
|
|||||||
func startWorkout() {
|
func startWorkout() {
|
||||||
guard selectedWorkout != nil else { return }
|
guard selectedWorkout != nil else { return }
|
||||||
|
|
||||||
|
if !healthKitAuthorized {
|
||||||
|
print("HealthKit not authorized, requesting...")
|
||||||
|
requestHealthKitAuthorization()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isWorkoutActive = true
|
isWorkoutActive = true
|
||||||
currentSetIndex = 0
|
currentSetIndex = 0
|
||||||
currentRep = 0
|
currentRep = 0
|
||||||
@@ -116,12 +155,14 @@ class WorkoutManager: ObservableObject {
|
|||||||
wasStationary = false
|
wasStationary = false
|
||||||
stationarySince = nil
|
stationarySince = nil
|
||||||
|
|
||||||
startHealthKitWorkout()
|
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
|
||||||
startHeartRateQuery()
|
startHeartRateQuery()
|
||||||
startCalorieQuery()
|
startCalorieQuery()
|
||||||
startTimer()
|
startTimer()
|
||||||
startLapDetection()
|
startLapDetection()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
WKInterfaceDevice.current().enableWaterLock()
|
WKInterfaceDevice.current().enableWaterLock()
|
||||||
|
}
|
||||||
WKInterfaceDevice.current().play(.start)
|
WKInterfaceDevice.current().play(.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +171,7 @@ class WorkoutManager: ObservableObject {
|
|||||||
stopLapDetection()
|
stopLapDetection()
|
||||||
stopHeartRateQuery()
|
stopHeartRateQuery()
|
||||||
stopCalorieQuery()
|
stopCalorieQuery()
|
||||||
|
isPaused = true
|
||||||
WKInterfaceDevice.current().play(.notification)
|
WKInterfaceDevice.current().play(.notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +180,7 @@ class WorkoutManager: ObservableObject {
|
|||||||
startLapDetection()
|
startLapDetection()
|
||||||
startHeartRateQuery()
|
startHeartRateQuery()
|
||||||
startCalorieQuery()
|
startCalorieQuery()
|
||||||
|
isPaused = false
|
||||||
WKInterfaceDevice.current().play(.start)
|
WKInterfaceDevice.current().play(.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,19 +461,26 @@ class WorkoutManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - HealthKit
|
// MARK: - HealthKit
|
||||||
|
|
||||||
private func startHealthKitWorkout() {
|
private func startHealthKitWorkout(poolLength: Double) {
|
||||||
guard let healthStore = healthStore else { return }
|
guard let healthStore = healthStore else { return }
|
||||||
|
|
||||||
let config = HKWorkoutConfiguration()
|
let config = HKWorkoutConfiguration()
|
||||||
config.activityType = .swimming
|
config.activityType = .swimming
|
||||||
config.locationType = .indoor
|
config.locationType = .indoor
|
||||||
|
config.swimmingLocationType = .pool
|
||||||
|
config.lapLength = HKQuantity(unit: .meter(), doubleValue: poolLength)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||||
let workoutBuilder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: nil)
|
let workoutBuilder = activeWorkout!.associatedWorkoutBuilder()
|
||||||
|
workoutBuilder.delegate = self
|
||||||
self.builder = workoutBuilder
|
self.builder = workoutBuilder
|
||||||
activeWorkout?.startActivity(with: Date())
|
activeWorkout?.startActivity(with: Date())
|
||||||
workoutBuilder.beginCollection(withStart: Date()) { _, _ in }
|
workoutBuilder.beginCollection(withStart: Date()) { success, error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to begin collection: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to start HealthKit workout: \(error)")
|
print("Failed to start HealthKit workout: \(error)")
|
||||||
}
|
}
|
||||||
@@ -438,11 +488,40 @@ class WorkoutManager: ObservableObject {
|
|||||||
|
|
||||||
private func endHealthKitWorkout() {
|
private func endHealthKitWorkout() {
|
||||||
activeWorkout?.end()
|
activeWorkout?.end()
|
||||||
builder?.endCollection(withEnd: Date()) { [weak self] _, _ in
|
builder?.endCollection(withEnd: Date()) { [weak self] success, error in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to end collection: \(error)")
|
||||||
|
}
|
||||||
self?.builder?.finishWorkout { _, _ in }
|
self?.builder?.finishWorkout { _, _ in }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - HKLiveWorkoutBuilderDelegate
|
||||||
|
|
||||||
|
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||||
|
for type in collectedTypes {
|
||||||
|
guard let quantityType = type as? HKQuantityType else { continue }
|
||||||
|
|
||||||
|
if let statistics = workoutBuilder.statistics(for: quantityType) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
switch quantityType {
|
||||||
|
case HKQuantityType.quantityType(forIdentifier: .heartRate):
|
||||||
|
let heartRateUnit = HKUnit.count().unitDivided(by: .minute())
|
||||||
|
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
|
||||||
|
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
|
||||||
|
self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
|
||||||
|
// Handle collected events if needed
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Sync to Phone
|
// MARK: - Sync to Phone
|
||||||
|
|
||||||
private func syncCompletedWorkout() {
|
private func syncCompletedWorkout() {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ module.exports = (config) => ({
|
|||||||
type: "watch",
|
type: "watch",
|
||||||
icon: "../../assets/icon.png",
|
icon: "../../assets/icon.png",
|
||||||
deploymentTarget: "10.0",
|
deploymentTarget: "10.0",
|
||||||
frameworks: ["SwiftUI"],
|
frameworks: ["SwiftUI", "HealthKit"],
|
||||||
entitlements: {
|
entitlements: {
|
||||||
"com.apple.security.application-groups":
|
"com.apple.security.application-groups":
|
||||||
config.ios?.entitlements?.[
|
config.ios?.entitlements?.[
|
||||||
"com.apple.security.application-groups"
|
"com.apple.security.application-groups"
|
||||||
] ?? [],
|
] ?? [],
|
||||||
|
"com.apple.developer.healthkit": true,
|
||||||
|
"com.apple.developer.healthkit.access": [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user