feat: initial project scaffold with Watch companion app

This commit is contained in:
2026-08-04 17:28:06 +02:00
parent adbb4f01c5
commit 43b6467efb
65 changed files with 6361 additions and 166 deletions
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
}
}
}
+99
View File
@@ -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()
}
}
+5
View File
@@ -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>
+109
View File
@@ -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)
}
}
+283
View File
@@ -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?
}
+10
View File
@@ -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"] ?? [],
},
});
+16
View File
@@ -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)
}
}
}
}