feat: initial project scaffold with Watch companion app
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "App-Icon-1024x1024@1x.png",
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"platform": "watchos"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "expo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
||||
static let shared = PhoneConnector()
|
||||
private var workoutManager: WorkoutManager?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setup(workoutManager: WorkoutManager) {
|
||||
self.workoutManager = workoutManager
|
||||
if WCSession.isSupported() {
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// Send completed workout data to phone
|
||||
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
|
||||
let message: [String: Any] = [
|
||||
"type": "workoutCompleted",
|
||||
"workoutId": workoutId,
|
||||
"laps": laps,
|
||||
"duration": duration,
|
||||
]
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
WCSession.default.sendMessage(message, replyHandler: nil) { error in
|
||||
print("Failed to send: \(error)")
|
||||
}
|
||||
} else {
|
||||
WCSession.default.transferUserInfo(message)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
print("WatchConnectivity activated: \(activationState.rawValue)")
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.handleMessage(userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMessage(_ message: [String: Any]) {
|
||||
guard let type = message["type"] as? String else { return }
|
||||
|
||||
switch type {
|
||||
case "workoutPlan":
|
||||
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||
let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) {
|
||||
workoutManager?.addWorkoutFromPhone(workout)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var workoutManager = WorkoutManager()
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 16) {
|
||||
if workoutManager.isWorkoutActive {
|
||||
ActiveWorkoutView(workoutManager: workoutManager)
|
||||
} else {
|
||||
WorkoutListView(workoutManager: workoutManager)
|
||||
}
|
||||
}
|
||||
.navigationTitle("SwimBuddy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutListView: View {
|
||||
@ObservedObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if workoutManager.savedWorkouts.isEmpty {
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "figure.pool.swim")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.cyan)
|
||||
Text("No workouts yet")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Create a workout on your iPhone to get started")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
} else {
|
||||
Section("Saved Workouts") {
|
||||
ForEach(workoutManager.savedWorkouts) { (workout: StoredWorkout) in
|
||||
Button {
|
||||
workoutManager.selectWorkout(workout)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workout.name ?? "Workout")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text("\(workout.totalDistance)m · \(workout.sets.count) sets")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if workoutManager.completedWorkouts.count > 0 {
|
||||
Section("Recent") {
|
||||
ForEach(workoutManager.completedWorkouts.prefix(3)) { workout in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(workout.name ?? "Workout")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
Text(workout.completedAt ?? "")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
.onAppear {
|
||||
workoutManager.loadSavedWorkouts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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/>
|
||||
</plist>
|
||||
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ActiveWorkoutView: View {
|
||||
@ObservedObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
if workoutManager.isResting {
|
||||
RestView(workoutManager: workoutManager)
|
||||
} else {
|
||||
SwimView(workoutManager: workoutManager)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct SwimView: View {
|
||||
@ObservedObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
if let workout = workoutManager.selectedWorkout,
|
||||
workoutManager.currentSetIndex < workout.sets.count {
|
||||
let currentSet = workout.sets[workoutManager.currentSetIndex]
|
||||
|
||||
Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(currentSet.stroke)
|
||||
.font(.headline)
|
||||
.foregroundColor(.cyan)
|
||||
|
||||
Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)")
|
||||
.font(.subheadline)
|
||||
|
||||
Text("\(currentSet.distancePerRep)m")
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.cyan)
|
||||
}
|
||||
|
||||
Text(formatTime(workoutManager.elapsedSeconds))
|
||||
.font(.system(.title, design: .monospaced))
|
||||
|
||||
if workoutManager.heartRate > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
Text("\(Int(workoutManager.heartRate))")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
workoutManager.completeLap()
|
||||
} label: {
|
||||
Text("Complete Lap")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.cyan)
|
||||
|
||||
Button("End") {
|
||||
workoutManager.endWorkout()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let mins = Int(seconds) / 60
|
||||
let secs = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", mins, secs)
|
||||
}
|
||||
}
|
||||
|
||||
struct RestView: View {
|
||||
@ObservedObject var workoutManager: WorkoutManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Rest")
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(formatTime(workoutManager.restTimeRemaining))
|
||||
.font(.system(size: 44, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.cyan)
|
||||
|
||||
if let workout = workoutManager.selectedWorkout,
|
||||
workoutManager.currentSetIndex + 1 < workout.sets.count {
|
||||
let nextSet = workout.sets[workoutManager.currentSetIndex + 1]
|
||||
Text("Next: \(nextSet.stroke) \(nextSet.distancePerRep)m")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||
let mins = Int(seconds) / 60
|
||||
let secs = Int(seconds) % 60
|
||||
return String(format: "%d:%02d", mins, secs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
import WatchKit
|
||||
import WatchConnectivity
|
||||
|
||||
class WorkoutManager: ObservableObject {
|
||||
@Published var isWorkoutActive = false
|
||||
@Published var savedWorkouts: [StoredWorkout] = []
|
||||
@Published var completedWorkouts: [StoredWorkout] = []
|
||||
@Published var selectedWorkout: StoredWorkout?
|
||||
@Published var currentSetIndex = 0
|
||||
@Published var currentRep = 0
|
||||
@Published var currentLapDistance = 0
|
||||
@Published var elapsedSeconds: TimeInterval = 0
|
||||
@Published var heartRate: Double = 0
|
||||
@Published var isResting = false
|
||||
@Published var restTimeRemaining: TimeInterval = 0
|
||||
|
||||
private var workoutTimer: Timer?
|
||||
private var restTimer: Timer?
|
||||
private var healthStore: HKHealthStore?
|
||||
private var activeWorkout: HKWorkoutSession?
|
||||
private var builder: HKWorkoutBuilder?
|
||||
private var lapStartTime: Date?
|
||||
private var completedLaps: [CompletedLap] = []
|
||||
|
||||
private let suiteName = "group.com.swimbuddy.app"
|
||||
|
||||
init() {
|
||||
healthStore = HKHealthStore()
|
||||
}
|
||||
|
||||
// MARK: - Data Persistence
|
||||
|
||||
func loadSavedWorkouts() {
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
||||
|
||||
if let data = defaults.data(forKey: "savedWorkouts"),
|
||||
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
||||
savedWorkouts = workouts
|
||||
}
|
||||
|
||||
if let data = defaults.data(forKey: "completedWorkouts"),
|
||||
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
||||
completedWorkouts = workouts
|
||||
}
|
||||
}
|
||||
|
||||
func saveWorkoutsToStorage() {
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
||||
if let data = try? JSONEncoder().encode(savedWorkouts) {
|
||||
defaults.set(data, forKey: "savedWorkouts")
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(completedWorkouts) {
|
||||
defaults.set(data, forKey: "completedWorkouts")
|
||||
}
|
||||
}
|
||||
|
||||
func addWorkoutFromPhone(_ plan: StoredWorkout) {
|
||||
savedWorkouts.append(plan)
|
||||
saveWorkoutsToStorage()
|
||||
}
|
||||
|
||||
// MARK: - Workout Selection
|
||||
|
||||
func selectWorkout(_ workout: StoredWorkout) {
|
||||
selectedWorkout = workout
|
||||
}
|
||||
|
||||
// MARK: - Workout Control
|
||||
|
||||
func startWorkout() {
|
||||
guard selectedWorkout != nil else { return }
|
||||
|
||||
isWorkoutActive = true
|
||||
currentSetIndex = 0
|
||||
currentRep = 0
|
||||
elapsedSeconds = 0
|
||||
completedLaps = []
|
||||
|
||||
startHealthKitWorkout()
|
||||
startTimer()
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func pauseWorkout() {
|
||||
workoutTimer?.invalidate()
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
}
|
||||
|
||||
func resumeWorkout() {
|
||||
startTimer()
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
func endWorkout() {
|
||||
workoutTimer?.invalidate()
|
||||
restTimer?.invalidate()
|
||||
isWorkoutActive = false
|
||||
|
||||
endHealthKitWorkout()
|
||||
syncCompletedWorkout()
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
}
|
||||
|
||||
// MARK: - Lap Tracking
|
||||
|
||||
func completeLap() {
|
||||
guard let workout = selectedWorkout else { return }
|
||||
|
||||
let now = Date()
|
||||
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
|
||||
|
||||
let lap = CompletedLap(
|
||||
setPosition: currentSetIndex + 1,
|
||||
repNumber: currentRep + 1,
|
||||
distance: workout.sets[currentSetIndex].distancePerRep,
|
||||
duration: duration,
|
||||
heartRate: Int(heartRate)
|
||||
)
|
||||
completedLaps.append(lap)
|
||||
|
||||
WKInterfaceDevice.current().play(.click)
|
||||
|
||||
advanceToNextRep()
|
||||
}
|
||||
|
||||
private func advanceToNextRep() {
|
||||
guard let workout = selectedWorkout else { return }
|
||||
let currentSet = workout.sets[currentSetIndex]
|
||||
|
||||
if currentRep + 1 < currentSet.repCount {
|
||||
currentRep += 1
|
||||
lapStartTime = Date()
|
||||
} else if currentSetIndex + 1 < workout.sets.count {
|
||||
startRestPeriod()
|
||||
} else {
|
||||
endWorkout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Period
|
||||
|
||||
private func startRestPeriod() {
|
||||
guard let workout = selectedWorkout else { return }
|
||||
let currentSet = workout.sets[currentSetIndex]
|
||||
|
||||
guard currentSet.restType != "none", currentSet.restDuration > 0 else {
|
||||
moveToNextSet()
|
||||
return
|
||||
}
|
||||
|
||||
isResting = true
|
||||
restTimeRemaining = currentSet.restDuration
|
||||
|
||||
WKInterfaceDevice.current().play(.notification)
|
||||
|
||||
restTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.restTimeRemaining -= 1
|
||||
|
||||
if self.restTimeRemaining <= 0 {
|
||||
self.restTimer?.invalidate()
|
||||
self.moveToNextSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveToNextSet() {
|
||||
isResting = false
|
||||
currentSetIndex += 1
|
||||
currentRep = 0
|
||||
lapStartTime = Date()
|
||||
|
||||
if currentSetIndex >= (selectedWorkout?.sets.count ?? 0) {
|
||||
endWorkout()
|
||||
} else {
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timer
|
||||
|
||||
private func startTimer() {
|
||||
lapStartTime = Date()
|
||||
workoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
self?.elapsedSeconds += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HealthKit
|
||||
|
||||
private func startHealthKitWorkout() {
|
||||
guard let healthStore = healthStore else { return }
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .swimming
|
||||
config.locationType = .indoor
|
||||
|
||||
do {
|
||||
activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||
let workoutBuilder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: nil)
|
||||
self.builder = workoutBuilder
|
||||
activeWorkout?.startActivity(with: Date())
|
||||
workoutBuilder.beginCollection(withStart: Date()) { _, _ in }
|
||||
} catch {
|
||||
print("Failed to start HealthKit workout: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endHealthKitWorkout() {
|
||||
activeWorkout?.end()
|
||||
builder?.endCollection(withEnd: Date()) { [weak self] _, _ in
|
||||
self?.builder?.finishWorkout { _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync to Phone
|
||||
|
||||
private func syncCompletedWorkout() {
|
||||
let workoutSummary: [String: Any] = [
|
||||
"type": "workoutCompleted",
|
||||
"workoutId": selectedWorkout?.id ?? "",
|
||||
"laps": completedLaps.map { lap in
|
||||
[
|
||||
"setPosition": lap.setPosition,
|
||||
"repNumber": lap.repNumber,
|
||||
"distance": lap.distance,
|
||||
"duration": lap.duration,
|
||||
"heartRate": lap.heartRate ?? 0,
|
||||
] as [String: Any]
|
||||
},
|
||||
"duration": elapsedSeconds,
|
||||
]
|
||||
|
||||
if let defaults = UserDefaults(suiteName: suiteName) {
|
||||
var completed = completedWorkouts
|
||||
var summary = selectedWorkout
|
||||
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
|
||||
if let s = summary {
|
||||
completed.insert(s, at: 0)
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(completed) {
|
||||
defaults.set(data, forKey: "completedWorkouts")
|
||||
}
|
||||
}
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
WCSession.default.sendMessage(workoutSummary, replyHandler: nil) { error in
|
||||
print("Failed to sync to phone: \(error)")
|
||||
}
|
||||
} else {
|
||||
WCSession.default.transferUserInfo(workoutSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct StoredWorkout: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let poolLength: Double
|
||||
let sets: [StoredSet]
|
||||
var completedAt: String?
|
||||
}
|
||||
|
||||
struct StoredSet: Codable {
|
||||
let stroke: String
|
||||
let distancePerRep: Int
|
||||
let repCount: Int
|
||||
let restType: String
|
||||
let restDuration: TimeInterval
|
||||
}
|
||||
|
||||
struct CompletedLap {
|
||||
let setPosition: Int
|
||||
let repNumber: Int
|
||||
let distance: Int
|
||||
let duration: TimeInterval
|
||||
let heartRate: Int?
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
||||
module.exports = (config) => ({
|
||||
type: "watch",
|
||||
icon: "../../assets/icon.png",
|
||||
deploymentTarget: "10.0",
|
||||
entitlements: {
|
||||
"com.apple.security.application-groups":
|
||||
config.ios?.entitlements?.["com.apple.security.application-groups"] ?? [],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct SwimTrackerWatchApp: App {
|
||||
@StateObject private var workoutManager = WorkoutManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(workoutManager)
|
||||
.onAppear {
|
||||
PhoneConnector.shared.setup(workoutManager: workoutManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user