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:
Millian Lamiaux
2026-04-21 21:55:00 +02:00
parent 8c90b73d90
commit 89cca25e22
285 changed files with 11212 additions and 44392 deletions

View File

@@ -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)
}
}

View 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
}
}
}
}