Compare commits

..
2 Commits
5 changed files with 213 additions and 35 deletions
+6 -1
View File
@@ -1,5 +1,10 @@
<?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">
<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>
+12
View File
@@ -415,6 +415,9 @@
}
}
}
},
"End Workout" : {
},
"kcal" : {
"extractionState" : "manual",
@@ -838,6 +841,9 @@
}
}
}
},
"Pause" : {
},
"Recent" : {
"extractionState" : "manual",
@@ -966,6 +972,12 @@
}
}
}
},
"Rest %@" : {
},
"Resume" : {
},
"Saved Workouts" : {
"extractionState" : "manual",
+105 -25
View File
@@ -4,18 +4,22 @@ struct ActiveWorkoutView: View {
@ObservedObject var workoutManager: WorkoutManager
var body: some View {
VStack(spacing: 8) {
TabView {
if workoutManager.isResting {
RestView(workoutManager: workoutManager)
RestPage(workoutManager: workoutManager)
} 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
var body: some View {
@@ -82,24 +86,6 @@ struct SwimView: View {
.foregroundColor(.green)
.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
var body: some View {
@@ -141,8 +127,8 @@ struct RestView: View {
}
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.caption)
.foregroundColor(.orange)
.font(.caption)
Text("\(Int(workoutManager.activeCalories))kcal")
.font(.caption)
}
@@ -157,3 +143,97 @@ struct RestView: View {
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()
}
}
}
+86 -7
View File
@@ -5,7 +5,7 @@ import WatchKit
import WatchConnectivity
import CoreMotion
class WorkoutManager: ObservableObject {
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
@Published var isWorkoutActive = false
@Published var savedWorkouts: [StoredWorkout] = []
@Published var completedWorkouts: [StoredWorkout] = []
@@ -39,9 +39,42 @@ class WorkoutManager: ObservableObject {
private var wasStationary = false
private var stationarySince: Date?
@Published var lapDetected = false
@Published var healthKitAuthorized = false
@Published var isPaused = false
init() {
override init() {
super.init()
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
@@ -106,6 +139,12 @@ class WorkoutManager: ObservableObject {
func startWorkout() {
guard selectedWorkout != nil else { return }
if !healthKitAuthorized {
print("HealthKit not authorized, requesting...")
requestHealthKitAuthorization()
return
}
isWorkoutActive = true
currentSetIndex = 0
currentRep = 0
@@ -116,12 +155,14 @@ class WorkoutManager: ObservableObject {
wasStationary = false
stationarySince = nil
startHealthKitWorkout()
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
startHeartRateQuery()
startCalorieQuery()
startTimer()
startLapDetection()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
WKInterfaceDevice.current().enableWaterLock()
}
WKInterfaceDevice.current().play(.start)
}
@@ -130,6 +171,7 @@ class WorkoutManager: ObservableObject {
stopLapDetection()
stopHeartRateQuery()
stopCalorieQuery()
isPaused = true
WKInterfaceDevice.current().play(.notification)
}
@@ -138,6 +180,7 @@ class WorkoutManager: ObservableObject {
startLapDetection()
startHeartRateQuery()
startCalorieQuery()
isPaused = false
WKInterfaceDevice.current().play(.start)
}
@@ -418,19 +461,26 @@ class WorkoutManager: ObservableObject {
// MARK: - HealthKit
private func startHealthKitWorkout() {
private func startHealthKitWorkout(poolLength: Double) {
guard let healthStore = healthStore else { return }
let config = HKWorkoutConfiguration()
config.activityType = .swimming
config.locationType = .indoor
config.swimmingLocationType = .pool
config.lapLength = HKQuantity(unit: .meter(), doubleValue: poolLength)
do {
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
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 {
print("Failed to start HealthKit workout: \(error)")
}
@@ -438,11 +488,40 @@ class WorkoutManager: ObservableObject {
private func endHealthKitWorkout() {
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 }
}
}
// 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
private func syncCompletedWorkout() {
+3 -1
View File
@@ -4,11 +4,13 @@ module.exports = (config) => ({
type: "watch",
icon: "../../assets/icon.png",
deploymentTarget: "10.0",
frameworks: ["SwiftUI"],
frameworks: ["SwiftUI", "HealthKit"],
entitlements: {
"com.apple.security.application-groups":
config.ios?.entitlements?.[
"com.apple.security.application-groups"
] ?? [],
"com.apple.developer.healthkit": true,
"com.apple.developer.healthkit.access": [],
},
});