remove Expo project and all related files
Remove the entire Expo/React Native application: routes (app/), source code (src/), assets, iOS native build, config plugins, StoreKit config, npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific documentation. The repository now contains only: admin-web, supabase, youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
This commit is contained in:
17
tabatago-swift/TabataGoWatch/App/TabataGoWatchApp.swift
Normal file
17
tabatago-swift/TabataGoWatch/App/TabataGoWatchApp.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
|
||||
@main
|
||||
struct TabataGoWatchApp: App {
|
||||
|
||||
@StateObject private var connectivityManager = WatchConnectivityManager.shared
|
||||
@StateObject private var playerEngine = WatchPlayerEngine()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environmentObject(connectivityManager)
|
||||
.environmentObject(playerEngine)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
tabatago-swift/TabataGoWatch/Complications/Info.plist
Normal file
29
tabatago-swift/TabataGoWatch/Complications/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TabataGoWidget</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,165 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// ─── Timeline entry ───────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let streak: Int
|
||||
let lastWorkoutLabel: String // e.g. "Yesterday" or "2 days ago"
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataProvider: TimelineProvider {
|
||||
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
|
||||
func placeholder(in context: Context) -> TabataEntry {
|
||||
TabataEntry(date: Date(), streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TabataEntry) -> Void) {
|
||||
completion(makeEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TabataEntry>) -> Void) {
|
||||
let entry = makeEntry()
|
||||
// Refresh at midnight so the streak date label stays accurate
|
||||
let midnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
|
||||
completion(Timeline(entries: [entry], policy: .after(midnight)))
|
||||
}
|
||||
|
||||
private func makeEntry() -> TabataEntry {
|
||||
let streak = sharedDefaults?.integer(forKey: "streak") ?? 0
|
||||
let lastDate = sharedDefaults?.object(forKey: "lastWorkoutDate") as? Date
|
||||
let label = relativeLabel(for: lastDate)
|
||||
return TabataEntry(date: Date(), streak: streak, lastWorkoutLabel: label)
|
||||
}
|
||||
|
||||
private func relativeLabel(for date: Date?) -> String {
|
||||
guard let date else { return "Not started" }
|
||||
let days = Calendar.current.dateComponents([.day],
|
||||
from: Calendar.current.startOfDay(for: date),
|
||||
to: Calendar.current.startOfDay(for: Date())).day ?? 0
|
||||
switch days {
|
||||
case 0: return "Today"
|
||||
case 1: return "Yesterday"
|
||||
default: return "\(days) days ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Views ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `.accessoryCircular` — streak count inside an orange ring
|
||||
struct CircularComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
Text("\(entry.streak)")
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
}
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
|
||||
/// `.accessoryRectangular` — streak + last workout date
|
||||
struct RectangularComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text("\(entry.streak) day streak")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
}
|
||||
Text(entry.lastWorkoutLabel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open TabataGo →")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
|
||||
/// `.accessoryCorner` — tiny bolt + streak digit in the corner
|
||||
struct CornerComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.widgetLabel("\(entry.streak) day streak")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Widget definition ────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataGoComplication: Widget {
|
||||
static let kind = "TabataGoComplication"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: Self.kind, provider: TabataProvider()) { entry in
|
||||
TabataComplicationEntryView(entry: entry)
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("TabataGo")
|
||||
.description("Your current workout streak.")
|
||||
.supportedFamilies([
|
||||
.accessoryCircular,
|
||||
.accessoryRectangular,
|
||||
.accessoryCorner
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
struct TabataComplicationEntryView: View {
|
||||
@Environment(\.widgetFamily) var family
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
CircularComplicationView(entry: entry)
|
||||
case .accessoryRectangular:
|
||||
RectangularComplicationView(entry: entry)
|
||||
case .accessoryCorner:
|
||||
CornerComplicationView(entry: entry)
|
||||
default:
|
||||
CircularComplicationView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Previews ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#Preview("Circular", as: .accessoryCircular) {
|
||||
TabataGoComplication()
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
|
||||
#Preview("Rectangular", as: .accessoryRectangular) {
|
||||
TabataGoComplication()
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
32
tabatago-swift/TabataGoWatch/Resources/Info.plist
Normal file
32
tabatago-swift/TabataGoWatch/Resources/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TabataGo</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>TabataGo reads your heart rate and calories during workouts.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>TabataGo saves workout data to Apple Health directly from your Watch.</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.tabatago.app</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?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>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tabatago.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Watch-side WatchConnectivity manager.
|
||||
/// Receives workout payloads from the phone, sends HR/calorie results back.
|
||||
@MainActor
|
||||
final class WatchConnectivityManager: NSObject, ObservableObject, WCSessionDelegate {
|
||||
|
||||
static let shared = WatchConnectivityManager()
|
||||
|
||||
@Published private(set) var isPhoneReachable = false
|
||||
|
||||
var onStartWorkout: ((WatchWorkoutPayload) -> Void)?
|
||||
var onTimerTick: ((TimerTickPayload) -> Void)?
|
||||
var onPause: (() -> Void)?
|
||||
var onResume: (() -> Void)?
|
||||
var onEnd: (() -> Void)?
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
guard WCSession.isSupported() else { return }
|
||||
session = WCSession.default
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
}
|
||||
|
||||
// ─── Send data to phone ───────────────────────────────────────
|
||||
|
||||
func sendHeartRate(_ bpm: Double) {
|
||||
send([WCMessageKey.type: WCMessageType.heartRateUpdate.rawValue,
|
||||
WCMessageKey.heartRate: bpm])
|
||||
}
|
||||
|
||||
func sendSessionResult(_ result: WatchSessionResult) {
|
||||
guard let data = try? JSONEncoder().encode(result) else { return }
|
||||
send([WCMessageKey.type: WCMessageType.sessionCompleted.rawValue,
|
||||
WCMessageKey.sessionResult: data])
|
||||
}
|
||||
|
||||
// ─── WCSessionDelegate ────────────────────────────────────────
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
|
||||
let reachable = session.isReachable
|
||||
Task { @MainActor in self.isPhoneReachable = reachable }
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
let reachable = session.isReachable
|
||||
Task { @MainActor in self.isPhoneReachable = reachable }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let typeRaw = message[WCMessageKey.type] as? String,
|
||||
let type = WCMessageType(rawValue: typeRaw) else { return }
|
||||
|
||||
// Extract Data payloads before crossing actor boundary — [String: Any] is not Sendable.
|
||||
let workoutData = message[WCMessageKey.workoutPayload] as? Data
|
||||
let tickData = message["tick"] as? Data
|
||||
|
||||
Task { @MainActor in
|
||||
switch type {
|
||||
case .startWorkout:
|
||||
guard let data = workoutData,
|
||||
let payload = try? JSONDecoder().decode(WatchWorkoutPayload.self, from: data) else { return }
|
||||
onStartWorkout?(payload)
|
||||
|
||||
case .timerTick:
|
||||
guard let data = tickData,
|
||||
let tick = try? JSONDecoder().decode(TimerTickPayload.self, from: data) else { return }
|
||||
onTimerTick?(tick)
|
||||
|
||||
case .pauseWorkout: onPause?()
|
||||
case .resumeWorkout: onResume?()
|
||||
case .endWorkout: onEnd?()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────────
|
||||
|
||||
private func send(_ message: [String: Any]) {
|
||||
guard let session, session.isReachable else { return }
|
||||
session.sendMessage(message, replyHandler: nil)
|
||||
}
|
||||
}
|
||||
297
tabatago-swift/TabataGoWatch/Services/WatchPlayerEngine.swift
Normal file
297
tabatago-swift/TabataGoWatch/Services/WatchPlayerEngine.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import WatchKit
|
||||
|
||||
// ─── Watch-local phase enum (mirrors iOS TimerPhase without cross-target dep) ──
|
||||
|
||||
enum WatchPhase: String, Codable, CaseIterable {
|
||||
case prep = "PREP"
|
||||
case warmup = "WARMUP"
|
||||
case work = "WORK"
|
||||
case rest = "REST"
|
||||
case interBlockRest = "INTER_BLOCK_REST"
|
||||
case cooldown = "COOLDOWN"
|
||||
case complete = "COMPLETE"
|
||||
}
|
||||
|
||||
// ─── Engine ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Drives the watch-side workout timer.
|
||||
///
|
||||
/// Design: The phone is the source-of-truth; the Watch engine *also* runs
|
||||
/// its own 1-second countdown so the display stays smooth even if WC
|
||||
/// messages are delayed. On every `timerTick` from the phone the Watch
|
||||
/// snaps its state to match, preventing drift.
|
||||
///
|
||||
/// HealthKit: The engine owns an `HKWorkoutSession` + `HKLiveWorkoutBuilder`
|
||||
/// so that calorie / heart-rate data is written directly from the Watch,
|
||||
/// which has the most accurate wrist sensors.
|
||||
@MainActor
|
||||
final class WatchPlayerEngine: NSObject, ObservableObject {
|
||||
|
||||
// ── Published state ───────────────────────────────────────────────
|
||||
@Published private(set) var isActive = false
|
||||
@Published private(set) var isPaused = false
|
||||
@Published private(set) var phase = WatchPhase.prep
|
||||
@Published private(set) var timeRemaining = 0
|
||||
@Published private(set) var currentRound = 1
|
||||
@Published private(set) var totalRoundsInBlock = 8
|
||||
@Published private(set) var currentExerciseName: String? = nil
|
||||
@Published private(set) var heartRate = 0.0
|
||||
@Published private(set) var activeCalories = 0.0
|
||||
|
||||
// ── Private state ─────────────────────────────────────────────────
|
||||
private var payload: WatchWorkoutPayload?
|
||||
private var startedAt: Date?
|
||||
private var timer: Timer?
|
||||
|
||||
// ── HealthKit ─────────────────────────────────────────────────────
|
||||
private let healthStore = HKHealthStore()
|
||||
private var workoutSession: HKWorkoutSession?
|
||||
private var liveBuilder: HKLiveWorkoutBuilder?
|
||||
|
||||
// ── Connectivity back-ref ─────────────────────────────────────────
|
||||
private let wc = WatchConnectivityManager.shared
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
wireConnectivityCallbacks()
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────
|
||||
|
||||
func togglePause() {
|
||||
if isPaused { resume() } else { pause() }
|
||||
}
|
||||
|
||||
func endWorkout() {
|
||||
stopTimer()
|
||||
Task { await finalizeHealthKit() }
|
||||
}
|
||||
|
||||
// ─── Connectivity callbacks ───────────────────────────────────────
|
||||
|
||||
private func wireConnectivityCallbacks() {
|
||||
wc.onStartWorkout = { [weak self] payload in
|
||||
Task { @MainActor in self?.handleStartWorkout(payload) }
|
||||
}
|
||||
wc.onTimerTick = { [weak self] tick in
|
||||
Task { @MainActor in self?.handleTick(tick) }
|
||||
}
|
||||
wc.onPause = { [weak self] in Task { @MainActor in self?.pause() } }
|
||||
wc.onResume = { [weak self] in Task { @MainActor in self?.resume() } }
|
||||
wc.onEnd = { [weak self] in Task { @MainActor in self?.endWorkout() } }
|
||||
}
|
||||
|
||||
private func handleStartWorkout(_ p: WatchWorkoutPayload) {
|
||||
payload = p
|
||||
startedAt = Date()
|
||||
|
||||
// Seed from first block
|
||||
if let first = p.blocks.first {
|
||||
totalRoundsInBlock = first.rounds
|
||||
currentExerciseName = first.exercise1Name
|
||||
}
|
||||
phase = p.warmupDuration > 0 ? .warmup : .prep
|
||||
timeRemaining = p.warmupDuration > 0 ? p.warmupDuration : 10
|
||||
currentRound = 1
|
||||
heartRate = 0
|
||||
activeCalories = 0
|
||||
isPaused = false
|
||||
isActive = true
|
||||
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
|
||||
startTimer()
|
||||
Task { await startHealthKit() }
|
||||
}
|
||||
|
||||
/// Snap watch state to phone's authoritative tick.
|
||||
private func handleTick(_ tick: TimerTickPayload) {
|
||||
phase = WatchPhase(rawValue: tick.phase) ?? phase
|
||||
timeRemaining = tick.timeRemaining
|
||||
currentRound = tick.currentRound
|
||||
totalRoundsInBlock = tick.totalRoundsInBlock
|
||||
currentExerciseName = tick.exerciseName
|
||||
}
|
||||
|
||||
// ─── Timer ────────────────────────────────────────────────────────
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.tick() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard !isPaused else { return }
|
||||
if timeRemaining > 0 {
|
||||
timeRemaining -= 1
|
||||
}
|
||||
// Phase transitions are driven by the phone's timerTick messages;
|
||||
// the local countdown is purely for smooth display.
|
||||
}
|
||||
|
||||
private func pause() {
|
||||
isPaused = true
|
||||
stopTimer()
|
||||
workoutSession?.pause()
|
||||
WKInterfaceDevice.current().play(.stop)
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
isPaused = false
|
||||
startTimer()
|
||||
workoutSession?.resume()
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
// ─── HealthKit ────────────────────────────────────────────────────
|
||||
|
||||
private func startHealthKit() async {
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
|
||||
let typesToShare: Set<HKSampleType> = [
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.heartRate),
|
||||
HKObjectType.workoutType()
|
||||
]
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.activeEnergyBurned)
|
||||
]
|
||||
|
||||
do {
|
||||
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
|
||||
} catch {
|
||||
return // HealthKit optional — proceed without it
|
||||
}
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .highIntensityIntervalTraining
|
||||
config.locationType = .indoor
|
||||
|
||||
do {
|
||||
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||
let builder = session.associatedWorkoutBuilder()
|
||||
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
|
||||
workoutConfiguration: config)
|
||||
session.delegate = self
|
||||
builder.delegate = self
|
||||
|
||||
workoutSession = session
|
||||
liveBuilder = builder
|
||||
|
||||
session.startActivity(with: startedAt ?? Date())
|
||||
try await builder.beginCollection(at: startedAt ?? Date())
|
||||
} catch {
|
||||
// Non-fatal — timer still works without HealthKit
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeHealthKit() async {
|
||||
let endDate = Date()
|
||||
guard let session = workoutSession, let builder = liveBuilder else {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
isActive = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await builder.endCollection(at: endDate)
|
||||
let workout = try await builder.finishWorkout()
|
||||
session.end()
|
||||
|
||||
guard let workout else {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
return
|
||||
}
|
||||
|
||||
let avgHR = workout.statistics(for: HKQuantityType(.heartRate))?
|
||||
.averageQuantity()?.doubleValue(for: HKUnit(from: "count/min"))
|
||||
let peakHR = workout.statistics(for: HKQuantityType(.heartRate))?
|
||||
.maximumQuantity()?.doubleValue(for: HKUnit(from: "count/min"))
|
||||
let cal = workout.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||
|
||||
if let cal { activeCalories = cal }
|
||||
sendResultToPhone(endDate: endDate, avgHR: avgHR, peakHR: peakHR, cal: cal)
|
||||
} catch {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
}
|
||||
|
||||
isActive = false
|
||||
}
|
||||
|
||||
private func sendResultToPhone(endDate: Date,
|
||||
avgHR: Double? = nil,
|
||||
peakHR: Double? = nil,
|
||||
cal: Double? = nil) {
|
||||
let result = WatchSessionResult(
|
||||
programId: payload?.programId ?? "",
|
||||
startedAt: startedAt ?? endDate,
|
||||
completedAt: endDate,
|
||||
durationSeconds: Int(endDate.timeIntervalSince(startedAt ?? endDate)),
|
||||
activeCalories: cal ?? activeCalories,
|
||||
averageHeartRate: avgHR,
|
||||
peakHeartRate: peakHR
|
||||
)
|
||||
wc.sendSessionResult(result)
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HKWorkoutSessionDelegate ─────────────────────────────────────────────────
|
||||
|
||||
extension WatchPlayerEngine: HKWorkoutSessionDelegate {
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||
didChangeTo toState: HKWorkoutSessionState,
|
||||
from fromState: HKWorkoutSessionState,
|
||||
date: Date) {}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||
didFailWithError error: Error) {}
|
||||
}
|
||||
|
||||
// ─── HKLiveWorkoutBuilderDelegate ────────────────────────────────────────────
|
||||
|
||||
extension WatchPlayerEngine: HKLiveWorkoutBuilderDelegate {
|
||||
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||
|
||||
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
|
||||
didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
||||
for type in collectedTypes {
|
||||
guard let quantityType = type as? HKQuantityType else { continue }
|
||||
|
||||
switch quantityType {
|
||||
case HKQuantityType(.heartRate):
|
||||
let hr = workoutBuilder
|
||||
.statistics(for: quantityType)?
|
||||
.mostRecentQuantity()?
|
||||
.doubleValue(for: HKUnit(from: "count/min")) ?? 0
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.heartRate = hr
|
||||
self.wc.sendHeartRate(hr)
|
||||
}
|
||||
|
||||
case HKQuantityType(.activeEnergyBurned):
|
||||
let cal = workoutBuilder
|
||||
.statistics(for: quantityType)?
|
||||
.sumQuantity()?
|
||||
.doubleValue(for: .kilocalorie()) ?? 0
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeCalories = cal
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
tabatago-swift/TabataGoWatch/Views/WatchActivityView.swift
Normal file
156
tabatago-swift/TabataGoWatch/Views/WatchActivityView.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
|
||||
/// Mini activity/streak summary shown on the Watch when idle.
|
||||
/// Displays today's Move/Exercise/Stand ring progress from HealthKit
|
||||
/// and a streak count stored via App Group UserDefaults (shared with phone).
|
||||
struct WatchActivityView: View {
|
||||
|
||||
@State private var moveProgress: Double = 0 // 0-1
|
||||
@State private var exerciseProgress: Double = 0
|
||||
@State private var standProgress: Double = 0
|
||||
@State private var streak: Int = 0
|
||||
@State private var isLoading = true
|
||||
|
||||
private let healthStore = HKHealthStore()
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
|
||||
// ── Rings ──────────────────────────────────────────
|
||||
Text("Today")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
RingView(progress: moveProgress,
|
||||
color: Color(red: 1.0, green: 0.23, blue: 0.19),
|
||||
icon: "flame.fill",
|
||||
label: "Move")
|
||||
RingView(progress: exerciseProgress,
|
||||
color: .green,
|
||||
icon: "figure.run",
|
||||
label: "Exercise")
|
||||
RingView(progress: standProgress,
|
||||
color: Color(red: 0.04, green: 0.80, blue: 0.97),
|
||||
icon: "figure.stand",
|
||||
label: "Stand")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Streak ─────────────────────────────────────────
|
||||
HStack {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 14))
|
||||
Text("\(streak) day\(streak == 1 ? "" : "s")")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
Text("streak")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 8)
|
||||
.redacted(reason: isLoading ? .placeholder : [])
|
||||
}
|
||||
.task { await loadData() }
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────
|
||||
|
||||
private func loadData() async {
|
||||
streak = sharedDefaults?.integer(forKey: "streak") ?? 0
|
||||
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
HKObjectType.activitySummaryType()
|
||||
]
|
||||
|
||||
guard (try? await healthStore.requestAuthorization(toShare: [], read: typesToRead)) != nil else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let predicate = HKQuery.predicateForActivitySummary(with: calendar.dateComponents(
|
||||
[.era, .year, .month, .day], from: today))
|
||||
|
||||
let summaries = try? await withCheckedThrowingContinuation { (cont: CheckedContinuation<[HKActivitySummary], Error>) in
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
|
||||
if let error { cont.resume(throwing: error) }
|
||||
else { cont.resume(returning: summaries ?? []) }
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
if let summary = summaries?.first {
|
||||
let moveGoal = summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())
|
||||
let exerciseGoal = summary.appleExerciseTimeGoal.doubleValue(for: .minute())
|
||||
let standGoal = summary.appleStandHoursGoal.doubleValue(for: .count())
|
||||
|
||||
await MainActor.run {
|
||||
moveProgress = moveGoal > 0
|
||||
? summary.activeEnergyBurned.doubleValue(for: .kilocalorie()) / moveGoal
|
||||
: 0
|
||||
exerciseProgress = exerciseGoal > 0
|
||||
? summary.appleExerciseTime.doubleValue(for: .minute()) / exerciseGoal
|
||||
: 0
|
||||
standProgress = standGoal > 0
|
||||
? summary.appleStandHours.doubleValue(for: .count()) / standGoal
|
||||
: 0
|
||||
isLoading = false
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
private struct RingView: View {
|
||||
let progress: Double
|
||||
let color: Color
|
||||
let icon: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
// Track
|
||||
Circle()
|
||||
.stroke(color.opacity(0.2), lineWidth: 5)
|
||||
// Fill
|
||||
Circle()
|
||||
.trim(from: 0, to: min(progress, 1.0))
|
||||
.stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut(duration: 0.6), value: progress)
|
||||
// Icon
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WatchActivityView()
|
||||
}
|
||||
43
tabatago-swift/TabataGoWatch/Views/WatchIdleView.swift
Normal file
43
tabatago-swift/TabataGoWatch/Views/WatchIdleView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Idle state — waiting for a workout to start from the phone.
|
||||
struct WatchIdleView: View {
|
||||
@EnvironmentObject private var connectivity: WatchConnectivityManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 36, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("TabataGo")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
|
||||
Text("Start a workout\non your iPhone")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if connectivity.isPhoneReachable {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Connected")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(.gray)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("No phone")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
156
tabatago-swift/TabataGoWatch/Views/WatchPlayerView.swift
Normal file
156
tabatago-swift/TabataGoWatch/Views/WatchPlayerView.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Active workout player on Watch — full-screen timer with phase, HR, calories.
|
||||
struct WatchPlayerView: View {
|
||||
@EnvironmentObject private var engine: WatchPlayerEngine
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Phase color background
|
||||
watchPhaseColor(engine.phase)
|
||||
.opacity(0.18)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 4) {
|
||||
|
||||
// ── Phase label ────────────────────────────────────
|
||||
Text(watchPhaseLabel(engine.phase))
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(watchPhaseColor(engine.phase))
|
||||
.kerning(1.5)
|
||||
|
||||
// ── Timer ──────────────────────────────────────────
|
||||
Text("\(engine.timeRemaining)")
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.spring(duration: 0.3), value: engine.timeRemaining)
|
||||
|
||||
// ── Exercise name ──────────────────────────────────
|
||||
if let name = engine.currentExerciseName {
|
||||
Text(name)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
|
||||
// ── Round pips ─────────────────────────────────────
|
||||
RoundPips(
|
||||
current: engine.currentRound,
|
||||
total: min(engine.totalRoundsInBlock, 8)
|
||||
)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// ── Live metrics ───────────────────────────────────
|
||||
HStack(spacing: 16) {
|
||||
if engine.heartRate > 0 {
|
||||
WatchMetric(
|
||||
icon: "heart.fill",
|
||||
value: "\(Int(engine.heartRate))",
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
if engine.activeCalories > 0 {
|
||||
WatchMetric(
|
||||
icon: "flame.fill",
|
||||
value: "\(Int(engine.activeCalories))",
|
||||
color: .orange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pause / End controls ───────────────────────────
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
engine.togglePause()
|
||||
} label: {
|
||||
Image(systemName: engine.isPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(role: .destructive) {
|
||||
engine.endWorkout()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func watchPhaseColor(_ phase: WatchPhase) -> Color {
|
||||
switch phase {
|
||||
case .prep, .warmup: return .orange
|
||||
case .work: return Color(red: 1.0, green: 0.42, blue: 0.21)
|
||||
case .rest, .interBlockRest: return Color(red: 0.35, green: 0.78, blue: 0.98)
|
||||
case .cooldown: return .cyan
|
||||
case .complete: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func watchPhaseLabel(_ phase: WatchPhase) -> String {
|
||||
switch phase {
|
||||
case .prep: return "GET READY"
|
||||
case .warmup: return "WARM UP"
|
||||
case .work: return "WORK"
|
||||
case .rest: return "REST"
|
||||
case .interBlockRest: return "BREAK"
|
||||
case .cooldown: return "COOL DOWN"
|
||||
case .complete: return "DONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────
|
||||
|
||||
struct RoundPips: View {
|
||||
let current: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(1...max(total, 1), id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i < current ? Color.orange :
|
||||
i == current ? .white : .white.opacity(0.25))
|
||||
.frame(width: i == current ? 16 : 6, height: 5)
|
||||
.animation(.spring(duration: 0.25), value: current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchMetric: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WatchPlayerView()
|
||||
.environmentObject(WatchPlayerEngine())
|
||||
.environmentObject(WatchConnectivityManager.shared)
|
||||
}
|
||||
17
tabatago-swift/TabataGoWatch/Views/WatchRootView.swift
Normal file
17
tabatago-swift/TabataGoWatch/Views/WatchRootView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Root view: shows idle screen or active player depending on state.
|
||||
struct WatchRootView: View {
|
||||
@EnvironmentObject private var playerEngine: WatchPlayerEngine
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if playerEngine.isActive {
|
||||
WatchPlayerView()
|
||||
} else {
|
||||
WatchIdleView()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: playerEngine.isActive)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user