From 7b801fb1bbf74f194aa35b23e255a30ae1cd7da1 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:30 +0200 Subject: [PATCH 1/5] Keep Apple training pauses and failed saves recoverable --- Strand/App/ActiveWorkoutPersistence.swift | 4 + Strand/App/AppModel.swift | 109 +++++++------- Strand/App/GpsWorkoutRecorder.swift | 4 +- Strand/Data/LiftSessionController.swift | 140 ++++++++++++++++-- Strand/Data/LiftSessionEngine.swift | 58 +++++++- Strand/Data/LiftSessionPersistence.swift | 15 +- Strand/Data/Repository.swift | 5 +- Strand/Resources/Localizable.xcstrings | 2 + Strand/Screens/LiftLogView.swift | 31 +--- Strand/Screens/LiftSessionBar.swift | 6 +- Strand/Screens/LiftSessionDetailSheet.swift | 1 + Strand/Screens/LiftSessionView.swift | 101 +++---------- Strand/Screens/LiveWorkoutView.swift | 10 +- .../ActiveWorkoutPersistenceTests.swift | 4 +- .../LiftSessionHistoryLoadingTests.swift | 47 ++++++ StrandTests/LiftSessionSaveFailureTests.swift | 56 +++++++ StrandTests/LiftSessionTimingTests.swift | 19 ++- StrandTests/WorkoutSaveFailureTests.swift | 59 ++++++++ 18 files changed, 478 insertions(+), 193 deletions(-) create mode 100644 StrandTests/LiftSessionHistoryLoadingTests.swift create mode 100644 StrandTests/LiftSessionSaveFailureTests.swift create mode 100644 StrandTests/WorkoutSaveFailureTests.swift diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index 0fc97a4279..c18f0319b2 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -30,6 +30,8 @@ enum ActiveWorkoutPersistence { var liveStrain: Double var pausedAtSec: Int? = nil var pausedDurationSec: Int? = nil + var sessionID: String? = nil + var pausedForPulseLoss: Bool? = nil } /// The single `UserDefaults` key (JSON-encoded `Snapshot`). Namespaced like `moments`/`sleepMarks`. @@ -60,6 +62,8 @@ enum ActiveWorkoutPersistence { liveStrain: raw.liveStrain.isFinite ? max(0, raw.liveStrain) : 0, pausedAtSec: raw.pausedAtSec.flatMap { $0 > 0 ? $0 : nil }, pausedDurationSec: raw.pausedDurationSec.map { max(0, $0) }, + sessionID: raw.sessionID, + pausedForPulseLoss: raw.pausedForPulseLoss, ) } diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index b477b8b992..711b8b1d15 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -124,6 +124,8 @@ final class AppModel: ObservableObject { var peakHr: Int = 0 var pausedAt: Date? var pausedDuration: TimeInterval = 0 + var sessionID = UUID().uuidString + var pausedForPulseLoss = false var isPaused: Bool { pausedAt != nil } @@ -900,7 +902,8 @@ final class AppModel: ObservableObject { peakHr: w.peakHr, liveStrain: w.liveStrain, pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) }, - pausedDurationSec: Int(w.pausedDuration))) + pausedDurationSec: Int(w.pausedDuration), + sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss)) } /// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable @@ -917,6 +920,8 @@ final class AppModel: ObservableObject { w.liveStrain = snap.liveStrain w.pausedAt = snap.pausedAtSec.map { Date(timeIntervalSince1970: TimeInterval($0)) } w.pausedDuration = TimeInterval(snap.pausedDurationSec ?? 0) + w.sessionID = snap.sessionID ?? "workout-\(snap.startSec)" + w.pausedForPulseLoss = snap.pausedForPulseLoss ?? false activeWorkout = w // Rebuild the transient GPS lifecycle flag as well as the durable workout value. Without this, @@ -935,21 +940,35 @@ final class AppModel: ObservableObject { } func toggleWorkoutPause() { - guard var w = activeWorkout else { return } - if let pausedAt = w.pausedAt { - w.pausedDuration += Date().timeIntervalSince(pausedAt) - w.pausedAt = nil - if activeWorkoutIsGps { gpsRecorder.resume() } - } else { - w.pausedAt = Date() - if activeWorkoutIsGps { gpsRecorder.pause() } - } + if activeWorkout?.isPaused == true { resumeWorkout() } else { pauseWorkout() } + } + + func pauseWorkout(at date: Date = Date(), pulseLoss: Bool = false) { + guard var w = activeWorkout, !w.isPaused else { return } + w.pausedAt = max(w.start, date) + w.pausedForPulseLoss = pulseLoss + activeWorkout = w + if activeWorkoutIsGps { gpsRecorder.pause(at: date) } + persistActiveWorkout() + } + + func resumeWorkout(at date: Date = Date()) { + guard var w = activeWorkout, let pausedAt = w.pausedAt, !isFinishingWorkout else { return } + w.pausedDuration += max(0, date.timeIntervalSince(pausedAt)) + w.pausedAt = nil + w.pausedForPulseLoss = false activeWorkout = w + if activeWorkoutIsGps { gpsRecorder.resume() } persistActiveWorkout() } /// Abort the active session without saving a workout. func discardWorkout() { + guard !isFinishingWorkout else { return } + clearActiveWorkout() + } + + private func clearActiveWorkout() { guard activeWorkout != nil else { return } activeWorkout = nil if activeWorkoutIsGps { gpsRecorder.stop() } @@ -973,34 +992,28 @@ final class AppModel: ObservableObject { elapsedSeconds < minimumWorkoutSeconds } - func endWorkout() { + private(set) var isFinishingWorkout = false + + func endWorkout() { Task { try? await finishWorkout() } } + + func finishWorkout() async throws { + guard activeWorkout != nil, !isFinishingWorkout else { return } + isFinishingWorkout = true + defer { isFinishingWorkout = false } + pauseWorkout() guard let w = activeWorkout else { return } - activeWorkout = nil let wasGps = activeWorkoutIsGps - activeWorkoutIsGps = false - // Drop the durable snapshot the instant the session ends , whether it saves below or is discarded - // as too-short , so a relaunch never rehydrates an already-finished session (#529). - ActiveWorkoutPersistence.clear() - // #524: finalize the GPS route. Stop the recorder and take its captured route , it kept + // Keep the paused snapshot and route until the write succeeds, so a failed save remains retryable. + // #524: capture the GPS route; it kept // accumulating from CoreLocation independently of the HR window. `capturedRoute()` is nil unless // ≥2 points actually landed (honest: no route, no distance, when nothing was captured , e.g. a // Mac with no GPS, or denied permission). A non-GPS session never armed the recorder. var route: WorkoutRoute? if wasGps { - gpsRecorder.stop() route = gpsRecorder.capturedRoute() } let samples = w.samples - // Save when there's an HR window OR a real GPS route , a GPS-only walk (HR not streaming) is - // still a workout (parity with Android's `samples.size < 2 && track.size < 2` discard gate). - guard samples.count >= 2 || route != nil else { - // Workouts & GPS test mode: record WHY a session vanished (too short / no route), tagged `.workouts`. - emitWorkoutsTrace(WorkoutsTrace.sessionLine( - event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport), - hrSamples: samples.count, gpsPoints: route == nil ? 0 : nil)) - lastWorkout = nil - return - } + guard samples.count >= 2 || route != nil else { clearActiveWorkout(); return } let end = Date() // A session under a minute is a start/stop the wearer did not mean to keep, and it was the thing // that made deletion feel broken: the list filled with 5-30 second entries (#2278). Discarded HERE, @@ -1009,8 +1022,6 @@ final class AppModel: ObservableObject { // and no cloud copy, so a later prune would be irreversible; this is not, because nothing with real // data is ever removed. // - // Sits after the sample/route gate above so that gate's meaning is unchanged: a 30-second session - // can easily carry two HR samples and would otherwise have been saved. let elapsed = w.elapsed(at: end) if Self.isTooShortToSave(elapsedSeconds: elapsed) { emitWorkoutsTrace(WorkoutsTrace.sessionLine( @@ -1019,7 +1030,7 @@ final class AppModel: ObservableObject { gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) // Drop the route too: keeping a polyline for a session that was never saved would orphan it in // RouteStore under a natural key no row claims. - lastWorkout = nil + clearActiveWorkout() return } let avg = samples.isEmpty ? nil @@ -1032,22 +1043,19 @@ final class AppModel: ObservableObject { // disagree with its own re-score. Read once here, at save time; the live readout during the // session is a transient running estimate and deliberately left alone. let restingHR = repo.today?.restingHr.map(Double.init) ?? StrainScorer.defaultRestingHR - let strain = samples.count >= 2 - ? StrainScorer.strain(samples, maxHR: Double(profile.hrMax), - restingHR: restingHR, - method: PuffinExperiment.effortMethod, sex: profile.sex) : nil - // Estimate calories from the captured HR window (same Keytel/Harris–Benedict model the - // auto-detector uses) so a manual session shows energy too, not just duration/strain. (#117) let up = UserProfile(weightKg: profile.weightKg, heightCm: profile.heightCm, age: Double(profile.age), sex: profile.sex) - let kcal = samples.count >= 2 - // #983: same measured resting HR as the strain above, not nil. The calories model's - // active-vs-resting threshold sits at resting + 30% HRR, so the default silently shifts what - // counts as active — and #972 already threads it in the rescore path, so leaving it nil here - // meant a saved workout's kcal disagreed with its own re-score just as its Effort did. - ? Calories.estimateBoutCalories(samples, profile: up, hrmax: Double(profile.hrMax), - restingHR: restingHR).0 - : 0 + let hrMax = Double(profile.hrMax) + let method = PuffinExperiment.effortMethod + let sex = profile.sex + // Pure scoring uses an immutable snapshot, leaving the main actor free for UI and BLE. + let (strain, kcal) = await Task.detached(priority: .userInitiated) { + let strain = samples.count >= 2 + ? StrainScorer.strain(samples, maxHR: hrMax, restingHR: restingHR, method: method, sex: sex) : nil + let kcal = samples.count >= 2 + ? Calories.estimateBoutCalories(samples, profile: up, hrmax: hrMax, restingHR: restingHR).0 : 0 + return (strain, kcal) + }.value let startTs = Int(w.start.timeIntervalSince1970) let row = WorkoutRow( startTs: startTs, endTs: Int(end.timeIntervalSince1970), @@ -1060,6 +1068,9 @@ final class AppModel: ObservableObject { // Persist the route polyline under the row's natural key so WorkoutDetailView can draw it. On // device only; mirrors the moments / sleepMarks UserDefaults persistence. (#524) if let route { RouteStore.store(route, startTs: startTs, sport: w.sport) } + guard let store = await repo.storeHandle() else { throw CocoaError(.fileWriteUnknown) } + _ = try await store.upsertWorkouts([row], deviceId: deviceId) + clearActiveWorkout() lastWorkout = row // Workouts & GPS test mode: one session-end summary tagged `.workouts` (the lastSessionSummary readout // source) carrying the captured HR window size, the duration, and the accepted GPS point count, so the @@ -1070,13 +1081,7 @@ final class AppModel: ObservableObject { durationSec: Int(w.elapsed(at: end)), gpsPoints: wasGps ? gpsRecorder.pointCount : nil)) buzz(loops: 2, gate: HapticPrefs.workout) - Task { [weak self] in - guard let self else { return } - if let store = await self.repo.storeHandle() { - _ = try? await store.upsertWorkouts([row], deviceId: self.deviceId) - await self.repo.refresh() - } - } + await repo.refresh() } /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called diff --git a/Strand/App/GpsWorkoutRecorder.swift b/Strand/App/GpsWorkoutRecorder.swift index ad7cbb0e97..d3547124ee 100644 --- a/Strand/App/GpsWorkoutRecorder.swift +++ b/Strand/App/GpsWorkoutRecorder.swift @@ -594,9 +594,9 @@ final class GpsWorkoutRecorder: NSObject, ObservableObject { return final } - func pause() { + func pause(at date: Date = Date()) { guard isRecording else { return } - pausedAtMs = Int64(Date().timeIntervalSince1970 * 1000) + pausedAtMs = Int64(date.timeIntervalSince1970 * 1000) manager.stopUpdatingLocation() isRecording = false } diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 72c3ca1bbf..2d0a7a8031 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -29,6 +29,7 @@ final class LiftSessionController: ObservableObject { @Published private(set) var programName: String? /// True while the full sheet is presented; false when minimised to the bottom bar. @Published var isPresented = false + @Published var sessionRpeText = "" { didSet { persist() } } /// Bumped each time a finished session is written. The session sheet is presented above every /// screen, so its save cannot call back into the one listing sessions; that screen reloads on this. @@ -50,6 +51,22 @@ final class LiftSessionController: ObservableObject { .eraseToAnyPublisher() var isActive: Bool { engine != nil && engine?.isFinished == false } + private(set) var sessionID = UUID().uuidString + private(set) var pausedForPulseLoss = false + + func pause(at now: Int = LiftSessionController.unixNow, pulseLoss: Bool = false) { + guard isActive, engine?.isPaused == false else { return } + pausedForPulseLoss = pulseLoss + engine?.pause(now: now) + persist() + } + + func resume(at now: Int = LiftSessionController.unixNow) { + guard !isSaving, engine?.isPaused == true else { return } + pausedForPulseLoss = false + engine?.resume(now: now) + persist() + } /// Rest period the five-second warning has already fired for. Lives HERE, not in a view, so /// re-opening the sheet mid-rest cannot re-fire it. @@ -126,9 +143,98 @@ final class LiftSessionController: ObservableObject { self.log = log } + func start(program: LiftProgramRow, repo: Repository, present: Bool = true) async throws { + guard !isActive else { if present { isPresented = true }; return } + guard let store = await repo.storeHandle() else { throw CocoaError(.fileReadUnknown) } + let items = try await store.liftProgramItems(programId: program.id) + guard !items.isEmpty else { throw CocoaError(.fileReadNoSuchFile) } + let vocabulary = try await store.liftExercises(deviceId: repo.deviceId) + + let plan = items.map { item -> LiftPlanItem in + // The classification comes from the exercise vocabulary, which is the one place that owns + // it — the program line deliberately stores no muscle of its own to drift from. + let known = vocabulary.first { $0.name == item.exercise } + return LiftPlanItem(exercise: item.exercise, + primaryMuscle: known?.primaryMuscle, + secondaryMuscles: known?.secondaryMuscles ?? [], + targetSets: item.targetSets, + restSec: item.restSec, + targetRepsLow: item.targetRepsLow, + targetRepsHigh: item.targetRepsHigh, + targetRpe: item.targetRpe, + targetWeightKg: item.targetWeightKg, + note: item.note, + // Carried so a set added or dropped mid-session can be written back + // onto the line it came from, and be there next time. + programItemId: item.id) + } + guard !isActive else { if present { isPresented = true }; return } + start(plan: plan, programId: program.id, programName: program.name) + isPresented = present + await loadLastSession(repo: repo) + } + + func loadLastSession(repo: Repository) async { + guard let engine, let store = await repo.storeHandle() else { return } + let id = sessionID + let exercises = Set(engine.plan.map(\.exercise)) + guard Set(lastSession.keys) != exercises else { return } + var out: [String: [Int: LiftSetCarry]] = [:] + for exercise in exercises { + let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, exercise: exercise, before: engine.startTs)) ?? [] + var bySet: [Int: LiftSetCarry] = [:] + for row in rows where !row.isWarmup { bySet[row.setIndex] = LiftSetCarry(weightKg: row.weightKg, reps: row.reps) } + out[exercise] = bySet + } + guard sessionID == id, Set(self.engine?.plan.map(\.exercise) ?? []) == exercises else { return } + setLastSession(out) + } + + private(set) var isSaving = false + + /// Retains the paused session until all writes succeed; stable IDs make a retry idempotent. + func save(repo: Repository, completingUnfinished: Bool, sessionRpe: Double?) async throws -> (plan: [LiftPlanItem], sets: [FinishedSet]) { + guard !isSaving, engine != nil else { throw CocoaError(.userCancelled) } + isSaving = true + defer { isSaving = false } + pause() + guard var finishedEngine = engine, let store = await repo.storeHandle() else { throw CocoaError(.fileWriteUnknown) } + let endTs = Self.unixNow + let duration = finishedEngine.elapsed(now: endTs) + finishedEngine.finish(now: endTs) + let finished = setsToSave(completingUnfinished: completingUnfinished, using: finishedEngine) + if Self.anyPerformed(finished) { + let row = LiftSessionRow(id: sessionID, deviceId: repo.deviceId, startTs: finishedEngine.startTs, + endTs: endTs, sport: "Strength", programId: programId, + programName: programName, sessionRpe: sessionRpe ?? LiftFormat.number(sessionRpeText), note: programName) + _ = try await store.upsertLiftSessions([row]) + let rows = finished.enumerated().map { ord, set in + let item = finishedEngine.planItem(for: set.slot) + return LiftSetRow(id: "\(sessionID):\(set.slot.exerciseIndex):\(set.slot.setIndex)", + deviceId: repo.deviceId, sessionId: sessionID, ord: ord, + exercise: item?.exercise ?? "", primaryMuscle: item?.primaryMuscle, + secondaryMuscles: item?.secondaryMuscles ?? [], setIndex: set.slot.setIndex, + weightKg: set.weightKg, reps: set.reps, rpe: set.rpe, isWarmup: set.isWarmup, + startTs: set.startTs, endTs: set.endTs, restSec: set.restSec, note: nil) + } + _ = try await store.upsertLiftSets(rows) + let workout = WorkoutRow(startTs: finishedEngine.startTs, endTs: endTs, sport: "Strength", source: "manual", + durationS: Double(duration), energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, + distanceM: nil, zonesJSON: nil, notes: programName, steps: nil) + _ = try await store.upsertWorkouts([workout], deviceId: repo.deviceId) + + } + return (finishedEngine.plan, finished) + } + // MARK: - Lifecycle func start(plan: [LiftPlanItem], programId: String?, programName: String?) { + guard !isActive else { return } + sessionID = UUID().uuidString + lastSession = [:] + pausedForPulseLoss = false + sessionRpeText = "" let stamp = Int(Date().timeIntervalSince1970) engine = LiftSessionEngine(plan: plan, startTs: stamp) self.programId = programId @@ -158,6 +264,9 @@ final class LiftSessionController: ObservableObject { /// Rehydrate an interrupted session found on disk. Does NOT present the sheet: the session comes /// back as the bottom bar, and the user opens it if they want to. func resume(from snapshot: LiftSessionPersistence.Snapshot, present: Bool = false) { + lastSession = [:] + sessionID = snapshot.sessionID ?? "lift-\(snapshot.startSec)" + pausedForPulseLoss = snapshot.pausedForPulseLoss ?? false engine = LiftSessionPersistence.engine(from: snapshot) programId = snapshot.programId programName = snapshot.programName @@ -176,11 +285,13 @@ final class LiftSessionController: ObservableObject { warnedFor = nil } isPresented = present + sessionRpeText = snapshot.sessionRpeText ?? "" claimStrap() } /// Give up the session without saving. func discard() { + guard !isSaving else { return } teardown() LiftSessionPersistence.clear() } @@ -194,6 +305,7 @@ final class LiftSessionController: ObservableObject { private func teardown() { engine = nil + sessionRpeText = "" programId = nil programName = nil warnedFor = nil @@ -213,13 +325,13 @@ final class LiftSessionController: ObservableObject { } /// The current unix second. Read when needed; nothing about a session is stored per second. - static var unixNow: Int { Int(Date().timeIntervalSince1970) } + nonisolated static var unixNow: Int { Int(Date().timeIntervalSince1970) } // MARK: - Actions /// The one action. `fromStrap` earns a single confirming buzz, unless the tap reads as a knock. func advance(fromStrap: Bool = false) { - guard let current = engine else { return } + guard !isSaving, let current = engine, !current.isPaused else { return } let stamp = Int(Date().timeIntervalSince1970) if fromStrap { if let last = lastStrapStepAt, @@ -291,7 +403,12 @@ final class LiftSessionController: ObservableObject { func presentation(system: UnitSystem) -> Presentation? { guard let engine, !engine.isFinished else { return nil } - let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt)) + let started = Date(timeIntervalSince1970: TimeInterval(engine.stageStartedAt + engine.stagePausedDuration)) + if engine.isPaused { + return Presentation(isResting: false, exercise: engine.currentSlot.flatMap { engine.planItem(for: $0)?.exercise } ?? programName ?? String(localized: "Session"), + status: String(localized: "Paused"), detail: nil, next: Self.nextLine(engine), + stageStartedAt: started, restEndsAt: nil) + } let next = Self.nextLine(engine) guard let slot = engine.currentSlot, let item = engine.planItem(for: slot) else { @@ -536,8 +653,8 @@ final class LiftSessionController: ObservableObject { /// (`LiftMetrics.isPerformed`) and Edit sets still shows, so a discard made by mistake can be filled /// back in. Done sets keep the order they happened in and their timing; sets never started follow in /// plan order, with no timing. - func setsToSave(completingUnfinished: Bool) -> [FinishedSet] { - guard let engine else { return [] } + func setsToSave(completingUnfinished: Bool, using override: LiftSessionEngine? = nil) -> [FinishedSet] { + guard let engine = override ?? engine else { return [] } // The plan's max RPE, which the session shows grey in the RPE field. func planned(_ slot: LiftSlot) -> Double? { engine.planItem(for: slot)?.targetRpe } var out = engine.sets.map { set -> FinishedSet in @@ -677,7 +794,7 @@ final class LiftSessionController: ObservableObject { /// Re-arm the rest's timers when the rest changed — a new rest, a rest undone or cut short, no rest. private func scheduleRestTimers() { - let endsAt: Int? = { if case .resting(_, let end) = engine?.stage { return end }; return nil }() + let endsAt: Int? = { if engine?.isPaused == false, case .resting(_, let end) = engine?.stage { return end }; return nil }() guard endsAt != scheduledRestEnd else { return } scheduledRestEnd = endsAt restTimers.forEach { $0.cancel() } @@ -710,7 +827,7 @@ final class LiftSessionController: ObservableObject { } private func fireRestWarningIfDue() { - guard let engine, case .resting(_, let endsAt) = engine.stage else { return } + guard let engine, !engine.isPaused, case .resting(_, let endsAt) = engine.stage else { return } guard warnedFor != endsAt else { return } guard endsAt - Self.unixNow <= LiftSessionController.restWarningLeadSec else { return } warnedFor = endsAt @@ -727,11 +844,14 @@ final class LiftSessionController: ObservableObject { private func persist() { guard let engine, !engine.isFinished else { return } - LiftSessionPersistence.store( - LiftSessionPersistence.snapshot(engine: engine, + var snapshot = LiftSessionPersistence.snapshot(engine: engine, programId: programId, programName: programName, pendingValues: pendingValues, - pendingWarmups: pendingWarmups)) + pendingWarmups: pendingWarmups) + snapshot.sessionID = sessionID + snapshot.pausedForPulseLoss = pausedForPulseLoss + snapshot.sessionRpeText = sessionRpeText.isEmpty ? nil : sessionRpeText + LiftSessionPersistence.store(snapshot) } } diff --git a/Strand/Data/LiftSessionEngine.swift b/Strand/Data/LiftSessionEngine.swift index 3e33e6e490..f8e3d3033c 100644 --- a/Strand/Data/LiftSessionEngine.swift +++ b/Strand/Data/LiftSessionEngine.swift @@ -135,6 +135,39 @@ struct LiftSessionEngine: Equatable { private(set) var sets: [LiftRecordedSet] /// When the current stage began, so a set's duration is measurable. private(set) var stageStartedAt: Int + private(set) var pausedAt: Int? + private(set) var pausedDuration = 0 + private(set) var stagePausedDuration = 0 + var isPaused: Bool { pausedAt != nil } + + func elapsed(now: Int) -> Int { + Int(ActiveWorkoutClock.activeElapsed( + start: Date(timeIntervalSince1970: Double(startTs)), + pausedAt: pausedAt.map { Date(timeIntervalSince1970: Double($0)) }, + pausedDuration: Double(pausedDuration), now: Date(timeIntervalSince1970: Double(now)))) + } + + func stageElapsed(now: Int) -> Int { max(0, (pausedAt ?? now) - stageStartedAt - stagePausedDuration) } + + mutating func pause(now: Int) { + guard !isPaused, !isFinished else { return } + pausedAt = max(startTs, now) + } + + mutating func resume(now: Int) { + guard let pausedAt else { return } + let gap = max(0, now - pausedAt) + pausedDuration += gap + stagePausedDuration += gap + if case .resting(let slot, let endsAt) = stage { stage = .resting(slot, endsAt: endsAt + gap) } + for i in history.indices { + history[i].stagePausedDuration += gap + if case .resting(let slot, let end) = history[i].stage { + history[i].stage = .resting(slot, endsAt: end + gap) + } + } + self.pausedAt = nil + } /// Undo stack: whole-state snapshots rather than inverse operations. A gym is a bad place to be /// one tap ahead of yourself, and restoring a snapshot cannot get the arithmetic wrong the way a @@ -151,6 +184,7 @@ struct LiftSessionEngine: Equatable { var stage: Stage var sets: [LiftRecordedSet] var stageStartedAt: Int + var stagePausedDuration: Int } init(plan: [LiftPlanItem], startTs: Int) { @@ -166,12 +200,16 @@ struct LiftSessionEngine: Equatable { /// The undo history is deliberately NOT restored: it is a within-sitting convenience, and a /// stack that survives a relaunch invites reaching back past a save boundary. init(restoring plan: [LiftPlanItem], startTs: Int, stage: Stage, - sets: [LiftRecordedSet], stageStartedAt: Int) { + sets: [LiftRecordedSet], stageStartedAt: Int, pausedAt: Int? = nil, + pausedDuration: Int = 0, stagePausedDuration: Int = 0) { self.plan = plan self.startTs = startTs self.stage = stage self.sets = sets self.stageStartedAt = stageStartedAt + self.pausedAt = pausedAt + self.pausedDuration = max(0, pausedDuration) + self.stagePausedDuration = max(0, stagePausedDuration) } // MARK: - The sheet @@ -260,7 +298,7 @@ struct LiftSessionEngine: Equatable { /// actually means: the user has not moved on yet. func restRemaining(now: Int) -> Int? { guard case .resting(_, let endsAt) = stage else { return nil } - return max(0, endsAt - now) + return max(0, endsAt - (pausedAt ?? now)) } /// The nearest earlier set of this exercise already performed in THIS session. Nil for the first @@ -322,15 +360,17 @@ struct LiftSessionEngine: Equatable { /// because nothing was finished. Starting an already-completed slot re-opens it for a redo, /// dropping its previous record so the set is not counted twice. mutating func start(_ slot: LiftSlot, now: Int) { - guard planItem(for: slot) != nil else { return } + guard !isPaused, planItem(for: slot) != nil else { return } pushHistory() sets.removeAll { $0.slot == slot } stage = .working(slot) stageStartedAt = now + stagePausedDuration = 0 } /// The big button. Context decides what it means. mutating func advance(now: Int) { + guard !isPaused else { return } switch stage { case .warmup: guard let next = nextPendingSlot else { return } @@ -346,22 +386,25 @@ struct LiftSessionEngine: Equatable { let rest = planItem(for: slot)?.restSec ?? LiftPlanItem.defaultRestSec stage = .resting(slot, endsAt: now + rest) stageStartedAt = now + stagePausedDuration = 0 case .resting(let slot, _): pushHistory() // Record what was ACTUALLY rested — the figure each set row shows, and the one thing // only the taps can know. if let i = sets.firstIndex(where: { $0.slot == slot }) { - sets[i].restSec = max(0, now - stageStartedAt) + sets[i].restSec = stageElapsed(now: now) } if let next = slotAfter(slot) { stage = .working(next) stageStartedAt = now + stagePausedDuration = 0 } else { // Sheet complete: stay put rather than inventing a stage. The user finishes when // they are ready, and the time until then is the cool-down. stage = .resting(slot, endsAt: now) stageStartedAt = now + stagePausedDuration = 0 } case .finished: @@ -453,22 +496,25 @@ struct LiftSessionEngine: Equatable { pushHistory() if case .resting(let slot, _) = stage, let i = sets.firstIndex(where: { $0.slot == slot }), sets[i].restSec == nil { - sets[i].restSec = max(0, now - stageStartedAt) + sets[i].restSec = stageElapsed(now: now) } stage = .finished stageStartedAt = now + stagePausedDuration = 0 } mutating func undo() { + guard !isPaused else { return } guard let previous = history.popLast() else { return } plan = previous.plan stage = previous.stage sets = previous.sets stageStartedAt = previous.stageStartedAt + stagePausedDuration = previous.stagePausedDuration } private mutating func pushHistory() { history.append(Snapshot(plan: plan, stage: stage, - sets: sets, stageStartedAt: stageStartedAt)) + sets: sets, stageStartedAt: stageStartedAt, stagePausedDuration: stagePausedDuration)) } } diff --git a/Strand/Data/LiftSessionPersistence.swift b/Strand/Data/LiftSessionPersistence.swift index e6017fe333..90e0a4e2fd 100644 --- a/Strand/Data/LiftSessionPersistence.swift +++ b/Strand/Data/LiftSessionPersistence.swift @@ -35,6 +35,12 @@ enum LiftSessionPersistence { /// as absent and the session resumes with nothing pending, which is exactly right. var pendingValues: [PendingValue]? var pendingWarmups: [SlotBox]? + var sessionID: String? = nil + var pausedAt: Int? = nil + var pausedDuration: Int? = nil + var stagePausedDuration: Int? = nil + var pausedForPulseLoss: Bool? = nil + var sessionRpeText: String? = nil struct PendingValue: Codable, Equatable { var exerciseIndex: Int @@ -176,7 +182,10 @@ enum LiftSessionPersistence { }, pendingWarmups: pendingWarmups .sorted { ($0.exerciseIndex, $0.setIndex) < ($1.exerciseIndex, $1.setIndex) } - .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }) + .map { Snapshot.SlotBox(exerciseIndex: $0.exerciseIndex, setIndex: $0.setIndex) }, + pausedAt: engine.pausedAt, + pausedDuration: engine.pausedDuration == 0 ? nil : engine.pausedDuration, + stagePausedDuration: engine.stagePausedDuration == 0 ? nil : engine.stagePausedDuration) } /// The numbers typed in advance, back as the controller holds them. @@ -219,7 +228,9 @@ enum LiftSessionPersistence { restSec: $0.restSec) } return LiftSessionEngine(restoring: plan, startTs: s.startSec, - stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt) + stage: unbox(s.stage), sets: sets, stageStartedAt: s.stageStartedAt, + pausedAt: s.pausedAt, pausedDuration: s.pausedDuration ?? 0, + stagePausedDuration: s.stagePausedDuration ?? 0) } private static func box(_ stage: LiftSessionEngine.Stage) -> Snapshot.StageBox { diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 3b05714075..bfe7ba7034 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -243,7 +243,10 @@ final class Repository: ObservableObject { workoutsLog(build()) } - init(deviceId: String) { self.deviceId = deviceId } + init(deviceId: String, store: WhoopStore? = nil) { + self.deviceId = deviceId + self.store = store + } /// Re-point the read model's ACTIVE-strap id at the device registry's active device, so a re-added /// strap's LIVE raw (written under its fresh "whoop-" id) surfaces on the dashboard (#814). diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..6ca969c2e3 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,8 @@ { "sourceLanguage": "en", "strings": { + "Training could not be saved": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training could not be saved"}}, "de": {"stringUnit": {"state": "translated", "value": "Training konnte nicht gespeichert werden"}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo guardar el entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible d’enregistrer la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível guardar o treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile salvare l’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się zapisać treningu"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось сохранить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法保存训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法儲存訓練"}}}}, + "Your paused training is still available. Please try again.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Your paused training is still available. Please try again."}}, "de": {"stringUnit": {"state": "translated", "value": "Dein pausiertes Training bleibt erhalten. Bitte versuche es erneut."}}, "es": {"stringUnit": {"state": "translated", "value": "Tu entrenamiento pausado sigue disponible. Vuelve a intentarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Votre séance en pause est conservée. Réessayez."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O treino em pausa continua disponível. Tente novamente."}}, "it": {"stringUnit": {"state": "translated", "value": "L’allenamento in pausa è conservato. Riprova."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wstrzymany trening jest zachowany. Spróbuj ponownie."}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренировка на паузе сохранена. Повторите попытку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已暂停的训练仍然保留。请重试。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已暫停的訓練仍然保留。請重試。"}}}}, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/LiftLogView.swift b/Strand/Screens/LiftLogView.swift index ce72933b6f..2be4ed6d7e 100644 --- a/Strand/Screens/LiftLogView.swift +++ b/Strand/Screens/LiftLogView.swift @@ -186,36 +186,7 @@ struct LiftLogView: View { /// Flatten a program into the plan the session runs. The plan is SNAPSHOT at start: editing or /// deleting the program mid-session cannot change what is being tapped through. private func start(_ program: LiftProgramRow) async { - guard let store = await repo.storeHandle() else { return } - let items = (try? await store.liftProgramItems(programId: program.id)) ?? [] - guard !items.isEmpty else { return } - let vocabulary = (try? await store.liftExercises(deviceId: repo.deviceId)) ?? [] - - let plan = items.map { item -> LiftPlanItem in - // The classification comes from the exercise vocabulary, which is the one place that owns - // it — the program line deliberately stores no muscle of its own to drift from. - let known = vocabulary.first { $0.name == item.exercise } - return LiftPlanItem(exercise: item.exercise, - primaryMuscle: known?.primaryMuscle, - secondaryMuscles: known?.secondaryMuscles ?? [], - targetSets: item.targetSets, - restSec: item.restSec, - targetRepsLow: item.targetRepsLow, - targetRepsHigh: item.targetRepsHigh, - targetRpe: item.targetRpe, - targetWeightKg: item.targetWeightKg, - note: item.note, - // Carried so a set added or dropped mid-session can be written back - // onto the line it came from, and be there next time. - programItemId: item.id) - } - // Refuse to start a second session over a running one: two live sessions would both claim - // the strap gesture and both write the in-flight snapshot. - guard !session.isActive else { - session.isPresented = true - return - } - session.start(plan: plan, programId: program.id, programName: program.name) + try? await session.start(program: program, repo: repo) } // MARK: - This week, per muscle diff --git a/Strand/Screens/LiftSessionBar.swift b/Strand/Screens/LiftSessionBar.swift index a4a01f6bcb..216252393d 100644 --- a/Strand/Screens/LiftSessionBar.swift +++ b/Strand/Screens/LiftSessionBar.swift @@ -77,8 +77,8 @@ struct LiftSessionBar: View { // The same action the sheet's button performs, so a set can be closed out // without opening anything. - Button { session.advance() } label: { - Image(systemName: "checkmark.circle.fill") + Button { if engine.isPaused { session.resume() } else { session.advance() } } label: { + Image(systemName: engine.isPaused ? "play.circle.fill" : "checkmark.circle.fill") .font(.system(size: 26, weight: .semibold)) .foregroundStyle(tint(engine)) } @@ -108,6 +108,6 @@ struct LiftSessionBar: View { /// Rest counts DOWN (that is the number you act on); everything else counts up. Written as the Lock /// Screen writes the same clock — "0:45", "0:00", "1:05:00" — through NOOP's one running-clock format. private func bigClock(_ engine: LiftSessionEngine) -> LiftRunningClock { - LiftRunningClock { now in engine.restRemaining(now: now) ?? now - engine.stageStartedAt } + LiftRunningClock { now in engine.restRemaining(now: now) ?? engine.stageElapsed(now: now) } } } diff --git a/Strand/Screens/LiftSessionDetailSheet.swift b/Strand/Screens/LiftSessionDetailSheet.swift index 4871ef0b6e..7d0cfa844d 100644 --- a/Strand/Screens/LiftSessionDetailSheet.swift +++ b/Strand/Screens/LiftSessionDetailSheet.swift @@ -40,6 +40,7 @@ struct LiftSessionDetailSheet: View { private var unitSystem: UnitSystem { UnitSystem(rawValue: unitSystemRaw) ?? .metric } private var durationSec: Int { + if let duration = workout?.durationS { return max(0, Int(duration)) } guard let end = session.endTs else { return 0 } return max(0, end - session.startTs) } diff --git a/Strand/Screens/LiftSessionView.swift b/Strand/Screens/LiftSessionView.swift index 6c6e78c928..d952ce64e6 100644 --- a/Strand/Screens/LiftSessionView.swift +++ b/Strand/Screens/LiftSessionView.swift @@ -30,8 +30,8 @@ struct LiftSessionView: View { @State private var showingFinish = false @State private var confirmingDiscard = false - @State private var sessionRpeText = "" @State private var saving = false + @State private var saveError = false /// The two questions finishing can ask. Nil until answered: saving waits for an answer rather than /// deciding for the user. @State private var unfinishedChoice: UnfinishedChoice? @@ -91,7 +91,7 @@ struct LiftSessionView: View { .dismissesKeyboardOnTap($focused) // Re-read whenever the session's exercises change, so an exercise added mid-session that was // done before shows last time's numbers in grey, like every other line. - .task(id: engine?.plan.map(\.exercise)) { await loadLastTime() } + .task(id: engine?.plan.map(\.exercise)) { await session.loadLastSession(repo: repo) } // Release a field's draft once the user leaves it, so the row returns to the canonical // formatting ("45.50" typed becomes "45.5"). The single-argument form on purpose: the // two-argument `onChange` is macOS 14+ and this file also builds for macOS 13. @@ -550,8 +550,11 @@ struct LiftSessionView: View { } HStack(spacing: NoopMetrics.rowSpacing) { - Button { session.advance() } label: { - Text(actionLabel(engine)).frame(maxWidth: .infinity) + if !engine.isPaused { + Button("Pause") { session.pause() }.buttonStyle(.noopSecondary) + } + Button { if engine.isPaused { session.resume() } else { session.advance() } } label: { + Text(engine.isPaused ? "Resume" : actionLabel(engine)).frame(maxWidth: .infinity) } .buttonStyle(.noopPrimary) @@ -607,7 +610,7 @@ struct LiftSessionView: View { switch engine.stage { case .working: clock(String(localized: "This set"), tint: StrandPalette.statusPositive) { - $0 - engine.stageStartedAt + engine.stageElapsed(now: $0) } case .resting: // "Rest period", never "Rest": the catalog's "Rest" key is NOOP's SLEEP metric, so this @@ -618,7 +621,7 @@ struct LiftSessionView: View { } case .warmup, .finished: clock(String(localized: "Warm-up"), tint: StrandPalette.textSecondary) { - $0 - engine.stageStartedAt + engine.stageElapsed(now: $0) } } } @@ -645,7 +648,7 @@ struct LiftSessionView: View { NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.gap) { Text("How hard was the whole session? (1–10)").strandOverline() - TextField("7", text: $sessionRpeText) + TextField("7", text: $session.sessionRpeText) .textFieldStyle(.plain) .font(StrandFont.bodyNumber) .foregroundStyle(StrandPalette.textPrimary) @@ -705,6 +708,9 @@ struct LiftSessionView: View { #endif .background(StrandPalette.surfaceBase) .keyboardDoneToolbar($focused) + .alert("Training could not be saved", isPresented: $saveError) { + Button("OK", role: .cancel) {} + } message: { Text("Your paused training is still available. Please try again.") } .task { await loadSetCountChanges() } } @@ -789,89 +795,20 @@ struct LiftSessionView: View { // MARK: - Loading and saving - /// What was lifted for each of this session's exercises LAST time, by set number — the middle - /// layer of the grey numbers, handed to the controller that owns the chain. - private func loadLastTime() async { - guard let engine, let store = await repo.storeHandle() else { return } - var out: [String: [Int: LiftSetCarry]] = [:] - // One query per DISTINCT exercise, not per plan line. A program that programs the same - // movement twice — or an imported one with many lines — would otherwise re-ask the store the - // same question, and this runs when the sheet opens. - for exercise in NSOrderedSet(array: engine.plan.map(\.exercise)).compactMap({ $0 as? String }) { - let rows = (try? await store.lastLiftSets(deviceId: repo.deviceId, - exercise: exercise, - before: engine.startTs)) ?? [] - var bySet: [Int: LiftSetCarry] = [:] - for r in rows where !r.isWarmup { - bySet[r.setIndex] = LiftSetCarry(weightKg: r.weightKg, reps: r.reps) - } - out[exercise] = bySet - } - session.setLastSession(out) - } - private func save() async { guard !saving, let store = await repo.storeHandle() else { return } saving = true defer { saving = false } - session.finish() - guard let engine = session.engine else { return } - let endTs = Int(Date().timeIntervalSince1970) - let sessionId = UUID().uuidString - // After `finish`, which closes out the running rest: that set's measured rest belongs to it. - let finished = session.setsToSave(completingUnfinished: unfinishedChoice == .complete) - - // Nothing to file, so file nothing. With no set done, "Discard them" turns every set into a zero. - // Filing that anyway wrote a session with nothing in it AND a manual workout, and the engine fills - // that workout's strain from the heart rate the strap measured — so an hour that recorded nothing - // still read back as a workout. The finish sheet says so before Save. The program's set counts - // are a separate thing the user chose explicitly, so those still apply. - guard LiftSessionController.anyPerformed(finished) else { - await writeProgram(store: store, plan: engine.plan, sets: finished) - await finishAndDismiss() + do { + let saved = try await session.save(repo: repo, completingUnfinished: unfinishedChoice == .complete, + sessionRpe: LiftFormat.number(session.sessionRpeText)) + await writeProgram(store: store, plan: saved.plan, sets: saved.sets) + } catch { + saveError = true return } - let row = LiftSessionRow( - id: sessionId, deviceId: repo.deviceId, - startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, - programId: session.programId, - // Snapshot the name: renaming or deleting the program never rewrites this session. - programName: session.programName, - sessionRpe: LiftFormat.number(sessionRpeText), - note: session.programName) - _ = try? await store.upsertLiftSessions([row]) - - // `ord` is COMPLETION order, which with out-of-order work is not the plan's order — and it - // is the order that actually happened, which is what a session should read back as. Sets - // completed at finish without being started come last. - let rows = finished.enumerated().map { ord, s -> LiftSetRow in - let item = engine.planItem(for: s.slot) - return LiftSetRow( - id: UUID().uuidString, deviceId: repo.deviceId, sessionId: sessionId, - ord: ord, exercise: item?.exercise ?? "", - // Snapshot the classification AS IT WAS, so reclassifying later never rewrites what - // past weeks were counted as. - primaryMuscle: item?.primaryMuscle, - secondaryMuscles: item?.secondaryMuscles ?? [], - setIndex: s.slot.setIndex, weightKg: s.weightKg, reps: s.reps, rpe: s.rpe, - isWarmup: s.isWarmup, startTs: s.startTs, endTs: s.endTs, - restSec: s.restSec, note: nil) - } - _ = try? await store.upsertLiftSets(rows) - await writeProgram(store: store, plan: engine.plan, sets: finished) - - // Through the SAME path a manual workout takes, so it inherits overlap dedup, the engine's - // HR-derived strain fill and delete/merge. `strain` stays nil deliberately: the engine fills - // it from the heart rate the strap MEASURED, never from typed sets and reps. - let workout = WorkoutRow( - startTs: engine.startTs, endTs: endTs, sport: LiftSessionView.sport, - source: "manual", durationS: Double(max(0, endTs - engine.startTs)), - energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, - distanceM: nil, zonesJSON: nil, notes: session.programName, steps: nil) - await repo.saveManualWorkout(workout) - await finishAndDismiss() } diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index a57a3364d3..4a402d6b09 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -38,6 +38,7 @@ struct LiveWorkoutView: View { /// control must not end the workout instantly with no way back. @State private var showEndConfirm = false @State private var showDeleteConfirm = false + @State private var showSaveError = false private var zoneSet: HRZoneSet { model.profile.hrZoneSet } private var zone: Int { model.bpm.map { zoneSet.zoneNumber(forBPM: Double($0)) } ?? 0 } @@ -112,12 +113,17 @@ struct LiveWorkoutView: View { isPresented: $showEndConfirm) { Button("Cancel", role: .cancel) { } Button("End", role: .destructive) { - model.endWorkout() - onClose() + Task { + do { try await model.finishWorkout(); onClose() } + catch { showSaveError = true } + } } } message: { Text("This stops recording and saves what's captured so far. It can't be resumed.") } + .alert("Training could not be saved", isPresented: $showSaveError) { + Button("OK", role: .cancel) {} + } message: { Text("Your paused training is still available. Please try again.") } .confirmationDialog("Delete", isPresented: $showDeleteConfirm, titleVisibility: .visible) { Button("Delete", role: .destructive) { diff --git a/StrandTests/ActiveWorkoutPersistenceTests.swift b/StrandTests/ActiveWorkoutPersistenceTests.swift index c661c74366..090910dd6a 100644 --- a/StrandTests/ActiveWorkoutPersistenceTests.swift +++ b/StrandTests/ActiveWorkoutPersistenceTests.swift @@ -36,7 +36,9 @@ final class ActiveWorkoutPersistenceTests: XCTestCase { // MARK: - pure codec round-trip func testEncodeDecodeRoundTripsEveryField() { - let original = snapshot(pausedAtSec: 1_700_000_120, pausedDurationSec: 45) + var original = snapshot(pausedAtSec: 1_700_000_120, pausedDurationSec: 45) + original.sessionID = "stable-widget-session" + original.pausedForPulseLoss = true let decoded = ActiveWorkoutPersistence.decode(ActiveWorkoutPersistence.encode(original)) XCTAssertEqual(decoded, original) } diff --git a/StrandTests/LiftSessionHistoryLoadingTests.swift b/StrandTests/LiftSessionHistoryLoadingTests.swift new file mode 100644 index 0000000000..c9823bd444 --- /dev/null +++ b/StrandTests/LiftSessionHistoryLoadingTests.swift @@ -0,0 +1,47 @@ +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class LiftSessionHistoryLoadingTests: XCTestCase { + func testAddingAnExerciseLoadsItsPreviousSets() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try await WhoopStore(path: directory.appendingPathComponent("history.sqlite").path) + let repo = Repository(deviceId: "history-test", store: store) + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { controller.discard() } + let start = Int(Date().timeIntervalSince1970) - 3_600 + _ = try await store.upsertLiftSessions([ + .init(id: "previous", deviceId: repo.deviceId, startTs: start, endTs: start + 600, + sport: "Strength", programId: nil, programName: nil, sessionRpe: nil, note: nil) + ]) + _ = try await store.upsertLiftSets([ + .init(id: "previous-fly", deviceId: repo.deviceId, sessionId: "previous", ord: 0, + exercise: "Cable fly", primaryMuscle: .chest, setIndex: 1, weightKg: 15, reps: 12, + rpe: nil, isWarmup: false, startTs: start, endTs: start + 30, restSec: 60, note: nil) + ]) + controller.start(plan: [.init(exercise: "Bench press")], programId: nil, programName: nil) + await controller.loadLastSession(repo: repo) + XCTAssertTrue(controller.addExercise("Cable fly", primaryMuscle: .chest, secondaryMuscles: [])) + await controller.loadLastSession(repo: repo) + XCTAssertEqual(controller.carry(for: .init(exerciseIndex: 1, setIndex: 1)), + LiftSetCarry(weightKg: 15, reps: 12)) + } + + func testStartingWhileActivePresentsTheExistingSessionOnlyWhenRequested() async throws { + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { controller.discard() } + controller.start(plan: [.init(exercise: "Row")], programId: nil, programName: nil) + let id = controller.sessionID + let program = LiftProgramRow(id: "other", deviceId: "test", name: "Other", note: nil, + createdAt: 1, updatedAt: 1, archived: false) + controller.isPresented = false + try await controller.start(program: program, repo: Repository(deviceId: "test"), present: false) + XCTAssertFalse(controller.isPresented) + try await controller.start(program: program, repo: Repository(deviceId: "test")) + XCTAssertTrue(controller.isPresented) + XCTAssertEqual(controller.sessionID, id) + } +} diff --git a/StrandTests/LiftSessionSaveFailureTests.swift b/StrandTests/LiftSessionSaveFailureTests.swift new file mode 100644 index 0000000000..c4b53c8803 --- /dev/null +++ b/StrandTests/LiftSessionSaveFailureTests.swift @@ -0,0 +1,56 @@ +import XCTest +import WhoopStore +@testable import Strand + +@MainActor +final class LiftSessionSaveFailureTests: XCTestCase { + func testFailedWriteRetainsSessionAndRetryDoesNotDuplicateItsRows() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("training.sqlite").path + let store = try await WhoopStore(path: path) + let repo = Repository(deviceId: "training-test", store: store) + let controller = LiftSessionController(buzz: { _ in }, setStrapHandler: { _ in }) + defer { + controller.discard() + } + controller.start(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], programId: nil, programName: nil) + controller.advance() + controller.advance() + controller.sessionRpeText = "7" + let id = controller.sessionID + let start = try XCTUnwrap(controller.engine?.startTs) + try execute("CREATE TRIGGER fail_training_save BEFORE INSERT ON liftSet BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + do { + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + XCTFail("the database rejected the set write") + } catch {} + XCTAssertTrue(controller.isActive) + XCTAssertTrue(controller.engine?.isPaused == true) + XCTAssertEqual(LiftSessionPersistence.load()?.sessionID, id) + try execute("DROP TRIGGER fail_training_save", path: path) + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) + let sessions = try await store.liftSessions(deviceId: "training-test", fromTs: start, toTs: start) + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions.first?.id, id) + XCTAssertEqual(sessions.first?.sessionRpe, 7) + let sets = try await store.liftSets(sessionId: id) + XCTAssertEqual(sets.count, 2) + XCTAssertEqual(sets.filter { $0.startTs != nil }.count, 1, "an open set is never invented as completed") + controller.finishedSaving() + XCTAssertNil(controller.engine) + XCTAssertNil(LiftSessionPersistence.load()) + } + // Use macOS's SQLite CLI: GRDB is linked in the host app, not directly in the test bundle. + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } + +} diff --git a/StrandTests/LiftSessionTimingTests.swift b/StrandTests/LiftSessionTimingTests.swift index 192fde3831..437a24b544 100644 --- a/StrandTests/LiftSessionTimingTests.swift +++ b/StrandTests/LiftSessionTimingTests.swift @@ -40,14 +40,29 @@ final class LiftSessionTimingTests: XCTestCase { c.start(plan: plan(restSec: 2), programId: "p", programName: "Upper A") c.advance() // set 1 working c.advance() // set 1 done: a 2 s rest - XCTAssertEqual(c.presentation(system: .metric)?.status, "Resting after set 1") + let setIndex = 1 + XCTAssertEqual(c.presentation(system: .metric)?.status, String(localized: "Resting after set \(setIndex)")) var changes = 0 let watching = c.objectWillChange.sink { changes += 1 } RunLoop.main.run(until: Date().addingTimeInterval(3.2)) watching.cancel() XCTAssertEqual(changes, 1, "the rest's end, once") XCTAssertEqual(buzzes, [LiftSessionController.restWarningBuzzes], "the warning, once") - XCTAssertEqual(c.presentation(system: .metric)?.status, "Ready for the next set") + XCTAssertEqual(c.presentation(system: .metric)?.status, String(localized: "Ready for the next set")) + } + + func testPauseCancelsRestWarningAndPreservesTheRemainingTime() { + var buzzes: [UInt8] = [] + let controller = LiftSessionController(buzz: { buzzes.append($0) }, setStrapHandler: { _ in }) + controller.start(plan: plan(restSec: 2), programId: nil, programName: nil) + controller.advance() + controller.advance() + controller.pause() + let remaining = controller.engine?.restRemaining(now: LiftSessionController.unixNow) + RunLoop.main.run(until: Date().addingTimeInterval(2.5)) + XCTAssertEqual(buzzes, []) + XCTAssertEqual(controller.engine?.restRemaining(now: LiftSessionController.unixNow), remaining) + XCTAssertEqual(LiftSessionPersistence.load()?.pausedAt, controller.engine?.pausedAt) } /// When the warning and the end fire. A rest inside the warning window warns a second from now, clear of diff --git a/StrandTests/WorkoutSaveFailureTests.swift b/StrandTests/WorkoutSaveFailureTests.swift new file mode 100644 index 0000000000..e824674b14 --- /dev/null +++ b/StrandTests/WorkoutSaveFailureTests.swift @@ -0,0 +1,59 @@ +import XCTest +import WhoopStore +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSaveFailureTests: XCTestCase { + func testFailedWriteKeepsRecordedWorkoutRetryable() async throws { + let defaults = UserDefaults.standard + let keys = [ActiveWorkoutPersistence.defaultsKey] + let originals = keys.map { ($0, defaults.object(forKey: $0)) } + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + for (key, original) in originals { + if let original { defaults.set(original, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("workout.sqlite").path + let store = try await WhoopStore(path: path) + let model = AppModel() + model.repo.setStoreForTesting(store) + let started = Date().addingTimeInterval(-600) + model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + model.activeWorkout?.samples = [HRSample(ts: Int(started.timeIntervalSince1970), bpm: 140), + HRSample(ts: Int(started.timeIntervalSince1970) + 120, bpm: 140)] + let id = try XCTUnwrap(model.activeWorkout?.sessionID) + try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {} + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id) + try execute("DROP TRIGGER fail_workout_save", path: path) + try await model.finishWorkout() + try await model.finishWorkout() + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + let rows = try await store.workouts(deviceId: model.deviceId, from: Int(started.timeIntervalSince1970), + to: Int(Date().timeIntervalSince1970), limit: 10) + XCTAssertEqual(rows.count, 1) + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(row.sport, "Strength") + XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) + XCTAssertEqual(row.avgHr, 140) + } + + private func execute(_ sql: String, path: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [path, sql] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { throw CocoaError(.fileWriteUnknown) } + } +} From b27493a229625f066c9fd3ab1cc257d7aa35dbdf Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:47:57 +0200 Subject: [PATCH 2/5] Reconcile Lift rows on save retry and retain discard diagnostics --- Strand/App/AppModel.swift | 8 +++++++- Strand/Data/LiftSessionController.swift | 3 +++ StrandTests/LiftSessionSaveFailureTests.swift | 10 +++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 711b8b1d15..24f492d58d 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -1013,7 +1013,13 @@ final class AppModel: ObservableObject { route = gpsRecorder.capturedRoute() } let samples = w.samples - guard samples.count >= 2 || route != nil else { clearActiveWorkout(); return } + guard samples.count >= 2 || route != nil else { + emitWorkoutsTrace(WorkoutsTrace.sessionLine( + event: "discarded", sportKey: WorkoutSource.traceSportKey(w.sport), + hrSamples: samples.count, gpsPoints: route == nil ? 0 : nil)) + clearActiveWorkout() + return + } let end = Date() // A session under a minute is a start/stop the wearer did not mean to keep, and it was the thing // that made deletion feel broken: the list filled with 5-30 second entries (#2278). Discarded HERE, diff --git a/Strand/Data/LiftSessionController.swift b/Strand/Data/LiftSessionController.swift index 2d0a7a8031..ad9ddb4251 100644 --- a/Strand/Data/LiftSessionController.swift +++ b/Strand/Data/LiftSessionController.swift @@ -217,7 +217,10 @@ final class LiftSessionController: ObservableObject { weightKg: set.weightKg, reps: set.reps, rpe: set.rpe, isWarmup: set.isWarmup, startTs: set.startTs, endTs: set.endTs, restSec: set.restSec, note: nil) } + let existing = try await store.liftSets(sessionId: sessionID) + let savedIDs = Set(rows.map(\.id)) _ = try await store.upsertLiftSets(rows) + _ = try await store.deleteLiftSets(ids: existing.filter { !savedIDs.contains($0.id) }.map(\.id)) let workout = WorkoutRow(startTs: finishedEngine.startTs, endTs: endTs, sport: "Strength", source: "manual", durationS: Double(duration), energyKcal: nil, avgHr: nil, maxHr: nil, strain: nil, distanceM: nil, zonesJSON: nil, notes: programName, steps: nil) diff --git a/StrandTests/LiftSessionSaveFailureTests.swift b/StrandTests/LiftSessionSaveFailureTests.swift index c4b53c8803..9bf2fb5035 100644 --- a/StrandTests/LiftSessionSaveFailureTests.swift +++ b/StrandTests/LiftSessionSaveFailureTests.swift @@ -21,14 +21,18 @@ final class LiftSessionSaveFailureTests: XCTestCase { controller.sessionRpeText = "7" let id = controller.sessionID let start = try XCTUnwrap(controller.engine?.startTs) - try execute("CREATE TRIGGER fail_training_save BEFORE INSERT ON liftSet BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) + try execute("CREATE TRIGGER fail_training_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) do { _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) - XCTFail("the database rejected the set write") + XCTFail("the database rejected the workout write") } catch {} XCTAssertTrue(controller.isActive) XCTAssertTrue(controller.engine?.isPaused == true) XCTAssertEqual(LiftSessionPersistence.load()?.sessionID, id) + let partiallySaved = try await store.liftSets(sessionId: id) + XCTAssertEqual(partiallySaved.count, 2) + XCTAssertEqual(partiallySaved.filter { $0.startTs != nil }.count, 1) + XCTAssertTrue(controller.removeSet(fromExercise: 0)) try execute("DROP TRIGGER fail_training_save", path: path) _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) _ = try await controller.save(repo: repo, completingUnfinished: false, sessionRpe: nil) @@ -37,7 +41,7 @@ final class LiftSessionSaveFailureTests: XCTestCase { XCTAssertEqual(sessions.first?.id, id) XCTAssertEqual(sessions.first?.sessionRpe, 7) let sets = try await store.liftSets(sessionId: id) - XCTAssertEqual(sets.count, 2) + XCTAssertEqual(sets.count, 1, "a pending set removed after a failed save must not remain in storage") XCTAssertEqual(sets.filter { $0.startTs != nil }.count, 1, "an open set is never invented as completed") controller.finishedSaving() XCTAssertNil(controller.engine) From 0175257ce08db808b5aa635aa4d2b317a989a70c Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:30 +0200 Subject: [PATCH 3/5] Add iPhone training favorites and interactive session controls --- .../Sources/StrandDesign/SportIcon.swift | 1 + Strand/App/LiveSessionRunner.swift | 2 +- Strand/App/NavRouter.swift | 9 + Strand/App/RootView.swift | 1 + Strand/App/TrainingPulseGuard.swift | 34 ++ Strand/BLE/BLEManager.swift | 3 +- Strand/BLE/LiveState.swift | 4 + Strand/Resources/Localizable.xcstrings | 29 ++ Strand/Screens/SettingsView.swift | 7 +- Strand/System/NotificationPresenter.swift | 8 + StrandTests/FrameIntegrityGateTests.swift | 18 + StrandTests/TrainingPulseGuardTests.swift | 119 ++++++ StrandTests/TrainingSnapshotTests.swift | 93 +++++ StrandiOS/App/RootTabView.swift | 32 +- StrandiOS/App/StrandiOSApp.swift | 65 +--- StrandiOS/App/TrainingFavoritesView.swift | 108 ++++++ .../Widgets/LiftLiveActivityController.swift | 17 +- StrandiOS/Widgets/TrainingCoordinator.swift | 361 ++++++++++++++++++ StrandiOSShared/LiftActivityAttributes.swift | 2 + StrandiOSShared/TrainingIntents.swift | 50 +++ StrandiOSShared/TrainingSnapshot.swift | 75 ++++ .../WorkoutActivityAttributes.swift | 10 + StrandiOSWidgets/LiftLiveActivity.swift | 175 ++++----- StrandiOSWidgets/NOOPWidgetBundle.swift | 2 + StrandiOSWidgets/TrainingControls.swift | 91 +++++ StrandiOSWidgets/TrainingWidget.swift | 195 ++++++++++ StrandiOSWidgets/WorkoutLiveActivity.swift | 83 ++++ project.yml | 2 + 28 files changed, 1433 insertions(+), 163 deletions(-) create mode 100644 Strand/App/TrainingPulseGuard.swift create mode 100644 StrandTests/TrainingPulseGuardTests.swift create mode 100644 StrandTests/TrainingSnapshotTests.swift create mode 100644 StrandiOS/App/TrainingFavoritesView.swift create mode 100644 StrandiOS/Widgets/TrainingCoordinator.swift create mode 100644 StrandiOSShared/TrainingIntents.swift create mode 100644 StrandiOSShared/TrainingSnapshot.swift create mode 100644 StrandiOSShared/WorkoutActivityAttributes.swift create mode 100644 StrandiOSWidgets/TrainingControls.swift create mode 100644 StrandiOSWidgets/TrainingWidget.swift create mode 100644 StrandiOSWidgets/WorkoutLiveActivity.swift diff --git a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift index 2ecb3548a1..87e1d9bea9 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift @@ -598,6 +598,7 @@ public struct WorkoutTypeIcon: View { switch WorkoutTypeIconography.glyph(for: type) { case .system(let name): Image(systemName: name) + .resizable().scaledToFit() .font(.system(size: size, weight: weight)) .symbolRenderingMode(.monochrome) case .custom(let custom): diff --git a/Strand/App/LiveSessionRunner.swift b/Strand/App/LiveSessionRunner.swift index c9e81941e1..aa0481cd7e 100644 --- a/Strand/App/LiveSessionRunner.swift +++ b/Strand/App/LiveSessionRunner.swift @@ -34,7 +34,7 @@ final class LiveSessionRunner: ObservableObject { /// Continuous stale time (seconds) after which the session ends itself — the strap is gone, and a /// "guarding" screen over no data would be a false promise. - static let autoEndStaleSec = 600 + static let autoEndStaleSec = TrainingPulseGuard.timeoutSeconds /// Where the guarded HR came from. v1 only coaches the strap's live feed (see `LiveSessionRow.hrSource`). static let hrSource = "whoop" /// The UI/staleness tick does not need an exact wakeup. A ten-percent tolerance lets the system diff --git a/Strand/App/NavRouter.swift b/Strand/App/NavRouter.swift index e5c6f6b76b..ec399e83c9 100644 --- a/Strand/App/NavRouter.swift +++ b/Strand/App/NavRouter.swift @@ -35,6 +35,7 @@ final class NavRouter: ObservableObject { /// Also the K5 scheduled morning-brief notification's tap-through target. case coach case alarms + case trainingFavorites var id: String { rawValue } @@ -70,6 +71,14 @@ final class NavRouter: ObservableObject { func openCoach() { requestedDestination = .coach } /// Open the existing wake-alarm and wind-down settings from Sleep. func openAlarms() { requestedDestination = .alarms } + func openTrainingFavorites() { requestedDestination = .trainingFavorites } + + func consumeTrainingFavoritesRequest(isReady: Bool) -> Bool { + guard isReady, requestedDestination == .trainingFavorites else { return false } + requestedDestination = nil + return true + } + /// Open the v5 Insights hub (the n-of-1 "what moves your Charge" surface). func openInsightsHub() { requestedDestination = .insightsHub } /// Open the Lab Book (private health-records logbook). diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift index 97e239d341..e655264dcf 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -335,6 +335,7 @@ struct RootView: View { // stays in exactly one place. case .coach: selection = .coach case .alarms: selection = .smartAlarm + case .trainingFavorites: selection = .settings case nil: break } if dest != nil { router.requestedDestination = nil } diff --git a/Strand/App/TrainingPulseGuard.swift b/Strand/App/TrainingPulseGuard.swift new file mode 100644 index 0000000000..f299001ff0 --- /dev/null +++ b/Strand/App/TrainingPulseGuard.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Freshness of accepted live samples, independent of the displayed heart-rate value. +struct TrainingPulseGuard: Codable, Equatable { + static let timeoutSeconds = 600 + static let checkpointSeconds = 30 + var lastSampleSec: Int? + var recoveryAllowance = 0 + var disconnected: Bool? = nil + var reconnectedAtSec: Int? = nil + + var deadline: Int? { + let allowance = min(Self.checkpointSeconds, max(0, recoveryAllowance)) + guard disconnected != true, let lastSampleSec, lastSampleSec > 0 else { return nil } + let reference = max(lastSampleSec, reconnectedAtSec ?? lastSampleSec) + guard reference > 0, + reference <= Int.max - Self.timeoutSeconds - allowance - Self.checkpointSeconds else { return nil } + return reference + Self.timeoutSeconds + allowance + } + func isDue(at now: Int) -> Bool { deadline.map { now >= $0 } ?? false } + mutating func receive(at now: Int) { lastSampleSec = now; recoveryAllowance = 0; reconnectedAtSec = nil } + mutating func resume(at now: Int) { if lastSampleSec != nil { receive(at: now) } } + mutating func setConnected(_ connected: Bool, at now: Int) { + if !connected { disconnected = true; reconnectedAtSec = nil } + else if disconnected == true { + disconnected = false + // A connection is not a sample; allow a new window without claiming a fresh pulse. + reconnectedAtSec = now + recoveryAllowance = 0 + } + } + // A checkpoint can lag receipt by 30 seconds. Never pause early after a process restart. + var checkpoint: Self { var copy = self; copy.recoveryAllowance = Self.checkpointSeconds; return copy } +} diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 053813a014..64baecf611 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -5700,6 +5700,8 @@ public final class BLEManager: NSObject, ObservableObject { log("HR: skin contact \(m.contact.rawValue) (was \(lastLoggedHRContact?.rawValue ?? "unknown")), \(m.hr) bpm") lastLoggedHRContact = m.contact } + // Reconcile a training deadline before either biometric publisher can capture a sample. + if readable { state.noteReadableHeartRate() } if !rr.isEmpty, readable { state.setRRIntervals(rr) } // A run of unreadable samples clears the shown heart rate instead of leaving the last one standing. if heartRateReadability.clearsShownHeartRate(bpm: m.hr, contact: m.contact), state.heartRate != nil { @@ -5712,7 +5714,6 @@ public final class BLEManager: NSObject, ObservableObject { // live perf: only publish on a real change so a steady resting HR doesn't re-render the whole // Live console every second. if readable { - state.noteReadableHeartRate() if state.heartRate != m.hr { state.heartRate = m.hr } } // Record it continuously — independent of the realtime stream or the open screen. diff --git a/Strand/BLE/LiveState.swift b/Strand/BLE/LiveState.swift index 266740857a..02fd473f5d 100644 --- a/Strand/BLE/LiveState.swift +++ b/Strand/BLE/LiveState.swift @@ -729,10 +729,14 @@ public final class LiveState: ObservableObject { private var heartRateSilenceTimer: DispatchSourceTimer? private var heartRateSilenceArmedAt: DispatchTime? + /// Receipt observers run before biometric publication; they do not invalidate SwiftUI views. + public var onReadableHeartRate: ((Date) -> Void)? + /// A readable heart-rate sample arrived (`BLEManager`'s standard profile, `FrameRouter`'s realtime frames): move the /// silence deadline on. One timer, rescheduled at most every tenth of the wait (once a second in use), so it costs /// nothing while samples flow and fires once when they stop. public func noteReadableHeartRate() { + onReadableHeartRate?(Date()) let now = DispatchTime.now() let rearmNanos = UInt64(heartRateSilence / 10 * 1_000_000_000) if let armed = heartRateSilenceArmedAt, now.uptimeNanoseconds &- armed.uptimeNanoseconds < rearmNanos { return } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 6ca969c2e3..e8fea952a1 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,8 +1,37 @@ { "sourceLanguage": "en", "strings": { + "Training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training"}}, "de": {"stringUnit": {"state": "translated", "value": "Training"}}, "es": {"stringUnit": {"state": "translated", "value": "Entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Entraînement"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trening"}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренировка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "訓練"}}}}, + "Strength training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Strength training"}}, "de": {"stringUnit": {"state": "translated", "value": "Krafttraining"}}, "es": {"stringUnit": {"state": "translated", "value": "Entrenamiento de fuerza"}}, "fr": {"stringUnit": {"state": "translated", "value": "Musculation"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Treino de força"}}, "it": {"stringUnit": {"state": "translated", "value": "Allenamento di forza"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trening siłowy"}}, "ru": {"stringUnit": {"state": "translated", "value": "Силовая тренировка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "力量训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "力量訓練"}}}}, + "Running workout": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Running workout"}}, "de": {"stringUnit": {"state": "translated", "value": "Laufen"}}, "es": {"stringUnit": {"state": "translated", "value": "Carrera"}}, "fr": {"stringUnit": {"state": "translated", "value": "Course à pied"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Corrida"}}, "it": {"stringUnit": {"state": "translated", "value": "Corsa"}}, "pl": {"stringUnit": {"state": "translated", "value": "Bieganie"}}, "ru": {"stringUnit": {"state": "translated", "value": "Бег"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "跑步"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "跑步"}}}}, + "Control training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Control training"}}, "de": {"stringUnit": {"state": "translated", "value": "Training steuern"}}, "es": {"stringUnit": {"state": "translated", "value": "Controlar entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Contrôler la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Controlar treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Controlla allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Steruj treningiem"}}, "ru": {"stringUnit": {"state": "translated", "value": "Управление тренировкой"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "控制训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "控制訓練"}}}}, + "Training favorite": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training favorite"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsfavorit"}}, "es": {"stringUnit": {"state": "translated", "value": "Entrenamiento favorito"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séance favorite"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Treino favorito"}}, "it": {"stringUnit": {"state": "translated", "value": "Allenamento preferito"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony trening"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранная тренировка"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "训练收藏"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "訓練收藏"}}}}, + "Training favorites": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training favorites"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsfavoriten"}}, "es": {"stringUnit": {"state": "translated", "value": "Entrenamientos favoritos"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séances favorites"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Treinos favoritos"}}, "it": {"stringUnit": {"state": "translated", "value": "Allenamenti preferiti"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubione treningi"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранные тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "训练收藏"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "訓練收藏"}}}}, + "Favorite": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite"}}, "de": {"stringUnit": {"state": "translated", "value": "Favorit"}}, "es": {"stringUnit": {"state": "translated", "value": "Favorito"}}, "fr": {"stringUnit": {"state": "translated", "value": "Favori"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Favorito"}}, "it": {"stringUnit": {"state": "translated", "value": "Preferito"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранное"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏"}}}}, + "Favorite 1": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite 1"}}, "de": {"stringUnit": {"state": "translated", "value": "Favorit 1"}}, "es": {"stringUnit": {"state": "translated", "value": "Favorito 1"}}, "fr": {"stringUnit": {"state": "translated", "value": "Favori 1"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Favorito 1"}}, "it": {"stringUnit": {"state": "translated", "value": "Preferito 1"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony 1"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранное 1"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏 1"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏 1"}}}}, + "Favorite 2": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite 2"}}, "de": {"stringUnit": {"state": "translated", "value": "Favorit 2"}}, "es": {"stringUnit": {"state": "translated", "value": "Favorito 2"}}, "fr": {"stringUnit": {"state": "translated", "value": "Favori 2"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Favorito 2"}}, "it": {"stringUnit": {"state": "translated", "value": "Preferito 2"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony 2"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранное 2"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏 2"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏 2"}}}}, + "Favorite 3": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite 3"}}, "de": {"stringUnit": {"state": "translated", "value": "Favorit 3"}}, "es": {"stringUnit": {"state": "translated", "value": "Favorito 3"}}, "fr": {"stringUnit": {"state": "translated", "value": "Favori 3"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Favorito 3"}}, "it": {"stringUnit": {"state": "translated", "value": "Preferito 3"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony 3"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранное 3"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏 3"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏 3"}}}}, + "Favorite %lld": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite %lld"}}, "de": {"stringUnit": {"state": "translated", "value": "Favorit %lld"}}, "es": {"stringUnit": {"state": "translated", "value": "Favorito %lld"}}, "fr": {"stringUnit": {"state": "translated", "value": "Favori %lld"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Favorito %lld"}}, "it": {"stringUnit": {"state": "translated", "value": "Preferito %lld"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ulubiony %lld"}}, "ru": {"stringUnit": {"state": "translated", "value": "Избранное %lld"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏 %lld"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏 %lld"}}}}, + "Favorite name": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Favorite name"}}, "de": {"stringUnit": {"state": "translated", "value": "Name des Favoriten"}}, "es": {"stringUnit": {"state": "translated", "value": "Nombre del favorito"}}, "fr": {"stringUnit": {"state": "translated", "value": "Nom du favori"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nome do favorito"}}, "it": {"stringUnit": {"state": "translated", "value": "Nome del preferito"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nazwa ulubionego"}}, "ru": {"stringUnit": {"state": "translated", "value": "Название избранного"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收藏名称"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收藏名稱"}}}}, + "Action": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Action"}}, "de": {"stringUnit": {"state": "translated", "value": "Aktion"}}, "es": {"stringUnit": {"state": "translated", "value": "Acción"}}, "fr": {"stringUnit": {"state": "translated", "value": "Action"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ação"}}, "it": {"stringUnit": {"state": "translated", "value": "Azione"}}, "pl": {"stringUnit": {"state": "translated", "value": "Działanie"}}, "ru": {"stringUnit": {"state": "translated", "value": "Действие"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "操作"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "操作"}}}}, + "Confirmation": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Confirmation"}}, "de": {"stringUnit": {"state": "translated", "value": "Bestätigung"}}, "es": {"stringUnit": {"state": "translated", "value": "Confirmación"}}, "fr": {"stringUnit": {"state": "translated", "value": "Confirmation"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Confirmação"}}, "it": {"stringUnit": {"state": "translated", "value": "Conferma"}}, "pl": {"stringUnit": {"state": "translated", "value": "Potwierdzenie"}}, "ru": {"stringUnit": {"state": "translated", "value": "Подтверждение"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "确认"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "確認"}}}}, + "End training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "End training"}}, "de": {"stringUnit": {"state": "translated", "value": "Training beenden"}}, "es": {"stringUnit": {"state": "translated", "value": "Finalizar entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminer la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Terminar treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Termina allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Zakończ trening"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "结束训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "結束訓練"}}}}, + "Really end training?": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Really end training?"}}, "de": {"stringUnit": {"state": "translated", "value": "Training wirklich beenden?"}}, "es": {"stringUnit": {"state": "translated", "value": "¿Finalizar el entrenamiento?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Terminer vraiment la séance ?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Terminar mesmo o treino?"}}, "it": {"stringUnit": {"state": "translated", "value": "Terminare davvero l’allenamento?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Na pewno zakończyć trening?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Завершить тренировку?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "确定结束训练?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "確定結束訓練?"}}}}, + "Yes, end training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Yes, end training"}}, "de": {"stringUnit": {"state": "translated", "value": "Ja, Training beenden"}}, "es": {"stringUnit": {"state": "translated", "value": "Sí, finalizar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Oui, terminer"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sim, terminar"}}, "it": {"stringUnit": {"state": "translated", "value": "Sì, termina"}}, "pl": {"stringUnit": {"state": "translated", "value": "Tak, zakończ"}}, "ru": {"stringUnit": {"state": "translated", "value": "Да, завершить"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "是,结束训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "是,結束訓練"}}}}, + "Only completed sets will be saved.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Only completed sets will be saved."}}, "de": {"stringUnit": {"state": "translated", "value": "Nur absolvierte Sätze werden gespeichert."}}, "es": {"stringUnit": {"state": "translated", "value": "Solo se guardarán las series realizadas."}}, "fr": {"stringUnit": {"state": "translated", "value": "Seules les séries effectuées seront enregistrées."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Só serão guardadas as séries concluídas."}}, "it": {"stringUnit": {"state": "translated", "value": "Verranno salvate solo le serie completate."}}, "pl": {"stringUnit": {"state": "translated", "value": "Zapisane zostaną tylko wykonane serie."}}, "ru": {"stringUnit": {"state": "translated", "value": "Сохранятся только выполненные подходы."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "仅保存已完成的组。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "僅儲存已完成的組。"}}}}, + "Configure training favorites in NOOP": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Configure training favorites in NOOP"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsfavoriten in NOOP einrichten"}}, "es": {"stringUnit": {"state": "translated", "value": "Configura favoritos en NOOP"}}, "fr": {"stringUnit": {"state": "translated", "value": "Configurer les favoris dans NOOP"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Configurar favoritos no NOOP"}}, "it": {"stringUnit": {"state": "translated", "value": "Configura i preferiti in NOOP"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ustaw ulubione treningi w NOOP"}}, "ru": {"stringUnit": {"state": "translated", "value": "Настройте избранное в NOOP"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在 NOOP 中设置训练收藏"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在 NOOP 中設定訓練收藏"}}}}, + "Open NOOP to configure training": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Open NOOP to configure training"}}, "de": {"stringUnit": {"state": "translated", "value": "NOOP zum Einrichten öffnen"}}, "es": {"stringUnit": {"state": "translated", "value": "Abre NOOP para configurar el entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ouvrir NOOP pour configurer la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Abrir NOOP para configurar o treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Apri NOOP per configurare l’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Otwórz NOOP, aby ustawić trening"}}, "ru": {"stringUnit": {"state": "translated", "value": "Откройте NOOP для настройки тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "打开 NOOP 设置训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開啟 NOOP 設定訓練"}}}}, "Training could not be saved": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Training could not be saved"}}, "de": {"stringUnit": {"state": "translated", "value": "Training konnte nicht gespeichert werden"}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo guardar el entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible d’enregistrer la séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível guardar o treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile salvare l’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie udało się zapisać treningu"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось сохранить тренировку"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法保存训练"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法儲存訓練"}}}}, "Your paused training is still available. Please try again.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Your paused training is still available. Please try again."}}, "de": {"stringUnit": {"state": "translated", "value": "Dein pausiertes Training bleibt erhalten. Bitte versuche es erneut."}}, "es": {"stringUnit": {"state": "translated", "value": "Tu entrenamiento pausado sigue disponible. Vuelve a intentarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Votre séance en pause est conservée. Réessayez."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O treino em pausa continua disponível. Tente novamente."}}, "it": {"stringUnit": {"state": "translated", "value": "L’allenamento in pausa è conservato. Riprova."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wstrzymany trening jest zachowany. Spróbuj ponownie."}}, "ru": {"stringUnit": {"state": "translated", "value": "Тренировка на паузе сохранена. Повторите попытку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已暂停的训练仍然保留。请重试。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已暫停的訓練仍然保留。請重試。"}}}}, + "Still training?": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Still training?"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainierst du noch?"}}, "es": {"stringUnit": {"state": "translated", "value": "¿Sigues entrenando?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Vous vous entraînez encore ?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ainda está a treinar?"}}, "it": {"stringUnit": {"state": "translated", "value": "Ti stai ancora allenando?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nadal trenujesz?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Вы ещё тренируетесь?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "还在训练吗?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "還在訓練嗎?"}}}}, + "No live heart rate for ten minutes. Check your training or resume it.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "No live heart rate for ten minutes. Check your training or resume it."}}, "de": {"stringUnit": {"state": "translated", "value": "Seit zehn Minuten kein Live-Puls. Prüfe dein Training oder setze es fort."}}, "es": {"stringUnit": {"state": "translated", "value": "Sin pulso en directo durante diez minutos. Revisa o reanuda tu entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucun pouls en direct depuis dix minutes. Vérifiez ou reprenez votre séance."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Sem pulso em direto há dez minutos. Verifique ou retome o treino."}}, "it": {"stringUnit": {"state": "translated", "value": "Nessun battito in tempo reale da dieci minuti. Controlla o riprendi l’allenamento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Brak bieżącego tętna od dziesięciu minut. Sprawdź lub wznów trening."}}, "ru": {"stringUnit": {"state": "translated", "value": "Нет текущих измерений пульса десять минут. Проверьте или продолжите тренировку."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "十分钟未收到实时心率。请检查或继续训练。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "十分鐘未收到即時心率。請檢查或繼續訓練。"}}}}, + "Choose three workouts or Lift Log programs for your widgets.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Choose three workouts or Lift Log programs for your widgets."}}, "de": {"stringUnit": {"state": "translated", "value": "Wähle drei Sportarten oder Lift-Log-Programme für deine Widgets."}}, "es": {"stringUnit": {"state": "translated", "value": "Elige tres actividades o programas de Lift Log para tus widgets."}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisissez trois activités ou programmes Lift Log pour vos widgets."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolha três atividades ou programas Lift Log para os widgets."}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli tre attività o programmi Lift Log per i widget."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz trzy aktywności lub programy Lift Log dla widżetów."}}, "ru": {"stringUnit": {"state": "translated", "value": "Выберите три вида тренировки или программы Lift Log для виджетов."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "为小组件选择三种运动或 Lift Log 计划。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "為小工具選擇三種運動或 Lift Log 計畫。"}}}}, + "Unassigned": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Unassigned"}}, "de": {"stringUnit": {"state": "translated", "value": "Nicht belegt"}}, "es": {"stringUnit": {"state": "translated", "value": "Sin asignar"}}, "fr": {"stringUnit": {"state": "translated", "value": "Non attribué"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não atribuído"}}, "it": {"stringUnit": {"state": "translated", "value": "Non assegnato"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nieprzypisany"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не назначено"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "未分配"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "未指派"}}}}, + "Program unavailable": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Program unavailable"}}, "de": {"stringUnit": {"state": "translated", "value": "Programm nicht verfügbar"}}, "es": {"stringUnit": {"state": "translated", "value": "Programa no disponible"}}, "fr": {"stringUnit": {"state": "translated", "value": "Programme indisponible"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Programa indisponível"}}, "it": {"stringUnit": {"state": "translated", "value": "Programma non disponibile"}}, "pl": {"stringUnit": {"state": "translated", "value": "Program niedostępny"}}, "ru": {"stringUnit": {"state": "translated", "value": "Программа недоступна"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "计划不可用"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "計畫無法使用"}}}}, + "Enable training reminders": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Enable training reminders"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsmitteilungen erlauben"}}, "es": {"stringUnit": {"state": "translated", "value": "Permitir avisos de entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Autoriser les rappels de séance"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Permitir lembretes de treino"}}, "it": {"stringUnit": {"state": "translated", "value": "Consenti promemoria di allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Włącz przypomnienia treningowe"}}, "ru": {"stringUnit": {"state": "translated", "value": "Разрешить напоминания о тренировке"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "允许训练提醒"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "允許訓練提醒"}}}}, + "After the first live pulse, ten minutes without new measurements pauses training while the sensor is connected. Training continues when the sensor disconnects.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "After the first live pulse, ten minutes without new measurements pauses training while the sensor is connected. Training continues when the sensor disconnects."}}, "de": {"stringUnit": {"state": "translated", "value": "Nach dem ersten Live-Puls pausiert das Training nach zehn Minuten ohne neue Messung, solange der Sensor verbunden ist. Bei getrennter Verbindung läuft das Training weiter."}}, "es": {"stringUnit": {"state": "translated", "value": "Después del primer pulso en directo, diez minutos sin nuevas mediciones pausan el entrenamiento mientras el sensor esté conectado. El entrenamiento continúa si el sensor se desconecta."}}, "fr": {"stringUnit": {"state": "translated", "value": "Après le premier pouls en direct, dix minutes sans nouvelle mesure mettent la séance en pause tant que le capteur est connecté. La séance continue si le capteur se déconnecte."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Após o primeiro pulso em direto, dez minutos sem novas medições colocam o treino em pausa enquanto o sensor estiver ligado. O treino continua se o sensor se desligar."}}, "it": {"stringUnit": {"state": "translated", "value": "Dopo il primo battito in tempo reale, dieci minuti senza nuove misurazioni mettono in pausa l’allenamento mentre il sensore è connesso. L’allenamento continua se il sensore si disconnette."}}, "pl": {"stringUnit": {"state": "translated", "value": "Po pierwszym bieżącym pomiarze tętna dziesięć minut bez nowych pomiarów wstrzymuje trening, gdy czujnik jest połączony. Trening trwa dalej po rozłączeniu czujnika."}}, "ru": {"stringUnit": {"state": "translated", "value": "После первого текущего измерения пульса десять минут без новых измерений ставят тренировку на паузу, пока датчик подключён. При отключении датчика тренировка продолжается."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "收到首次实时心率后,传感器保持连接时,十分钟无新测量会暂停训练。传感器断开连接后,训练继续。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "收到首次即時心率後,感測器保持連線時,十分鐘無新測量會暫停訓練。感測器斷開連線後,訓練繼續。"}}}}, + "Control a running workout from the Lock Screen.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Control a running workout from the Lock Screen."}}, "de": {"stringUnit": {"state": "translated", "value": "Steuere ein laufendes Training vom Sperrbildschirm."}}, "es": {"stringUnit": {"state": "translated", "value": "Controla un entrenamiento desde la pantalla bloqueada."}}, "fr": {"stringUnit": {"state": "translated", "value": "Contrôlez une séance depuis l’écran verrouillé."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Controle um treino no ecrã bloqueado."}}, "it": {"stringUnit": {"state": "translated", "value": "Controlla l’allenamento dalla schermata di blocco."}}, "pl": {"stringUnit": {"state": "translated", "value": "Steruj trwającym treningiem z ekranu blokady."}}, "ru": {"stringUnit": {"state": "translated", "value": "Управляйте тренировкой с экрана блокировки."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在锁屏上控制正在进行的训练。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在鎖定畫面上控制正在進行的訓練。"}}}}, + "Start a favorite or control your running training.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Start a favorite or control your running training."}}, "de": {"stringUnit": {"state": "translated", "value": "Starte einen Favoriten oder steuere dein laufendes Training."}}, "es": {"stringUnit": {"state": "translated", "value": "Inicia un favorito o controla tu entrenamiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "Démarrez un favori ou contrôlez votre séance."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Inicie um favorito ou controle o treino em curso."}}, "it": {"stringUnit": {"state": "translated", "value": "Avvia un preferito o controlla l’allenamento in corso."}}, "pl": {"stringUnit": {"state": "translated", "value": "Uruchom ulubiony trening lub steruj trwającym."}}, "ru": {"stringUnit": {"state": "translated", "value": "Запустите избранную тренировку или управляйте текущей."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "开始收藏的训练或控制正在进行的训练。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "開始收藏的訓練或控制正在進行的訓練。"}}}}, "Move, relative to tonight": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Move, relative to tonight"}}, "de": {"stringUnit": {"state": "translated", "value": "Bewegung, relativ zu dieser Nacht"}}, "es": {"stringUnit": {"state": "translated", "value": "Movimiento, en relación con esta noche"}}, "fr": {"stringUnit": {"state": "translated", "value": "Mouvement, par rapport à cette nuit"}}, "it": {"stringUnit": {"state": "translated", "value": "Movimento, rispetto a questa notte"}}, "pl": {"stringUnit": {"state": "translated", "value": "Ruch, w odniesieniu do tej nocy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Movimento, em relação a esta noite"}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение, относительно этой ночи"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "活动,相对于今晚"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "活動,相對於今晚"}}}}, "Route and GPS distance, without the heart-rate and calorie summary": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Route and GPS distance, without the heart-rate and calorie summary"}}, "de": {"stringUnit": {"state": "translated", "value": "Route und GPS-Distanz, ohne die Herzfrequenz- und Kalorienübersicht"}}, "es": {"stringUnit": {"state": "translated", "value": "Ruta y distancia GPS, sin el resumen de frecuencia cardiaca y calorías"}}, "fr": {"stringUnit": {"state": "translated", "value": "Itinéraire et distance GPS, sans le récapitulatif de fréquence cardiaque et de calories"}}, "it": {"stringUnit": {"state": "translated", "value": "Percorso e distanza GPS, senza il riepilogo di frequenza cardiaca e calorie"}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa i dystans GPS, bez podsumowania tętna i kalorii"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Rota e distância GPS, sem o resumo de frequência cardíaca e calorias"}}, "ru": {"stringUnit": {"state": "translated", "value": "Маршрут и GPS-дистанция, без сводки пульса и калорий"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "路线和 GPS 距离,不含心率和卡路里摘要"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "路線和 GPS 距離,不含心率和卡路里摘要"}}}}, "Export Workout Route": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Export Workout Route"}}, "de": {"stringUnit": {"state": "translated", "value": "Trainingsroute exportieren"}}, "es": {"stringUnit": {"state": "translated", "value": "Exportar ruta del entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Exporter l’itinéraire de la séance"}}, "it": {"stringUnit": {"state": "translated", "value": "Esporta il percorso dell’allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Eksportuj trasę treningu"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Exportar rota do treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Экспортировать маршрут тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "导出运动路线"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "匯出運動路線"}}}}, diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift index 48e3a5e8df..26bcd65550 100644 --- a/Strand/Screens/SettingsView.swift +++ b/Strand/Screens/SettingsView.swift @@ -121,6 +121,7 @@ struct SettingsView: View { @AppStorage(UnitPrefs.liveActivityKey) private var liveActivityEnabled = true // Strap-sync Live Activity, iOS only. Separate from the live-HR one on purpose. Default on. @AppStorage(UnitPrefs.syncLiveActivityKey) private var syncLiveActivityEnabled = true + @AppStorage("noop.workoutLiveActivity") private var workoutLiveActivityEnabled = true @AppStorage(UnitPrefs.liftLiveActivityKey) private var liftLiveActivityEnabled = true @AppStorage(DayCycleMode.storageKey) private var dayCycleModeRaw = DayCycleMode.sleepOnset.rawValue // Alternate app icon (iOS only) — false = Titanium (primary AppIcon), true = Blue Titanium @@ -267,6 +268,7 @@ struct SettingsView: View { strapCard.staggeredAppear(index: 3) #if os(iOS) liveNotificationsCard.staggeredAppear(index: 3) + TrainingFavoritesView() #endif streakCard.staggeredAppear(index: 4) featuresCard.staggeredAppear(index: 5) @@ -1573,6 +1575,9 @@ struct SettingsView: View { liveNotificationSwitch("Live heart rate", isOn: $liveActivityEnabled, detail: "While the strap is connected.") rowDivider + liveNotificationSwitch("Training", isOn: $workoutLiveActivityEnabled, + detail: "Control a running workout from the Lock Screen.") + rowDivider liveNotificationSwitch("Lift Log session", isOn: $liftLiveActivityEnabled, detail: "Your set, rest and heart rate, and the Lock Screen light-up on a double-tap.") rowDivider @@ -2836,7 +2841,7 @@ private struct SettingsDisclosureGroup: View { /// A grouped settings card: a "Settings" overline + icon + title header, an explanatory blurb, /// then content. The surface stays neutral; accent blue is reserved for the icon and controls. -private struct SettingsSection: View { +struct SettingsSection: View { let icon: String let title: LocalizedStringKey let blurb: LocalizedStringKey diff --git a/Strand/System/NotificationPresenter.swift b/Strand/System/NotificationPresenter.swift index 9c5448eda0..caece199d8 100644 --- a/Strand/System/NotificationPresenter.swift +++ b/Strand/System/NotificationPresenter.swift @@ -22,6 +22,7 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { /// is a safe no-op (the tap is simply not routed) rather than a crash if this ever fires before the /// root has wired it. var onCoachBriefTapped: (() -> Void)? + var onTrainingAction: ((String, String, @escaping () -> Void) -> Void)? func userNotificationCenter( _ center: UNUserNotificationCenter, @@ -42,6 +43,13 @@ final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { if response.notification.request.content.categoryIdentifier == CoachBriefScheduler.notificationCategoryId { onCoachBriefTapped?() } + if response.notification.request.content.categoryIdentifier == "noop-training-pulse-loss", + let id = response.notification.request.content.userInfo["sessionID"] as? String { + if let onTrainingAction { + onTrainingAction(response.actionIdentifier, id, completionHandler) + return + } + } completionHandler() } } diff --git a/StrandTests/FrameIntegrityGateTests.swift b/StrandTests/FrameIntegrityGateTests.swift index c313fe7c2e..130e8e2bdf 100644 --- a/StrandTests/FrameIntegrityGateTests.swift +++ b/StrandTests/FrameIntegrityGateTests.swift @@ -48,6 +48,24 @@ final class FrameIntegrityGateTests: XCTestCase { // MARK: - the router drives no state from a frame that is not intact + func testFreshReceiptsIncludeRepeatedBpmAndExcludeInvalidFrames() { + let live = LiveState() + let router = FrameRouter(state: live) + var receipts = 0 + var valueBeforePublication: Int? + live.onReadableHeartRate = { _ in + receipts += 1 + if receipts == 1 { valueBeforePublication = live.heartRate } + } + let valid = bytes(realtimeHex) + router.handle(frame: valid) + router.handle(frame: valid) + XCTAssertEqual(receipts, 2, "a repeated bpm and a throttled silence timer still deliver fresh receipts") + XCTAssertNil(valueBeforePublication, "deadline reconciliation precedes the biometric write") + router.handle(frame: headerBroken()) + XCTAssertEqual(receipts, 2) + } + func testRouterRejectsABrokenHeaderChecksum() { let frame = headerBroken() let parsed = parseFrame(frame, family: .whoop4) diff --git a/StrandTests/TrainingPulseGuardTests.swift b/StrandTests/TrainingPulseGuardTests.swift new file mode 100644 index 0000000000..cfded39517 --- /dev/null +++ b/StrandTests/TrainingPulseGuardTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import Strand + +final class TrainingPulseGuardTests: XCTestCase { + func testDisconnectionNeverPausesAndReconnectStartsANewWindow() { + var state = TrainingPulseGuard() + state.receive(at: 1_000) + state.setConnected(false, at: 1_599) + XCTAssertNil(state.deadline) + XCTAssertFalse(state.isDue(at: 20_000)) + state.setConnected(true, at: 20_000) + XCTAssertEqual(state.lastSampleSec, 1_000, "a connection must not masquerade as a sample") + XCTAssertFalse(state.isDue(at: 20_599)) + XCTAssertTrue(state.isDue(at: 20_600)) + state.receive(at: 20_500) + XCTAssertEqual(state.deadline, 21_100) + } + + func testReconnectWithoutFirstSampleDoesNotArmAndRepeatedConnectionDoesNotExtend() { + var state = TrainingPulseGuard() + state.setConnected(false, at: 1_000) + state.setConnected(true, at: 2_000) + XCTAssertNil(state.deadline) + state.receive(at: 2_100) + state.setConnected(true, at: 2_699) + XCTAssertTrue(state.isDue(at: 2_700)) + } + + func testOfflineCheckpointSurvivesRestartAndLegacySnapshotsStillDecode() throws { + var state = TrainingPulseGuard() + state.receive(at: 1_000) + state.setConnected(false, at: 1_100) + var restored = try JSONDecoder().decode(TrainingPulseGuard.self, from: JSONEncoder().encode(state.checkpoint)) + XCTAssertNil(restored.deadline) + restored.setConnected(true, at: 30_000) + XCTAssertEqual(restored.deadline, 30_600) + let legacy = try JSONDecoder().decode(TrainingPulseGuard.self, + from: Data("{\"lastSampleSec\":1000,\"recoveryAllowance\":30}".utf8)) + XCTAssertEqual(legacy.deadline, 1_630) + } + + func testArmsOnlyAfterAReceiptAndUsesExactBoundary() { + var guardState = TrainingPulseGuard() + XCTAssertFalse(guardState.isDue(at: 10_000)) + guardState.receive(at: 1_000) + XCTAssertFalse(guardState.isDue(at: 1_599)) + XCTAssertTrue(guardState.isDue(at: 1_600)) + // Equal heart rates still generate receipts; no displayed-value comparison is involved. + guardState.receive(at: 1_500) + XCTAssertFalse(guardState.isDue(at: 2_099)) + XCTAssertTrue(guardState.isDue(at: 2_100)) + } + + func testCorruptPersistedDeadlineCannotOverflow() throws { + let restored = try JSONDecoder().decode(TrainingPulseGuard.self, + from: Data("{\"lastSampleSec\":9223372036854775807,\"recoveryAllowance\":9223372036854775807}".utf8)) + XCTAssertNil(restored.deadline) + XCTAssertFalse(restored.isDue(at: 1_000)) + XCTAssertNil(TrainingPulseGuard(lastSampleSec: Int.max - 600).deadline) + } + + func testResumeAndCheckpointAvoidPrematurePause() throws { + var guardState = TrainingPulseGuard() + guardState.resume(at: 500) + XCTAssertNil(guardState.deadline) + guardState.receive(at: 1_000) + let restored = try JSONDecoder().decode(TrainingPulseGuard.self, from: JSONEncoder().encode(guardState.checkpoint)) + XCTAssertFalse(restored.isDue(at: 1_629)) + XCTAssertTrue(restored.isDue(at: 1_630)) + guardState.resume(at: 1_900) + XCTAssertEqual(guardState.deadline, 2_500) + } + + func testLiftPauseFreezesRestAndSaveExcludesThePause() { + var engine = LiftSessionEngine(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], startTs: 1_000) + engine.advance(now: 1_010) + engine.advance(now: 1_030) + engine.pause(now: 1_050) + engine.pause(now: 1_100) + XCTAssertEqual(engine.restRemaining(now: 2_000), 70) + XCTAssertEqual(engine.elapsed(now: 2_000), 50) + let savedSets = engine.sets + engine.advance(now: 2_000) + XCTAssertEqual(engine.sets, savedSets) + engine.resume(now: 1_150) + XCTAssertEqual(engine.restRemaining(now: 1_150), 70) + engine.finish(now: 1_180) + XCTAssertEqual(engine.sets[0].restSec, 50) + XCTAssertEqual(engine.sets[0].startTs, 1_010) + XCTAssertEqual(engine.sets[0].endTs, 1_030) + XCTAssertEqual(engine.elapsed(now: 1_180), 80) + } + + func testFinishingDuringPauseNeverInventsASetOrRestTime() { + var engine = LiftSessionEngine(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], startTs: 1_000) + engine.advance(now: 1_010) + engine.advance(now: 1_030) + engine.pause(now: 1_050) + engine.finish(now: 3_000) + XCTAssertEqual(engine.sets.count, 1) + XCTAssertEqual(engine.sets[0].restSec, 20) + XCTAssertEqual(engine.elapsed(now: 3_000), 50) + } + + func testLiftPauseSnapshotRoundTripAndUndo() throws { + var engine = LiftSessionEngine(plan: [.init(exercise: "Row", targetSets: 2, restSec: 90)], startTs: 1_700_000_000) + engine.advance(now: 1_700_000_010) + engine.pause(now: 1_700_000_020) + let snapshot = LiftSessionPersistence.snapshot(engine: engine, programId: nil, programName: nil, pendingValues: [:], pendingWarmups: []) + let decoded = try XCTUnwrap(LiftSessionPersistence.decode(LiftSessionPersistence.encode(snapshot))) + var restored = LiftSessionPersistence.engine(from: decoded) + XCTAssertTrue(restored.isPaused) + XCTAssertEqual(restored.stageElapsed(now: 1_700_001_000), 10) + restored.resume(now: 1_700_000_120) + restored.advance(now: 1_700_000_130) + restored.undo() + XCTAssertEqual(restored.stageElapsed(now: 1_700_000_140), 30) + } +} diff --git a/StrandTests/TrainingSnapshotTests.swift b/StrandTests/TrainingSnapshotTests.swift new file mode 100644 index 0000000000..3299bb230d --- /dev/null +++ b/StrandTests/TrainingSnapshotTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import Strand + +final class TrainingSnapshotTests: XCTestCase { + func testSnapshotEqualityIgnoresEncodingOrderButKeepsVisibleActionChanges() throws { + let now = Date(timeIntervalSince1970: 1_000) + let training = TrainingDisplay(id: "run", kind: "workout", title: "Run", clockStart: now) + let snapshot = TrainingSnapshot(favorites: TrainingFavorite.defaults, sessions: [training], + labels: ["pause": "Pause", "resume": "Resume"]) + var copy = try JSONDecoder().decode(TrainingSnapshot.self, from: JSONEncoder().encode(snapshot)) + copy.labels = ["resume": "Resume", "pause": "Pause"] + XCTAssertEqual(snapshot, copy) + copy.sessions[0].pausedAt = now.addingTimeInterval(10) + XCTAssertNotEqual(snapshot, copy) + copy = snapshot + copy.sessions[0].confirmationToken = "new" + copy.sessions[0].confirmationUntil = now.addingTimeInterval(30) + XCTAssertNotEqual(snapshot, copy) + copy = snapshot + copy.sessions[0].error = "Save failed" + XCTAssertNotEqual(snapshot, copy) + copy = snapshot + copy.favorites[0].name = "New favorite" + XCTAssertNotEqual(snapshot, copy) + copy = snapshot + copy.labels["pause"] = "Pause training" + XCTAssertNotEqual(snapshot, copy) + } + + @MainActor + func testFavoriteConfigurationWaitsForLaunchGatesAndIsConsumedOnce() { + let router = NavRouter() + router.openTrainingFavorites() + XCTAssertFalse(router.consumeTrainingFavoritesRequest(isReady: false)) + XCTAssertEqual(router.requestedDestination, .trainingFavorites) + XCTAssertTrue(router.consumeTrainingFavoritesRequest(isReady: true)) + XCTAssertNil(router.requestedDestination) + XCTAssertFalse(router.consumeTrainingFavoritesRequest(isReady: true)) + router.openDevices() + XCTAssertFalse(router.consumeTrainingFavoritesRequest(isReady: true)) + XCTAssertEqual(router.requestedDestination, .devices) + } + + func testWorkoutIconKeepsRawSportAndReadsOlderSnapshots() throws { + let old = Data(#"{"id":"run","kind":"workout","title":"Laufen","clockStart":0}"#.utf8) + var display = try JSONDecoder().decode(TrainingDisplay.self, from: old) + XCTAssertNil(display.sport) + display.sport = "Running" + let restored = try JSONDecoder().decode(TrainingDisplay.self, from: JSONEncoder().encode(display)) + XCTAssertEqual(restored.sport, "Running") + XCTAssertEqual(restored.title, "Laufen") + } + + func testEndConfirmationIsBoundToTheSessionTokenAndThirtySeconds() { + let now = Date(timeIntervalSince1970: 1_000) + let confirmation = TrainingEndConfirmation(sessionID: "first", now: now) + XCTAssertTrue(confirmation.accepts(sessionID: "first", token: confirmation.token, now: now.addingTimeInterval(29))) + XCTAssertFalse(confirmation.accepts(sessionID: "second", token: confirmation.token, now: now)) + XCTAssertFalse(confirmation.accepts(sessionID: "first", token: "old", now: now)) + XCTAssertFalse(confirmation.accepts(sessionID: "first", token: confirmation.token, now: now.addingTimeInterval(30))) + } + + func testConfirmationSurvivesRestartButExpiredAndFutureSnapshotsDoNot() throws { + let now = Date(timeIntervalSince1970: 1_000) + let confirmation = TrainingEndConfirmation(sessionID: "first", now: now) + var display = TrainingDisplay(id: "first", kind: "workout", title: "Run", clockStart: now, + confirmationUntil: confirmation.until, confirmationToken: confirmation.token) + display = try JSONDecoder().decode(TrainingDisplay.self, from: JSONEncoder().encode(display)) + let restored = try XCTUnwrap(TrainingEndConfirmation(restoring: display, now: now.addingTimeInterval(15))) + XCTAssertTrue(restored.accepts(sessionID: "first", token: confirmation.token, now: now.addingTimeInterval(29))) + XCTAssertNil(TrainingEndConfirmation(restoring: display, now: now.addingTimeInterval(30))) + display.confirmationUntil = now.addingTimeInterval(100) + XCTAssertNil(TrainingEndConfirmation(restoring: display, now: now)) + } + + func testThreeFavoritesRoundTripAndRejectCorruptSlots() throws { + let suite = "training-tests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + var favorites = TrainingFavorite.load(from: defaults) + XCTAssertEqual(favorites.count, 3) + XCTAssertEqual(favorites[0].sport, "Strength") + XCTAssertEqual(favorites[1].sport, "Running") + XCTAssertNil(favorites[2].sport) + favorites[2] = .init(id: 2, name: "Upper A", programID: "p") + TrainingFavorite.save(favorites, into: defaults) + XCTAssertEqual(TrainingFavorite.load(from: defaults), favorites) + TrainingFavorite.save(Array(favorites.prefix(2)), into: defaults) + XCTAssertEqual(TrainingFavorite.load(from: defaults), favorites) + defaults.set(Data("[{\"id\":999,\"name\":\"x\"}]".utf8), forKey: TrainingFavorite.storageKey) + XCTAssertEqual(TrainingFavorite.load(from: defaults), TrainingFavorite.defaults) + } +} diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 41c6829964..31cdf88e49 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -245,6 +245,8 @@ struct RootTabView: View { // (InsightsView), matching the FAB's "Log journal" action. Calm sheet easing. withAnimation(Self.sheetEase) { quickAction = .journal } router.requestedDestination = nil + case .trainingFavorites: + presentPendingExternalActionIfPossible() case nil: break } @@ -256,16 +258,16 @@ struct RootTabView: View { router.quickActionsRequested = false } } - // A cold-launch selection is already pending when this shell appears; a warm selection arrives - // through the change callback. Both route through the same screens as the centre FAB. + // Cold-launch requests are already pending when this shell appears; warm requests arrive + // through the change callbacks. Both wait for the same mandatory launch gates. .onAppear { - presentPendingHomeScreenQuickActionIfPossible() + presentPendingExternalActionIfPossible() } .onChange(of: homeScreenQuickActions.pendingAction) { _, _ in - presentPendingHomeScreenQuickActionIfPossible() + presentPendingExternalActionIfPossible() } .onChange(of: homeScreenQuickActionsEnabled) { _, _ in - presentPendingHomeScreenQuickActionIfPossible() + presentPendingExternalActionIfPossible() } // The running gym session, reachable from ANY tab. It sits above the tab bar rather than // inside the Lift Log screen, because a workout outlives whichever screen you wandered to — @@ -289,10 +291,20 @@ struct RootTabView: View { } } - /// Mandatory launch gates defer an external action. Once the shell is available, an explicit Home - /// Screen choice supersedes any ordinary shell sheet; choosing the already-open destination simply + /// Mandatory launch gates defer an external action. Once the shell is available, an explicit widget + /// or Home Screen choice supersedes any ordinary shell sheet; choosing the already-open destination simply /// consumes the request and leaves that screen in place. - private func presentPendingHomeScreenQuickActionIfPossible() { + private func presentPendingExternalActionIfPossible() { + if router.consumeTrainingFavoritesRequest(isReady: homeScreenQuickActionsEnabled) { + if let action = homeScreenQuickActions.pendingAction { homeScreenQuickActions.consume(action) } + showDevices = false + routedPillar = nil + quickAction = nil + liftSession.isPresented = false + selectedTab = 4 + tabPaths[4] = NavigationPath([MoreDestination.trainingFavorites]) + return + } guard homeScreenQuickActionsEnabled, let action = homeScreenQuickActions.pendingAction else { return } @@ -340,6 +352,7 @@ struct RootTabView: View { // it ever reaches the host. case .coach: CoachView() case .alarms: SmartAlarmView() + case .trainingFavorites: MoreDestination.trainingFavorites.destination } } // The Trends/Today fallbacks above emit TabRoute value pushes (#198), which need a @@ -600,7 +613,7 @@ private enum MoreDestination: Hashable { case insightsHub, intelligence, coach, insights, explore, compare case live, workouts, liftLog, health, labBook, stress, breathe, intervals, rhythm case fusedRecord, appleHealth, miBand, dataSources, backupSync, shortcutsExport, noopLimitations - case alarms, automations, testCentre, siriShortcuts, powerSaving, settings + case alarms, automations, testCentre, siriShortcuts, powerSaving, settings, trainingFavorites @ViewBuilder var destination: some View { switch self { @@ -632,6 +645,7 @@ private enum MoreDestination: Hashable { case .siriShortcuts: SiriShortcutsSettingsView() case .powerSaving: PowerSavingView() case .settings: SettingsView() + case .trainingFavorites: ScreenScaffold(title: nil) { TrainingFavoritesView() } } } } diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index d505bc60d3..d5438793d1 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -27,11 +27,7 @@ struct StrandiOSApp: App { /// NOOP's live heart rate banner. Built in `init` and fed from there (`LiveActivityController.follow`), not from /// a view: a process iOS starts in the background need not build one. @State private var liveActivity: LiveActivityController - /// The Lift Log session's own Live Activity. Separate from the live-HR one above: while a gym - /// session is open this is the banner that matters (it carries the heart rate too), so the HR - /// activity is suppressed rather than stacked beside it. Built in `init`, where the strap log it - /// writes to exists. - @State private var liftActivity: LiftLiveActivityController + @StateObject private var training: TrainingCoordinator /// The live gym session. Owned HERE, at the app root, rather than by the screen that shows it: /// swiping the workout sheet away must not stop the clock, silence the strap or drop the /// double-tap handler. See `LiftSessionController`. @@ -115,11 +111,14 @@ struct StrandiOSApp: App { let liftActivity = LiftLiveActivityController(log: { [weak model] line in model?.live.append(log: AppModel.stamped(line)) }) - _liftActivity = State(initialValue: liftActivity) + // Restore before the coordinator's first push or any scene exists. + liftSession.resumeSaved() + let training = TrainingCoordinator(model: model, lift: liftSession, liftActivity: liftActivity) + _training = StateObject(wrappedValue: training) // The live heart rate banner makes room only for the Lift Log banner actually on screen, which carries the // heart rate itself — not for a sync (`LiveHRBannerLifecycle`). let liveActivity = LiveActivityController() - liveActivity.follow(model, standsAside: { [weak liftActivity] in liftActivity?.isShowing == true }) + liveActivity.follow(model, standsAside: { [weak liftActivity, weak training] in liftActivity?.isShowing == true || training?.isShowingWorkout == true }) _liveActivity = State(initialValue: liveActivity) // A gym session keeps ONE banner on the Lock Screen, its own — as the live-HR banner already // stands aside for it. A sync started in the foreground mid-session starts no sync banner. @@ -128,9 +127,6 @@ struct StrandiOSApp: App { SyncLiveActivityController.shared.holdsBackNewBanner = { [weak liftSession] in liftSession?.isActive == true && UnitPrefs.liftLiveActivityEnabled() } - // Before any view or publisher exists: the first push to the Lock Screen banner must find the - // session already running, or it ends the banner iOS kept alive across the restart. - liftSession.resumeSaved() // #1538: a strap offload completes while the app is BACKGROUNDED — it stays alive as a // bluetooth-central to receive it — and the re-score it triggers took nearly eight minutes on the // reporter's install, far longer than that wake survives. The pass is all-or-nothing, so being @@ -219,6 +215,7 @@ struct StrandiOSApp: App { var body: some Scene { WindowGroup { iOSRootView() + .modifier(TrainingNotificationConfirmation(training: training)) .environmentObject(model) .environmentObject(model.ble) // #334: Today pull-to-sync reads BLEManager (no HR churn) .environmentObject(model.live) @@ -231,6 +228,7 @@ struct StrandiOSApp: App { .environmentObject(router) .environmentObject(UpdateStore.shared) .environmentObject(liftSession) + .environmentObject(training) // v5 L3: the shared stress check-in nudge surface, so the Breathe screen's passive // card observes the SAME instance the central detector (AppModel.evaluateStress) posts to. .environment(\.stressNudgeCenter, model.stressNudgeCenter) @@ -244,20 +242,6 @@ struct StrandiOSApp: App { // fixed-geometry tiles/gauges stay legible at the largest accessibility sizes rather than // clipping; the common Larger-Text range still scales fully. .dynamicTypeSize(...DynamicTypeSize.accessibility1) - // `hr` is the value being written: this runs in willSet, when `live.heartRate` still holds the old one. - .onReceive(model.live.$heartRate) { hr in - // The gym banner's own cheap path: no presentation is built here, and a heart rate moves - // the banner only when `LiftBannerPushPolicy` says it is worth a push. Everything else - // about the session pushes through `pushLiftActivity` below, carrying the current number. - liftActivity.updateHeartRate(model.live.connected ? (model.bpm ?? hr) : nil) - } - // The gym session's own banner follows each change to the session once it has landed — - // a stage, typed numbers, a rest's end — and the heart rate above; the controller decides - // what is worth pushing, and the banner's clocks tick on their own. A strap step is pushed - // at once, with its light-up alert, below. - .onReceive(liftSession.changesSettled) { _ in pushLiftActivity() } - // A strap double-tap lights the Lock Screen on the step it took. - .onReceive(liftSession.strapStepTaken) { _ in pushLiftActivity(alert: true) } // #911/#759: republish the Home/Lock-Screen widget whenever the dashboard caches actually // change mid-session. The only other publish site is the scenePhase .active handler, so // during a long foreground session the widget froze at the last-foreground snapshot while @@ -321,6 +305,8 @@ struct StrandiOSApp: App { .onOpenURL { url in if url.host == "import-health" { model.handleHealthImportURL(url) + } else if url == TrainingFavorite.configurationURL { + router.openTrainingFavorites() } } .alert("Import Apple Health data?", isPresented: healthImportAlertPresented) { @@ -351,7 +337,7 @@ struct StrandiOSApp: App { model.drainPendingIntents(router: router) // iOS starts a Lift Log banner only for an app on screen, so a banner lost while NOOP was in // the background comes back now, whether or not the strap is sending anything. - pushLiftActivity() + training.reconcile() // Only the foreground may start the live heart rate banner: offer it now. liveActivity.appBecameActive() // End a "Connecting…" sync island whose sync never came, rather than leave it greyed. @@ -392,6 +378,7 @@ struct StrandiOSApp: App { await watch.pushLatest(from: model) } } else if phase == .background { + training.reconcile() // Re-submit on every transition because iOS may discard an old best-effort request. HealthWritebackBackgroundScheduler.updateSchedule( isAuthorized: health.auth == .authorized) @@ -414,34 +401,6 @@ struct StrandiOSApp: App { } } - /// Map the running session onto the Lock Screen banner. - /// - /// The wording and the numbers come from `LiftSessionController.presentation`, the same - /// resolution the in-app minimised bar renders, so the two surfaces cannot disagree. The heart - /// rate is the app's smoothed value, and only while the strap is actually connected — a frozen - /// last-known bpm on a Lock Screen reads as live and is not. `alert` lights the Lock Screen for this - /// push — see `LiftLiveActivityController.update`. - @MainActor - private func pushLiftActivity(alert: Bool = false) { - let system = UnitSystem(rawValue: unitSystemRaw) ?? .metric - guard let p = liftSession.presentation(system: system) else { - liftActivity.update(state: nil) - return - } - let lightUp = liftActivity.update( - state: LiftActivityAttributes.ContentState( - isResting: p.isResting, - exercise: p.exercise, - status: p.status, - detail: p.detail, - bpm: model.live.connected ? (model.bpm ?? model.live.heartRate) : nil, - next: p.next, - stageStartedAt: p.stageStartedAt, - restEndsAt: p.restEndsAt), - alert: alert) - // One line per strap step into NOOP's strap log: whether the Lock Screen was asked to light. - if let lightUp { model.live.append(log: AppModel.stamped(lightUp.logLine)) } - } } /// iOS root — the `RootTabView` shell with the first-run onboarding/pairing wizard overlaid until diff --git a/StrandiOS/App/TrainingFavoritesView.swift b/StrandiOS/App/TrainingFavoritesView.swift new file mode 100644 index 0000000000..b20d278b67 --- /dev/null +++ b/StrandiOS/App/TrainingFavoritesView.swift @@ -0,0 +1,108 @@ +#if os(iOS) +import SwiftUI +import WhoopStore +import StrandDesign +import UserNotifications + +struct TrainingFavoritesView: View { + @EnvironmentObject private var repo: Repository + @EnvironmentObject private var training: TrainingCoordinator + @State private var favorites = TrainingFavorite.load() + @State private var programs: [LiftProgramRow] = [] + @State private var error: String? + var body: some View { + SettingsSection(icon: "figure.run", title: "Training favorites", + blurb: "Choose three workouts or Lift Log programs for your widgets.") { + VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { + ForEach(favorites.indices, id: \.self) { index in + TextField("Favorite name", text: $favorites[index].name) + .font(StrandFont.body) + Picker("Favorite \(index + 1)", selection: selection(index)) { + Text("Unassigned").tag("") + ForEach(WorkoutCatalog.all) { sport in + Text(TrainingCoordinator.sportTitle(sport.name)).tag("sport:\(sport.name)") + } + ForEach(programs, id: \.id) { program in + Text(program.name).tag("program:\(program.id)") + } + if let id = favorites[index].programID, !programs.contains(where: { $0.id == id }) { + Text("Program unavailable").tag("program:\(id)") + } + } + if index < 2 { Divider() } + } + Button("Enable training reminders") { + Task { + do { _ = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) } + catch { self.error = error.localizedDescription } + } + } + .buttonStyle(.noopSecondary) + Text("After the first live pulse, ten minutes without new measurements pauses training while the sensor is connected. Training continues when the sensor disconnects.") + .font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary) + if let error { Text(error).font(StrandFont.footnote).foregroundStyle(StrandPalette.metricAmber) } + } + } + .onChange(of: favorites) { _, values in + TrainingFavorite.save(values) + training.publish() + } + .task { + do { + guard let store = await repo.storeHandle() else { return } + programs = try await store.liftPrograms(deviceId: repo.deviceId) + } catch { self.error = error.localizedDescription } + } + } + private func selection(_ index: Int) -> Binding { + Binding(get: { + if let id = favorites[index].programID { return "program:\(id)" } + return favorites[index].sport.map { "sport:\($0)" } ?? "" + }, set: { value in + favorites[index].sport = value.hasPrefix("sport:") ? String(value.dropFirst(6)) : nil + favorites[index].programID = value.hasPrefix("program:") ? String(value.dropFirst(8)) : nil + if let id = favorites[index].programID, favorites[index].name.isEmpty { + favorites[index].name = programs.first { $0.id == id }?.name ?? "" + } + }) + } +} +struct TrainingNotificationConfirmation: ViewModifier { + @ObservedObject var training: TrainingCoordinator + func body(content: Content) -> some View { + content.alert("Still training?", isPresented: Binding( + get: { training.foregroundPulseLoss != nil }, + set: { if !$0 { training.foregroundPulseLoss = nil } })) { + if let session = training.foregroundPulseLoss { + Button("Resume") { Task { try? await training.perform("resume", id: session.id) } } + Button("End training") { + Task { + try? await training.perform("requestEnd", id: session.id) + training.foregroundConfirmation = TrainingSnapshot.load().sessions.first { $0.id == session.id } + } + } + } + Button("Cancel", role: .cancel) {} + } message: { Text("No live heart rate for ten minutes. Check your training or resume it.") } + .alert("Really end training?", isPresented: Binding( + get: { training.foregroundConfirmation != nil }, + set: { if !$0 { training.foregroundConfirmation = nil } })) { + if let session = training.foregroundConfirmation { + Button("Yes, end training", role: .destructive) { + Task { + do { try await training.perform("confirmEnd", id: session.id, token: session.confirmationToken ?? "") } + catch { training.foregroundError = error.localizedDescription } + } + } + Button("Cancel", role: .cancel) { Task { try? await training.perform("cancelEnd", id: session.id) } } + } + } message: { + if training.foregroundConfirmation?.kind == "lift" { Text("Only completed sets will be saved.") } + } + .alert("Training could not be saved", isPresented: Binding( + get: { training.foregroundError != nil }, set: { if !$0 { training.foregroundError = nil } })) { + Button("OK", role: .cancel) {} + } message: { Text("Your paused training is still available. Please try again.") } + } +} +#endif diff --git a/StrandiOS/Widgets/LiftLiveActivityController.swift b/StrandiOS/Widgets/LiftLiveActivityController.swift index fc7e956c6a..01db545285 100644 --- a/StrandiOS/Widgets/LiftLiveActivityController.swift +++ b/StrandiOS/Widgets/LiftLiveActivityController.swift @@ -30,6 +30,7 @@ final class LiftLiveActivityController { /// The state the banner is showing, so a heart-rate tick can push a copy of it without the app /// building a whole presentation again (`updateHeartRate`). private var lastState: LiftActivityAttributes.ContentState? + private(set) var pendingUpdate: Task? /// Cached for the controller's lifetime — the same reasoning as `LiveActivityController`: this is /// consulted on every push and its value only changes via Settings. private let authInfo = ActivityAuthorizationInfo() @@ -77,7 +78,7 @@ final class LiftLiveActivityController { /// Dynamic Island instead. ActivityKit offers no setting for vibration; the sound is silence. /// Returns what happened about lighting when `alert` was asked for; nil otherwise. @discardableResult - func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false) -> LightUp? { + func update(state: LiftActivityAttributes.ContentState?, alert: Bool = false, allowBackgroundStart: Bool = false) -> LightUp? { guard authInfo.areActivitiesEnabled else { return alert ? .noBanner : nil } // A banner the lifter swiped off the Lock Screen, or one iOS ended, takes no more updates: let it @@ -93,7 +94,7 @@ final class LiftLiveActivityController { // Its own switch (`UnitPrefs.liftLiveActivityEnabled`), so the everyday heart-rate banner can be off while // the gym banner stays; turning this one off also ends a banner already showing. guard UnitPrefs.liftLiveActivityEnabled(), let state else { - if activity != nil { Task { await end() } } + if activity != nil { pendingUpdate = Task { await end() } } return alert ? .noBanner : nil } if adopted != nil { @@ -108,6 +109,10 @@ final class LiftLiveActivityController { state.detail ?? "", state.next, "\(state.stageStartedAt.timeIntervalSince1970)", "\(state.restEndsAt?.timeIntervalSince1970 ?? 0)", + "\(state.training?.pausedAt?.timeIntervalSince1970 ?? 0)", + state.training?.id ?? "", + state.training?.confirmationToken ?? "", + state.training?.error ?? "", ].joined(separator: "|") let contentChanged = signature != lastSignature @@ -129,15 +134,15 @@ final class LiftLiveActivityController { body: LocalizedStringResource(stringLiteral: state.detail.map { "\(state.status) — \($0)" } ?? state.status), sound: .named(Self.silentAlertSound)) - Task { await activity.update(content, alertConfiguration: stepAlert) } + pendingUpdate = Task { await activity.update(content, alertConfiguration: stepAlert) } } else { - Task { await activity.update(content) } + pendingUpdate = Task { await activity.update(content) } } return alert ? (lightsScreen ? .askedIOS : .appOnScreen) : nil } else { // iOS starts a Live Activity only for the app on screen; asked from the background it throws, // and this runs several times a second. The banner comes back the next time NOOP is opened. - guard UIApplication.shared.applicationState == .active else { + guard UIApplication.shared.applicationState == .active || allowBackgroundStart else { if !waitingForForeground { waitingForForeground = true log("Lift Log: no Lock Screen banner — iOS starts one only while NOOP is open, so it " @@ -181,7 +186,7 @@ final class LiftLiveActivityController { lastState = next lastPush = Date() let content = ActivityContent(state: next, staleDate: Date().addingTimeInterval(Self.staleAfter)) - Task { await activity.update(content) } + pendingUpdate = Task { await activity.update(content) } } func end() async { diff --git a/StrandiOS/Widgets/TrainingCoordinator.swift b/StrandiOS/Widgets/TrainingCoordinator.swift new file mode 100644 index 0000000000..59bcf18951 --- /dev/null +++ b/StrandiOS/Widgets/TrainingCoordinator.swift @@ -0,0 +1,361 @@ +#if os(iOS) +import Foundation +import Combine +import WidgetKit +import ActivityKit +import UIKit +import UserNotifications +import SwiftUI + +/// App-owned actions and deadlines. Screens, notifications, widgets and activities use this one path. +@MainActor +final class TrainingCoordinator: ObservableObject { + @Published var foregroundConfirmation: TrainingDisplay? + @Published var foregroundPulseLoss: TrainingDisplay? + @Published var foregroundError: String? + static let pulseStateKey = "noop.trainingPulseState" + private let model: AppModel + private let lift: LiftSessionController + private let liftActivity: LiftLiveActivityController + private var guards: [String: TrainingPulseGuard] + private var confirmations: [String: TrainingEndConfirmation] = [:] + private var errors: [String: String] = [:] + private var wasPaused: [String: Bool] = [:] + private var subscriptions = Set() + private var timer: Task? + private var armedDeadline: Double? + private var lastCheckpoint: [String: Int] = [:] + private var workoutActivity: Activity? + private var lastWorkoutState: WorkoutActivityAttributes.ContentState? + private var lastWidgetSnapshot: TrainingSnapshot? + private var workoutUpdate: Task? + var isShowingWorkout: Bool { workoutActivity?.activityState == .active || workoutActivity?.activityState == .stale } + private var canStartFromIntent = false + private var holdsRealtimeHR = false + private var connectionAvailable = false + + init(model: AppModel, lift: LiftSessionController, liftActivity: LiftLiveActivityController) { + self.model = model; self.lift = lift; self.liftActivity = liftActivity + guards = UserDefaults.standard.data(forKey: Self.pulseStateKey) + .flatMap { try? JSONDecoder().decode([String: TrainingPulseGuard].self, from: $0) } ?? [:] + for session in TrainingSnapshot.load().sessions { + if let confirmation = TrainingEndConfirmation(restoring: session) { confirmations[session.id] = confirmation } + } + // Install before any scene; receipt precedes biometric publication, including after a long suspension. + model.live.onReadableHeartRate = { [weak self] date in self?.receivedPulse(at: date) } + TrainingIntentHandler.perform = { [weak self] action, id, favorite, token in + guard let self else { throw CocoaError(.featureUnsupported) } + let started = ProcessInfo.processInfo.systemUptime + let sessionMatched = self.ids.contains(id) + self.canStartFromIntent = true + defer { + self.canStartFromIntent = false + if TestCentre.active(.display) { + let ms = (ProcessInfo.processInfo.systemUptime - started) * 1_000 + DisplayPerformanceMonitor.shared.emit?("trainingIntent action=\(action) handlerMs=\(String(format: "%.1f", ms)) sessionMatched=\(sessionMatched) confirming=\(self.confirmations[id] != nil) activeSessions=\(self.ids.count)") + } + } + do { try await self.perform(action, id: id, favorite: favorite, token: token) } + catch { + await self.flushActivityUpdates() + throw error + } + await self.flushActivityUpdates() + } + model.$activeWorkout.dropFirst().debounce(for: .milliseconds(250), scheduler: RunLoop.main) + .sink { [weak self] _ in self?.reconcile() }.store(in: &subscriptions) + lift.changesSettled.sink { [weak self] _ in self?.reconcile() }.store(in: &subscriptions) + lift.strapStepTaken.sink { [weak self] _ in self?.publish(alert: true) }.store(in: &subscriptions) + model.live.$heartRate.sink { [weak model, weak liftActivity] bpm in + liftActivity?.updateHeartRate(model?.live.connected == true ? (model?.bpm ?? bpm) : nil) + }.store(in: &subscriptions) + model.live.$connected.removeDuplicates().sink { [weak self] connected in + self?.connectionAvailable = connected + self?.reconcile() + }.store(in: &subscriptions) + NotificationPresenter.shared.onTrainingAction = { [weak self] action, id, completion in + Task { + defer { completion() } + do { + try await self?.perform(action, id: id) + if action == "requestEnd" { + self?.foregroundConfirmation = TrainingSnapshot.load().sessions.first { $0.id == id } + } + } catch { self?.foregroundError = error.localizedDescription } + } + } + registerNotifications() + reconcile() + Task { await lift.loadLastSession(repo: model.repo) } + } + + private var ids: [String] { + var ids: [String] = [] + if let workout = model.activeWorkout { ids.append(workout.sessionID) } + if lift.isActive { ids.append(lift.sessionID) } + return ids + } + private func isPaused(_ id: String) -> Bool { + if let workout = model.activeWorkout, workout.sessionID == id { return workout.isPaused } + return lift.sessionID == id && lift.engine?.isPaused == true + } + + private func receivedPulse(at date: Date) { + let now = Int(date.timeIntervalSince1970) + // An accepted live packet proves a link even if its connection publication arrives later. + connectionAvailable = true + reconcile(at: date, publish: false) + for id in ids where !isPaused(id) { + var guardState = guards[id] ?? TrainingPulseGuard() + let first = guardState.lastSampleSec == nil + guardState.receive(at: now) + guards[id] = guardState + if first || now - (lastCheckpoint[id] ?? 0) >= TrainingPulseGuard.checkpointSeconds { + lastCheckpoint[id] = now + persistGuards() + scheduleNotification(id: id, at: date.addingTimeInterval(630)) + // One publication per checkpoint, never one per raw realtime frame. + publish() + } + } + armTimer() + } + + func reconcile(at date: Date = Date(), publish shouldPublish: Bool = true) { + let now = Int(date.timeIntervalSince1970) + let active = Set(ids) + var pulseLossID: String? + let wantsRealtimeHR = !active.isEmpty + if wantsRealtimeHR != holdsRealtimeHR { + holdsRealtimeHR = wantsRealtimeHR + if wantsRealtimeHR { model.startRealtimeHR() } else { model.stopRealtimeHR() } + } + for id in Array(guards.keys) where !active.contains(id) { + guards.removeValue(forKey: id); confirmations.removeValue(forKey: id) + lastCheckpoint.removeValue(forKey: id); errors.removeValue(forKey: id); wasPaused.removeValue(forKey: id) + cancelNotification(id) + } + for id in ids { + let paused = isPaused(id) + if var state = guards[id] { + state.setConnected(connectionAvailable, at: now) + if state != guards[id] { + guards[id] = state + lastCheckpoint.removeValue(forKey: id) + cancelNotification(id) + if !paused, let deadline = state.deadline { + scheduleNotification(id: id, at: Date(timeIntervalSince1970: Double(deadline + 30))) + } + persistGuards() + } + } + if wasPaused[id] == true && !paused { + guards[id]?.resume(at: now) + lastCheckpoint.removeValue(forKey: id) + cancelNotification(id) + if let deadline = guards[id]?.deadline { + scheduleNotification(id: id, at: Date(timeIntervalSince1970: Double(deadline + 30))) + } + persistGuards() + } + if paused && wasPaused[id] != true { cancelNotification(id) } + if paused && wasPaused[id] == nil && (model.activeWorkout?.sessionID == id + ? model.activeWorkout?.pausedForPulseLoss == true : lift.pausedForPulseLoss) { pulseLossID = id } + if !paused, let guardState = guards[id], guardState.isDue(at: now), let deadline = guardState.deadline { + pause(id, at: Date(timeIntervalSince1970: Double(deadline)), pulseLoss: true) + cancelNotification(id) + scheduleNotification(id: id, at: date.addingTimeInterval(1)) + persistGuards() + pulseLossID = id + } + wasPaused[id] = isPaused(id) + } + confirmations = confirmations.filter { active.contains($0.key) && $0.value.until > date } + if let prompt = foregroundPulseLoss, !active.contains(prompt.id) || !isPaused(prompt.id) { foregroundPulseLoss = nil } + if shouldPublish || pulseLossID != nil { publish() } + if let pulseLossID { foregroundPulseLoss = TrainingSnapshot.load().sessions.first { $0.id == pulseLossID } } + armTimer() + } + + private func pause(_ id: String, at date: Date = Date(), pulseLoss: Bool = false) { + if model.activeWorkout?.sessionID == id { model.pauseWorkout(at: date, pulseLoss: pulseLoss) } + else if lift.sessionID == id { lift.pause(at: Int(date.timeIntervalSince1970), pulseLoss: pulseLoss) } + } + + func perform(_ action: String, id: String = "", favorite: Int = -1, token: String = "") async throws { + reconcile(publish: false) + defer { reconcile(); persistGuards() } + if action == "start" { + guard UserDefaults.standard.bool(forKey: "noop.onboarded"), + UserDefaults.standard.string(forKey: "noop.acceptedTermsVersion") == Terms.currentVersion else { throw CocoaError(.userCancelled) } + let favorites = TrainingFavorite.load() + guard favorites.indices.contains(favorite) else { throw CocoaError(.validationMissingMandatoryProperty) } + let selected = favorites[favorite] + if let programID = selected.programID { + guard !lift.isActive, !lift.isSaving else { return } + guard let store = await model.repo.storeHandle(), + let program = try await store.liftPrograms(deviceId: model.repo.deviceId).first(where: { $0.id == programID }) else { throw CocoaError(.fileReadNoSuchFile) } + try await lift.start(program: program, repo: model.repo, present: false) + } else if let sport = selected.sport, WorkoutCatalog.sport(named: sport) != nil { + model.startWorkout(sport: sport) + } else { throw CocoaError(.fileReadNoSuchFile) } + // Permission is requested only by explicit settings, never by a background intent. + } else { + guard ids.contains(id), !model.isFinishingWorkout, !lift.isSaving else { return } + switch action { + case "pause": pause(id) + case "resume": + if model.activeWorkout?.sessionID == id { model.resumeWorkout() } + else { lift.resume() } + case "requestEnd": + confirmations[id] = TrainingEndConfirmation(sessionID: id) + case "cancelEnd": confirmations.removeValue(forKey: id) + case "confirmEnd": + guard let confirmation = confirmations[id], confirmation.accepts(sessionID: id, token: token) else { return } + confirmations.removeValue(forKey: id) + do { + if model.activeWorkout?.sessionID == id { try await model.finishWorkout() } + else { + _ = try await lift.save(repo: model.repo, completingUnfinished: false, sessionRpe: nil) + lift.finishedSaving() + await model.repo.refresh() + } + errors.removeValue(forKey: id) + cancelNotification(id) + } catch { + errors[id] = String(localized: "Training could not be saved") + throw error + } + default: throw CocoaError(.validationMissingMandatoryProperty) + } + } + } + + private func persistGuards() { + if let data = try? JSONEncoder().encode(guards.mapValues(\.checkpoint)) { UserDefaults.standard.set(data, forKey: Self.pulseStateKey) } + } + private func flushActivityUpdates() async { + // User actions finish their ActivityKit update before the intent returns. + await workoutUpdate?.value + await liftActivity.pendingUpdate?.value + } + private func armTimer() { + let deadlines = ids.filter { !isPaused($0) }.compactMap { guards[$0]?.deadline }.map(Double.init) + + confirmations.values.map { $0.until.timeIntervalSince1970 } + guard let next = deadlines.min() else { timer?.cancel(); timer = nil; armedDeadline = nil; return } + if timer != nil, let armedDeadline, abs(next - armedDeadline) < 30 { return } + timer?.cancel() + armedDeadline = next + let delay = max(0.01, min(Double(TrainingPulseGuard.timeoutSeconds), next - Date().timeIntervalSince1970)) + timer = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + self?.timer = nil + self?.armedDeadline = nil + self?.reconcile() + } + } + + static func sportTitle(_ sport: String) -> String { + switch sport { + case "Strength": return String(localized: "Strength training") + case "Running": return String(localized: "Running workout") + default: return sport + } + } + + private var labels: [String: String] { [ + "pause": String(localized: "Pause"), "resume": String(localized: "Resume"), + "end": String(localized: "End training"), "confirm": String(localized: "Really end training?"), + "yes": String(localized: "Yes, end training"), "cancel": String(localized: "Cancel"), + "completedOnly": String(localized: "Only completed sets will be saved."), + "paused": String(localized: "Paused"), "running": String(localized: "Training"), + "configure": String(localized: "Configure training favorites in NOOP") + ] } + private func display(id: String, kind: String, title: String, start: Date, pausedAt: Date?, pausedDuration: TimeInterval, sport: String? = nil) -> TrainingDisplay { + TrainingDisplay(id: id, kind: kind, title: title, clockStart: start.addingTimeInterval(pausedDuration), pausedAt: pausedAt, + pulseDeadline: guards[id]?.checkpoint.deadline.map { Date(timeIntervalSince1970: Double($0)) }, + confirmationUntil: confirmations[id]?.until, confirmationToken: confirmations[id]?.token, error: errors[id], sport: sport) + } + func publish(alert: Bool = false) { + var sessions: [TrainingDisplay] = [] + if let w = model.activeWorkout { sessions.append(display(id: w.sessionID, kind: "workout", title: Self.sportTitle(w.sport), + start: w.start, pausedAt: w.pausedAt, pausedDuration: w.pausedDuration, sport: w.sport)) } + if let e = lift.engine, !e.isFinished { + sessions.append(display(id: lift.sessionID, kind: "lift", title: lift.programName ?? String(localized: "Strength training"), + start: Date(timeIntervalSince1970: Double(e.startTs)), pausedAt: e.pausedAt.map { Date(timeIntervalSince1970: Double($0)) }, + pausedDuration: Double(e.pausedDuration))) + } + var favorites = TrainingFavorite.load() + for i in favorites.indices where favorites[i].name.isEmpty { + favorites[i].name = favorites[i].sport.flatMap { Self.sportTitle($0) } ?? labels["configure"] ?? "" + } + let snapshot = TrainingSnapshot(favorites: favorites, sessions: sessions, labels: labels) + if snapshot != lastWidgetSnapshot { + snapshot.save(); lastWidgetSnapshot = snapshot + WidgetCenter.shared.reloadTimelines(ofKind: "NOOPTrainingWidget") + } + pushWorkout(sessions.first(where: { $0.kind == "workout" })) + if let p = lift.presentation(system: UnitSystem(rawValue: UserDefaults.standard.string(forKey: UnitPrefs.systemKey) ?? "") ?? .metric) { + var state = LiftActivityAttributes.ContentState(isResting: p.isResting, exercise: p.exercise, status: p.status, detail: p.detail, + bpm: model.live.connected ? model.bpm : nil, next: p.next, stageStartedAt: p.stageStartedAt, restEndsAt: p.restEndsAt) + if let engine = lift.engine, engine.isPaused, case .resting(_, let endsAt) = engine.stage { + state.isResting = true + state.restEndsAt = Date(timeIntervalSince1970: Double(endsAt)) + } + state.training = sessions.first(where: { $0.kind == "lift" }); state.trainingLabels = labels + if let lightUp = liftActivity.update(state: state, alert: alert, allowBackgroundStart: canStartFromIntent) { + model.live.append(log: AppModel.stamped(lightUp.logLine)) + } + } else { liftActivity.update(state: nil) } + } + private func pushWorkout(_ display: TrainingDisplay?) { + if let current = workoutActivity, [.ended, .dismissed].contains(current.activityState) { workoutActivity = nil } + if let current = workoutActivity, let display, current.attributes.sessionID != display.id { + workoutActivity = nil; lastWorkoutState = nil + workoutUpdate = Task { await current.end(nil, dismissalPolicy: .immediate) } + } + if workoutActivity == nil { workoutActivity = Activity.activities.first { $0.attributes.sessionID == display?.id && ![.ended, .dismissed].contains($0.activityState) } } + guard let display, UserDefaults.standard.object(forKey: "noop.workoutLiveActivity") as? Bool ?? true, + ActivityAuthorizationInfo().areActivitiesEnabled else { + let old = Activity.activities + workoutActivity = nil; lastWorkoutState = nil + if !old.isEmpty { workoutUpdate = Task { for activity in old { await activity.end(nil, dismissalPolicy: .immediate) } } } + return + } + let state = WorkoutActivityAttributes.ContentState(training: display, labels: labels) + guard state != lastWorkoutState else { return } + let content = ActivityContent(state: state, staleDate: display.pulseDeadline) + if let activity = workoutActivity { + workoutUpdate = Task { await activity.update(content) } + } else if UIApplication.shared.applicationState == .active || canStartFromIntent { + do { workoutActivity = try Activity.request(attributes: .init(sessionID: display.id), content: content, pushType: nil) } + catch { model.live.append(log: "Training: Live Activity could not start: \(error.localizedDescription)") } + } + if workoutActivity != nil { lastWorkoutState = state } + } + + private func registerNotifications() { + let center = UNUserNotificationCenter.current() + let category = UNNotificationCategory(identifier: "noop-training-pulse-loss", actions: [ + UNNotificationAction(identifier: "resume", title: String(localized: "Resume")), + UNNotificationAction(identifier: "requestEnd", title: String(localized: "End training"), options: [.foreground]) + ], intentIdentifiers: []) + center.getNotificationCategories { existing in center.setNotificationCategories(existing.union([category])) } + } + private func cancelNotification(_ id: String) { + let center = UNUserNotificationCenter.current() + center.removePendingNotificationRequests(withIdentifiers: ["training-\(id)"]) + center.removeDeliveredNotifications(withIdentifiers: ["training-\(id)"]) + } + private func scheduleNotification(id: String, at date: Date) { + let content = UNMutableNotificationContent() + content.title = String(localized: "Still training?") + content.body = String(localized: "No live heart rate for ten minutes. Check your training or resume it.") + content.sound = .default; content.categoryIdentifier = "noop-training-pulse-loss"; content.userInfo = ["sessionID": id] + let request = UNNotificationRequest(identifier: "training-\(id)", content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: max(1, date.timeIntervalSinceNow), repeats: false)) + UNUserNotificationCenter.current().add(request) { _ in } + } +} +#endif diff --git a/StrandiOSShared/LiftActivityAttributes.swift b/StrandiOSShared/LiftActivityAttributes.swift index 8a64a5d8b3..18ec547717 100644 --- a/StrandiOSShared/LiftActivityAttributes.swift +++ b/StrandiOSShared/LiftActivityAttributes.swift @@ -35,6 +35,8 @@ public struct LiftActivityAttributes: ActivityAttributes { public var stageStartedAt: Date /// When the running rest is due to end; the widget counts DOWN to it. Nil while working. public var restEndsAt: Date? + var training: TrainingDisplay? + var trainingLabels: [String: String]? public init(isResting: Bool, exercise: String, status: String, detail: String?, bpm: Int?, next: String, diff --git a/StrandiOSShared/TrainingIntents.swift b/StrandiOSShared/TrainingIntents.swift new file mode 100644 index 0000000000..154d1c54ff --- /dev/null +++ b/StrandiOSShared/TrainingIntents.swift @@ -0,0 +1,50 @@ +#if os(iOS) +import AppIntents +import Foundation + +/// The same intent is compiled into app and extension; LiveActivityIntent executes in the app process. +@MainActor +enum TrainingIntentHandler { + static var perform: ((String, String, Int, String) async throws -> Void)? +} + +enum TrainingAction: String, AppEnum { + case start, pause, resume, requestEnd, confirmEnd, cancelEnd + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Action" + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .start: "Start", .pause: "Pause", .resume: "Resume", + .requestEnd: "End training", .confirmEnd: "Yes, end training", .cancelEnd: "Cancel" + ] +} + +struct TrainingActionIntent: LiveActivityIntent { + static var title: LocalizedStringResource = "Control training" + static var openAppWhenRun = false + @Parameter(title: "Action", default: .pause) var action: TrainingAction + @Parameter(title: "Session", default: "") var sessionID: String + @Parameter(title: "Favorite", default: -1) var favorite: Int + @Parameter(title: "Confirmation", default: "") var token: String + init() {} + init(_ action: TrainingAction, sessionID: String = "", favorite: Int = -1, token: String = "") { + self.action = action; self.sessionID = sessionID; self.favorite = favorite; self.token = token + } + @MainActor + func perform() async throws -> some IntentResult { + guard let handler = TrainingIntentHandler.perform else { throw CocoaError(.featureUnsupported) } + try await handler(action.rawValue, sessionID, favorite, token) + return .result() + } +} + +enum TrainingFavoriteSlot: Int, AppEnum { + case first = 0, second = 1, third = 2 + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Training favorite" + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .first: "Favorite 1", .second: "Favorite 2", .third: "Favorite 3" + ] +} +struct TrainingWidgetConfiguration: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "Training favorite" + @Parameter(title: "Favorite", default: .first) var favorite: TrainingFavoriteSlot +} +#endif diff --git a/StrandiOSShared/TrainingSnapshot.swift b/StrandiOSShared/TrainingSnapshot.swift new file mode 100644 index 0000000000..566cd6f415 --- /dev/null +++ b/StrandiOSShared/TrainingSnapshot.swift @@ -0,0 +1,75 @@ +import Foundation + +struct TrainingFavorite: Codable, Equatable, Identifiable { + var id: Int + var name: String + var sport: String? + var programID: String? + static let defaults: [Self] = [ + .init(id: 0, name: "", sport: "Strength"), + .init(id: 1, name: "", sport: "Running"), + .init(id: 2, name: "") + ] + static let storageKey = "noop.trainingFavorites" + static let configurationURL = URL(string: "noop://training-favorites")! + static func load(from defaults: UserDefaults = .standard) -> [Self] { + guard let data = defaults.data(forKey: storageKey), + let values = try? JSONDecoder().decode([Self].self, from: data), + values.count == 3, values.map(\.id) == [0, 1, 2] else { return Self.defaults } + return values + } + static func save(_ values: [Self], into defaults: UserDefaults = .standard) { + guard values.count == 3, values.map(\.id) == [0, 1, 2], let data = try? JSONEncoder().encode(values) else { return } + defaults.set(data, forKey: storageKey) + } +} + +struct TrainingDisplay: Codable, Hashable, Identifiable { + var id: String + var kind: String + var title: String + var clockStart: Date + var pausedAt: Date? + var pulseDeadline: Date? + var confirmationUntil: Date? + var confirmationToken: String? + var error: String? + var sport: String? + func isConfirming(at now: Date = Date()) -> Bool { confirmationUntil.map { now < $0 } ?? false } +} + +struct TrainingSnapshot: Codable, Equatable { + var favorites: [TrainingFavorite] + var sessions: [TrainingDisplay] + var labels: [String: String] + static let storageKey = "noop.trainingWidget" + static var unavailable: Self { .init(favorites: [], sessions: [], labels: [:]) } + static func load() -> Self { + guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName), + let data = defaults.data(forKey: storageKey), + let value = try? JSONDecoder().decode(Self.self, from: data) else { return .unavailable } + return value + } + func save() { + guard let defaults = UserDefaults(suiteName: WidgetSnapshot.suiteName), + let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.storageKey) + } +} + +struct TrainingEndConfirmation: Equatable { + let sessionID: String + let token: String + let until: Date + init(sessionID: String, now: Date = Date()) { + self.sessionID = sessionID; token = UUID().uuidString; until = now.addingTimeInterval(30) + } + init?(restoring training: TrainingDisplay, now: Date = Date()) { + guard let token = training.confirmationToken, let until = training.confirmationUntil, + now < until, until.timeIntervalSince(now) <= 30 else { return nil } + sessionID = training.id; self.token = token; self.until = until + } + func accepts(sessionID: String, token: String, now: Date = Date()) -> Bool { + self.sessionID == sessionID && self.token == token && now < until + } +} diff --git a/StrandiOSShared/WorkoutActivityAttributes.swift b/StrandiOSShared/WorkoutActivityAttributes.swift new file mode 100644 index 0000000000..5d664118aa --- /dev/null +++ b/StrandiOSShared/WorkoutActivityAttributes.swift @@ -0,0 +1,10 @@ +#if os(iOS) +import ActivityKit +struct WorkoutActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var training: TrainingDisplay + var labels: [String: String] + } + var sessionID: String +} +#endif diff --git a/StrandiOSWidgets/LiftLiveActivity.swift b/StrandiOSWidgets/LiftLiveActivity.swift index 57dad8eb03..a73be55c9e 100644 --- a/StrandiOSWidgets/LiftLiveActivity.swift +++ b/StrandiOSWidgets/LiftLiveActivity.swift @@ -17,15 +17,16 @@ import StrandDesign struct LiftLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LiftActivityAttributes.self) { context in - lockScreen(context.state) + LiftActivityView(state: context.state) .activityBackgroundTint(StrandPalette.surfaceBase) .activitySystemActionForegroundColor(StrandPalette.textPrimary) } dynamicIsland: { context in - let tint = tint(context.state) + let view = LiftActivityView(state: context.state) + let tint = view.tint return DynamicIsland { DynamicIslandExpandedRegion(.leading) { Label(context.state.exercise, systemImage: "dumbbell.fill") - .font(.caption).lineLimit(1) + .font(StrandFont.caption).lineLimit(1) .foregroundStyle(tint) } DynamicIslandExpandedRegion(.trailing) { @@ -34,124 +35,114 @@ struct LiftLiveActivity: Widget { } icon: { Image(systemName: "heart.fill") } - .font(.caption) + .font(StrandFont.caption) .foregroundStyle(context.state.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose) } DynamicIslandExpandedRegion(.bottom) { - HStack { - Text(context.state.detail ?? context.state.status) - .font(.caption).lineLimit(1) - .foregroundStyle(StrandPalette.textSecondary) - Spacer(minLength: 8) - clock(context.state, tint: tint) - .font(.system(size: 15, weight: .semibold, design: .rounded)) + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack { + Text(context.state.detail ?? context.state.status) + .font(StrandFont.caption).lineLimit(1) + .foregroundStyle(StrandPalette.textSecondary) + Spacer(minLength: NoopMetrics.space2) + view.clock.font(StrandFont.bodyNumber) + } + if let training = context.state.training { + TrainingControls(training: training, labels: context.state.trainingLabels ?? [:]) + } } } } compactLeading: { - // The heart rate, where the island has the room for it, with the dumbbell standing in until - // the strap reports one — so this side is never the blank it was (Utku, 22 Sep 2026). - Label { - Text(context.state.bpm.map(String.init) ?? "").monospacedDigit() - } icon: { - Image(systemName: context.state.bpm == nil ? "dumbbell.fill" : "heart.fill") + if let bpm = context.state.bpm { + HStack(spacing: NoopMetrics.space1) { + Image(systemName: "heart.fill") + .resizable().scaledToFit() + .frame(width: NoopMetrics.space4, height: NoopMetrics.space4) + Text(bpm, format: .number).monospacedDigit() + } + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.metricRose) + .frame(height: NoopMetrics.space6) + } else { + WorkoutTypeIcon(workoutType: .strength, size: NoopMetrics.space4, color: tint) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } - .font(Self.islandFont) - .foregroundStyle(context.state.bpm == nil ? tint : StrandPalette.metricRose) } compactTrailing: { // Sized like the Lock Screen's clock: a running `Text(timerInterval:)` takes every point it // is offered, which stretched the island and left the digits adrift in its middle with blank - // to their right (Utku, 22 Sep 2026). A hidden "00:00" in the same font gives the region the + // to their right (Utku, 22 Sep 2026). A hidden "0:00:00" in the same font gives the region the // width of the clock itself, and the live one is right-aligned over it. - Text(verbatim: "00:00") - .font(Self.islandFont) + Text(verbatim: "0:00:00") + .font(StrandFont.captionNumber) .monospacedDigit() .hidden() .overlay(alignment: .trailing) { - clock(context.state, tint: tint) - .font(Self.islandFont) + view.clock + .font(StrandFont.captionNumber) .multilineTextAlignment(.trailing) + .lineLimit(1).minimumScaleFactor(0.8) } + .frame(height: NoopMetrics.space6) } minimal: { - Image(systemName: "dumbbell.fill").foregroundStyle(tint) + Image(systemName: context.state.training?.pausedAt == nil ? "dumbbell.fill" : "pause.fill") + .resizable().scaledToFit() + .frame(width: NoopMetrics.space4, height: NoopMetrics.space4) + .foregroundStyle(tint) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) } + .contentMargins(.horizontal, NoopMetrics.space4, for: .expanded) } } +} - /// The Lock Screen clock's face, shared by the clock and the hidden template that sizes it. - private static let clockFont = Font.system(size: 22, weight: .bold, design: .rounded) - /// The Dynamic Island's compact face, shared by its heart rate, its clock and that clock's template. - private static let islandFont = Font.system(size: 13, weight: .semibold, design: .rounded) - - /// Green while working, amber through the rest — the sheet's and the bar's colour language. - private func tint(_ state: LiftActivityAttributes.ContentState) -> Color { - state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive +struct LiftActivityView: View { + let state: LiftActivityAttributes.ContentState + var tint: Color { + state.training?.pausedAt != nil || state.isResting ? StrandPalette.metricAmber : StrandPalette.statusPositive } - - private func lockScreen(_ state: LiftActivityAttributes.ContentState) -> some View { - // Width goes to the words. The icon and the numbers sit nearer the banner's edges, and the heart - // rate stacks over the clock instead of beside it, so the exercise and the next set lose less to - // truncation — at the same sizes (Utku, 21 Sep 2026: "the writings are usually cut too quick"). - HStack(spacing: 10) { - Image(systemName: "dumbbell.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(tint(state)) - - VStack(alignment: .leading, spacing: 2) { - Text(state.exercise) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(StrandPalette.textPrimary) - .lineLimit(1) - Text(state.detail.map { "\(state.status) — \($0)" } ?? state.status) - .font(.caption) - .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(1) - // The set coming up, alone on the line, arriving pre-localized from the app (the - // extension has no catalog). It puts the set number before the exercise, so the tail - // truncation a long name needs cuts the name and keeps the number. - Text(state.next) - .font(.caption2) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) - .truncationMode(.tail) - } - - Spacer(minLength: 6) - - // Heart rate over the clock, both flush right. A running `Text(timerInterval:)` takes all the - // width it is offered, so the clock's width comes from a hidden "00:00" in the same font — - // the widest a set or a rest shows under an hour — and the live clock is right-aligned over - // it. Sized from the timer itself, a working set's count-up spread across the whole banner. - // - // The heart rate is ALWAYS present, dash and all. A readout that vanishes when the strap - // stops reading is indistinguishable from a missing feature — which is exactly how it - // was first reported. - VStack(alignment: .trailing, spacing: 2) { + var body: some View { + let focused = state.training.map { $0.isConfirming() || $0.error != nil } ?? false + return VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(alignment: .top, spacing: NoopMetrics.space2) { + Label(state.exercise, systemImage: state.training?.pausedAt == nil ? "dumbbell.fill" : "pause.fill") + .font(focused ? StrandFont.caption.weight(.semibold) : StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary).lineLimit(1).minimumScaleFactor(0.85) + Spacer(minLength: 0) + // Missing HR remains visible as a dash (Utku, 21 Sep 2026). Label { Text(state.bpm.map(String.init) ?? "—").monospacedDigit() } icon: { Image(systemName: "heart.fill") } - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(state.bpm == nil - ? StrandPalette.textTertiary - : StrandPalette.metricRose) - - Text(verbatim: "00:00") - .font(Self.clockFont) - .monospacedDigit() - .hidden() - .overlay(alignment: .trailing) { - clock(state, tint: tint(state)) - .font(Self.clockFont) - .multilineTextAlignment(.trailing) + .font(StrandFont.captionNumber) + .foregroundStyle(state.bpm == nil ? StrandPalette.textTertiary : StrandPalette.metricRose) + .fixedSize() + } + if !focused { + HStack(spacing: NoopMetrics.space2) { + Text(state.training?.pausedAt == nil ? state.status : (state.trainingLabels?["paused"] ?? "") + " · " + state.status) + .font(StrandFont.caption).foregroundStyle(tint).lineLimit(1) + Spacer(minLength: 0) + Text(verbatim: "00:00").font(StrandFont.title2).hidden() + .overlay(alignment: .trailing) { + clock.font(StrandFont.title2).multilineTextAlignment(.trailing) + } + } + HStack(spacing: NoopMetrics.space2) { + if let detail = state.detail { + Text(detail).font(StrandFont.bodyNumber).foregroundStyle(StrandPalette.textPrimary).fixedSize() } + Label(state.next, systemImage: "arrow.turn.down.right") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary).lineLimit(1) + } + } + if let training = state.training { + TrainingControls(training: training, labels: state.trainingLabels ?? [:]) } } - .padding(.vertical, 14) - .padding(.leading, 10) - .padding(.trailing, 12) + .padding(NoopMetrics.space3) } /// Counts DOWN through a rest (the number you act on) and UP through a set, both self-ticking. @@ -170,17 +161,17 @@ struct LiftLiveActivity: Widget { /// to a count-up. A rest with no length (the sheet is complete) has no range to count and shows /// the same 0:00. A working set counts up from its start; a zero-length range would render /// nothing, so that end is pushed a day out — well beyond any session. - private func clock(_ state: LiftActivityAttributes.ContentState, tint: Color) -> some View { + var clock: some View { Group { if let ends = state.restEndsAt { if ends > state.stageStartedAt { - Text(timerInterval: state.stageStartedAt...ends, countsDown: true) + Text(timerInterval: state.stageStartedAt...ends, pauseTime: state.training?.pausedAt, countsDown: true) } else { Text(verbatim: "0:00") } } else { Text(timerInterval: state.stageStartedAt...state.stageStartedAt.addingTimeInterval(86_400), - countsDown: false) + pauseTime: state.training?.pausedAt, countsDown: false) } } .monospacedDigit() diff --git a/StrandiOSWidgets/NOOPWidgetBundle.swift b/StrandiOSWidgets/NOOPWidgetBundle.swift index b688369f1d..f71be80ce8 100644 --- a/StrandiOSWidgets/NOOPWidgetBundle.swift +++ b/StrandiOSWidgets/NOOPWidgetBundle.swift @@ -8,12 +8,14 @@ import SwiftUI @main struct NOOPWidgetBundle: WidgetBundle { var body: some Widget { + TrainingWidget() NOOPWidget() NOOPLiveActivity() CoachBriefWidget() HeartRateWidget() StressWidget() LiftLiveActivity() + WorkoutLiveActivity() SyncLiveActivity() } } diff --git a/StrandiOSWidgets/TrainingControls.swift b/StrandiOSWidgets/TrainingControls.swift new file mode 100644 index 0000000000..a541ef767c --- /dev/null +++ b/StrandiOSWidgets/TrainingControls.swift @@ -0,0 +1,91 @@ +import SwiftUI +import WidgetKit +import StrandDesign + +struct TrainingControls: View { + let training: TrainingDisplay + let labels: [String: String] + var compact = false + var iconsOnly = false + @Environment(\.widgetRenderingMode) private var renderingMode + private func label(_ key: String) -> String { labels[key] ?? "" } + private var textColor: Color { renderingMode == .fullColor ? StrandPalette.textPrimary : StrandPalette.onDarkPrimary } + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + if let error = training.error { + Text(error).font(StrandFont.footnote).foregroundStyle(StrandPalette.metricAmber).lineLimit(2) + } + if training.isConfirming(), let token = training.confirmationToken { + Text(label("confirm")).font(StrandFont.caption.weight(.semibold)).foregroundStyle(textColor) + if training.kind == "lift" && !compact { + let warning = label("completedOnly") + Text(verbatim: warning).font(StrandFont.footnote).foregroundStyle(StrandPalette.textSecondary) + } + HStack(spacing: NoopMetrics.space2) { + button(.confirmEnd, title: label("yes"), symbol: "checkmark", token: token, destructive: true) + button(.cancelEnd, title: label("cancel"), symbol: "xmark") + } + } else { + HStack(spacing: NoopMetrics.space2) { + Toggle(isOn: training.pausedAt != nil, + intent: TrainingActionIntent(training.pausedAt == nil ? .pause : .resume, sessionID: training.id)) { + Text(label(training.pausedAt == nil ? "pause" : "resume")) + } + .toggleStyle(PauseStyle(controls: self)) + button(.requestEnd, title: label("end"), symbol: "stop.fill") + } + } + } + .font(StrandFont.caption) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + private func button(_ action: TrainingAction, title: String, symbol: String, token: String = "", + destructive: Bool = false) -> some View { + Button(intent: TrainingActionIntent(action, sessionID: training.id, token: token)) { + buttonLabel(title: title, symbol: symbol, destructive: destructive) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityHint(action == .confirmEnd && training.kind == "lift" ? label("completedOnly") : "") + } + private func buttonLabel(title: String, symbol: String, emphasized: Bool = false, destructive: Bool = false) -> some View { + let icon = destructive ? StrandPalette.statusCritical : emphasized ? StrandPalette.accent : StrandPalette.textPrimary + let fill = destructive ? StrandPalette.statusCritical.opacity(0.18) : emphasized ? StrandPalette.accentMuted : StrandPalette.surfaceRaised + return HStack(spacing: NoopMetrics.space1) { + if !compact || iconsOnly { + Image(systemName: symbol) + .foregroundStyle(renderingMode == .fullColor ? icon : StrandPalette.onDarkPrimary) + } + if !iconsOnly { Text(title) } + } + .font(StrandFont.caption.weight(.semibold)) + .padding(.horizontal, NoopMetrics.space2) + .frame(minWidth: iconsOnly ? NoopButtonMetrics.minHitTarget : nil, maxWidth: .infinity, + minHeight: compact ? NoopMetrics.compactMetadataMinHeight : NoopButtonMetrics.minHitTarget) + .foregroundStyle(textColor) + .background(renderingMode == .fullColor ? fill : StrandPalette.hairlineStrong, in: Capsule()) + } + private struct PauseStyle: ToggleStyle { + let controls: TrainingControls + func makeBody(configuration: Configuration) -> some View { + let title = controls.label(configuration.isOn ? "resume" : "pause") + Button { configuration.isOn.toggle() } label: { + controls.buttonLabel(title: title, symbol: configuration.isOn ? "play.fill" : "pause.fill", emphasized: true) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } + } +} + +struct TrainingClock: View { + let training: TrainingDisplay + @Environment(\.widgetRenderingMode) private var renderingMode + var body: some View { + Text(timerInterval: training.clockStart...max(training.clockStart, training.pulseDeadline ?? training.clockStart.addingTimeInterval(7 * 86_400)), + pauseTime: training.pausedAt, countsDown: false) + .monospacedDigit() + .foregroundStyle(renderingMode == .fullColor ? StrandPalette.textPrimary : StrandPalette.onDarkPrimary) + } +} diff --git a/StrandiOSWidgets/TrainingWidget.swift b/StrandiOSWidgets/TrainingWidget.swift new file mode 100644 index 0000000000..b1863f784a --- /dev/null +++ b/StrandiOSWidgets/TrainingWidget.swift @@ -0,0 +1,195 @@ +import SwiftUI +import WidgetKit +import StrandDesign + +struct TrainingEntry: TimelineEntry { + var date: Date + var snapshot: TrainingSnapshot + var favorite: Int +} +struct TrainingWidgetProvider: AppIntentTimelineProvider { + private var preview: TrainingSnapshot { + var favorites = TrainingFavorite.defaults + favorites[0].name = String(localized: "Strength training") + favorites[1].name = String(localized: "Running workout") + favorites[2].name = String(localized: "Configure") + return .init(favorites: favorites, sessions: [], labels: [:]) + } + func placeholder(in context: Context) -> TrainingEntry { .init(date: .now, snapshot: preview, favorite: 0) } + func snapshot(for configuration: TrainingWidgetConfiguration, in context: Context) async -> TrainingEntry { + let saved = TrainingSnapshot.load() + return .init(date: .now, snapshot: context.isPreview && saved.favorites.isEmpty ? preview : saved, + favorite: configuration.favorite.rawValue) + } + func timeline(for configuration: TrainingWidgetConfiguration, in context: Context) async -> Timeline { + let entry = await snapshot(for: configuration, in: context) + let confirmationEnd = entry.snapshot.sessions.compactMap(\.confirmationUntil).filter { $0 > Date() }.min() + let next = confirmationEnd ?? Date().addingTimeInterval(900) + return Timeline(entries: [entry], policy: .after(next)) + } +} +struct TrainingWidget: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: "NOOPTrainingWidget", intent: TrainingWidgetConfiguration.self, provider: TrainingWidgetProvider()) { entry in + TrainingWidgetView(entry: entry) + .containerBackground(StrandPalette.surfaceBase, for: .widget) + } + .configurationDisplayName("Training") + .description("Start a favorite or control your running training.") + .supportedFamilies([.systemMedium, .accessoryRectangular]) + } +} +struct TrainingWidgetView: View { + let entry: TrainingEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + private var textColor: Color { renderingMode == .fullColor ? StrandPalette.textPrimary : StrandPalette.onDarkPrimary } + private var iconColor: Color { renderingMode == .fullColor ? StrandPalette.accent : StrandPalette.onDarkPrimary } + var body: some View { + Group { + if family == .accessoryRectangular { + accessory + } else if let training = selectedSession { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + if training.isConfirming() || training.error != nil { + Text(training.title).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary).lineLimit(1) + TrainingControls(training: training, labels: entry.snapshot.labels) + } else { + HStack(spacing: NoopMetrics.space3) { + VStack(alignment: .leading, spacing: NoopMetrics.spaceHalf) { + Text(training.title).font(StrandFont.caption.weight(.semibold)).lineLimit(1) + TrainingClock(training: training).font(StrandFont.title2).multilineTextAlignment(.leading) + status(training) + } + .frame(maxWidth: .infinity, alignment: .leading) + TrainingControls(training: training, labels: entry.snapshot.labels, iconsOnly: true) + .fixedSize(horizontal: true, vertical: false) + } + favorites(compact: true) + } + } + } else if entry.snapshot.favorites.isEmpty { + configurationLink + } else { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text("Training").font(StrandFont.headline) + favorites(compact: false) + } + } + } + .foregroundStyle(textColor) + .tint(StrandPalette.accent) + } + @ViewBuilder private var accessory: some View { + if let training = selectedSession { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + if !training.isConfirming() && training.error == nil { + HStack(spacing: NoopMetrics.space1) { + Text(training.title).lineLimit(1) + TrainingClock(training: training).multilineTextAlignment(.trailing) + } + .font(StrandFont.caption.weight(.semibold)) + } + TrainingControls(training: training, labels: entry.snapshot.labels, + compact: training.isConfirming(), iconsOnly: !training.isConfirming()) + } + } else if let favorite = selectedFavorite { + if favorite.sport != nil || favorite.programID != nil { + Button(intent: TrainingActionIntent(.start, favorite: favorite.id)) { + HStack(spacing: NoopMetrics.space2) { + favoriteIcon(favorite) + Text(favorite.name).font(StrandFont.caption.weight(.semibold)).lineLimit(2) + Spacer(minLength: 0) + Image(systemName: "play.circle.fill").font(StrandFont.title2) + } + } + .buttonStyle(.plain) + } else { + Link(destination: TrainingFavorite.configurationURL) { + HStack(spacing: NoopMetrics.space2) { + favoriteIcon(favorite) + Text("Configure").font(StrandFont.caption.weight(.semibold)).lineLimit(2) + } + } + .accessibilityLabel(Text("Configure training favorites in NOOP")) + } + } else { + configurationLink + } + } + private var configurationLink: some View { + Link(destination: TrainingFavorite.configurationURL) { + Text("Open NOOP to configure training").font(StrandFont.footnote) + } + } + private func status(_ training: TrainingDisplay) -> some View { + Label(entry.snapshot.labels[training.pausedAt == nil ? "running" : "paused"] ?? "", + systemImage: training.pausedAt == nil ? "record.circle" : "pause.circle") + .font(StrandFont.footnote) + .foregroundStyle(training.pausedAt == nil ? StrandPalette.accent : StrandPalette.metricAmber) + } + private func favorites(compact: Bool) -> some View { + HStack(spacing: NoopMetrics.space2) { + ForEach(entry.snapshot.favorites) { favorite in + let configured = favorite.sport != nil || favorite.programID != nil + if configured { + Button(intent: TrainingActionIntent(.start, favorite: favorite.id)) { + favoriteTile(favorite, configured: true, compact: compact) + } + .buttonStyle(.plain) + .accessibilityLabel(favorite.name) + } else { + Link(destination: TrainingFavorite.configurationURL) { + favoriteTile(favorite, configured: false, compact: compact) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Configure training favorites in NOOP")) + } + } + } + } + private func favoriteTile(_ favorite: TrainingFavorite, configured: Bool, compact: Bool) -> some View { + Group { + if compact { + favoriteTitle(favorite, configured: configured).lineLimit(2) + } else { + VStack(spacing: NoopMetrics.space2) { + favoriteIcon(favorite) + favoriteTitle(favorite, configured: configured).lineLimit(2, reservesSpace: true) + } + } + } + .frame(maxWidth: .infinity, maxHeight: compact ? nil : .infinity) + .padding(NoopMetrics.space2) + .frame(minHeight: NoopButtonMetrics.minHitTarget) + .background(renderingMode == .fullColor ? StrandPalette.surfaceRaised : StrandPalette.hairlineStrong, + in: RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous) + .strokeBorder(StrandPalette.hairline, lineWidth: NoopMetrics.hairlineWidth)) + } + @ViewBuilder private func favoriteTitle(_ favorite: TrainingFavorite, configured: Bool) -> some View { + Group { + if configured { Text(favorite.name) } else { Text("Configure") } + } + .font(StrandFont.caption.weight(.semibold)).minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + .foregroundStyle(configured ? textColor : StrandPalette.textTertiary) + } + @ViewBuilder private func favoriteIcon(_ favorite: TrainingFavorite) -> some View { + if let sport = favorite.sport { + WorkoutTypeIcon(workoutType: sport, size: NoopMetrics.space6, color: iconColor) + } else { + Image(systemName: favorite.programID == nil ? "plus" : "dumbbell.fill") + .resizable().scaledToFit() + .font(StrandFont.bodyNumber) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) + .foregroundStyle(favorite.programID == nil ? StrandPalette.textTertiary : iconColor) + } + } + // A widget addresses one session. Each simultaneous session retains its own Live Activity. + private var selectedFavorite: TrainingFavorite? { entry.snapshot.favorites.first { $0.id == entry.favorite } } + private var selectedSession: TrainingDisplay? { + let kind = selectedFavorite?.programID == nil ? "workout" : "lift" + return entry.snapshot.sessions.first { $0.kind == kind } ?? entry.snapshot.sessions.first + } +} diff --git a/StrandiOSWidgets/WorkoutLiveActivity.swift b/StrandiOSWidgets/WorkoutLiveActivity.swift new file mode 100644 index 0000000000..3c476c7262 --- /dev/null +++ b/StrandiOSWidgets/WorkoutLiveActivity.swift @@ -0,0 +1,83 @@ +import SwiftUI +import WidgetKit +import ActivityKit +import StrandDesign + +struct WorkoutLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in + WorkoutActivityView(training: context.state.training, labels: context.state.labels) + .activityBackgroundTint(StrandPalette.surfaceBase) + .activitySystemActionForegroundColor(StrandPalette.textPrimary) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label { + Text(context.state.training.title) + } icon: { + WorkoutTypeIcon(workoutType: context.state.training.sport ?? KnownWorkoutType.other.rawValue, + size: NoopMetrics.space4, color: StrandPalette.accent) + } + .font(StrandFont.caption.weight(.semibold)).lineLimit(1).foregroundStyle(StrandPalette.accent) + } + DynamicIslandExpandedRegion(.trailing) { + TrainingClock(training: context.state.training).font(StrandFont.bodyNumber) + } + DynamicIslandExpandedRegion(.bottom) { + TrainingControls(training: context.state.training, labels: context.state.labels) + } + } compactLeading: { + WorkoutTypeIcon(workoutType: context.state.training.sport ?? KnownWorkoutType.other.rawValue, + size: NoopMetrics.space4, color: StrandPalette.accent) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) + } compactTrailing: { + Text(verbatim: "0:00:00").font(StrandFont.captionNumber).hidden() + .overlay(alignment: .trailing) { + TrainingClock(training: context.state.training).font(StrandFont.captionNumber) + .multilineTextAlignment(.trailing).lineLimit(1).minimumScaleFactor(0.8) + } + .frame(height: NoopMetrics.space6) + } minimal: { + WorkoutTypeIcon(workoutType: context.state.training.sport ?? KnownWorkoutType.other.rawValue, + size: NoopMetrics.space4, color: StrandPalette.accent) + .frame(width: NoopMetrics.space6, height: NoopMetrics.space6) + } + .contentMargins(.horizontal, NoopMetrics.space4, for: .expanded) + } + } +} + +struct WorkoutActivityView: View { + let training: TrainingDisplay + let labels: [String: String] + var body: some View { + let focused = training.isConfirming() || training.error != nil + return VStack(alignment: .leading, spacing: NoopMetrics.space3) { + HStack(spacing: NoopMetrics.space3) { + if !focused { + WorkoutTypeIcon(workoutType: training.sport ?? KnownWorkoutType.other.rawValue, + size: NoopMetrics.space6, color: StrandPalette.accent) + .frame(width: NoopButtonMetrics.minHitTarget, height: NoopButtonMetrics.minHitTarget) + .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius)) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Text(training.title).font(focused ? StrandFont.caption.weight(.semibold) : StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary).lineLimit(focused ? 1 : 2) + if !focused { + Text(labels[training.pausedAt == nil ? "running" : "paused"] ?? "") + .font(StrandFont.caption).foregroundStyle(training.pausedAt == nil ? StrandPalette.textSecondary : StrandPalette.metricAmber) + } + } + Spacer(minLength: 0) + Text(verbatim: "0:00:00").font(focused ? StrandFont.bodyNumber : StrandFont.title1).hidden() + .overlay(alignment: .trailing) { + TrainingClock(training: training).font(focused ? StrandFont.bodyNumber : StrandFont.title1).multilineTextAlignment(.trailing) + .lineLimit(1).minimumScaleFactor(0.8) + } + } + TrainingControls(training: training, labels: labels) + } + .padding(NoopMetrics.space4) + } +} diff --git a/project.yml b/project.yml index beac7b618f..b9d271bb96 100644 --- a/project.yml +++ b/project.yml @@ -167,6 +167,7 @@ targets: # Compile the exact cross-target snapshot source into the test bundle so the AltStore App Group # resolver is covered without adding this iOS/widget-only type to the macOS application module. - StrandiOSShared/WidgetSnapshot.swift + - StrandiOSShared/TrainingSnapshot.swift # Same reasoning for the heart-rate trace: the pure half of the widget is where every decision # worth testing lives, and it is iOS/widget-only, so it is compiled into the test bundle rather # than added to the macOS app. @@ -377,6 +378,7 @@ targets: platform: iOS deploymentTarget: "17.0" sources: + - Strand/Resources/Localizable.xcstrings - path: StrandiOSWidgets excludes: - "Info.plist" From b93b26a546c56bbd558113a87b9ef7bb813990c7 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:49:43 +0200 Subject: [PATCH 4/5] Align training lifecycle and shell call sites with the installed build --- Strand/App/AppModel.swift | 2 +- StrandiOS/App/RootTabView.swift | 3 ++- StrandiOS/App/StrandiOSApp.swift | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 24f492d58d..2fffb8e384 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -891,7 +891,7 @@ final class AppModel: ObservableObject { /// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its /// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay /// a small per-sample write instead of rewriting a growing route on every beat. - private func persistActiveWorkout() { + func persistActiveWorkout() { guard let w = activeWorkout else { return } ActiveWorkoutPersistence.store( ActiveWorkoutPersistence.Snapshot( diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 31cdf88e49..dd52293819 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -29,7 +29,8 @@ struct RootTabView: View { /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool - @EnvironmentObject private var repo: Repository + // The shell uses repository actions only; its screens observe the data themselves. + let repo: Repository /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index d5438793d1..4189e0db6a 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -214,7 +214,7 @@ struct StrandiOSApp: App { var body: some Scene { WindowGroup { - iOSRootView() + iOSRootView(repo: model.repo) .modifier(TrainingNotificationConfirmation(training: training)) .environmentObject(model) .environmentObject(model.ble) // #334: Today pull-to-sync reads BLEManager (no HR churn) @@ -378,6 +378,7 @@ struct StrandiOSApp: App { await watch.pushLatest(from: model) } } else if phase == .background { + model.persistActiveWorkout() training.reconcile() // Re-submit on every transition because iOS may discard an old best-effort request. HealthWritebackBackgroundScheduler.updateSchedule( @@ -411,6 +412,7 @@ struct StrandiOSApp: App { /// excluded `RootView()` sidebar for `RootTabView()`. The shared `OnboardingWizard`, `TermsGateView`, /// `WhatsNewView`, `AppChangelog`, and `Terms` symbols all compile into the iOS target unchanged. private struct iOSRootView: View { + let repo: Repository @AppStorage("noop.onboarded") private var onboarded = false @AppStorage("noop.lastSeenChangelogVersion") private var lastSeenChangelog = "" @AppStorage("noop.acceptedTermsVersion") private var acceptedTerms = "" @@ -441,7 +443,7 @@ private struct iOSRootView: View { private var shell: some View { ZStack { - RootTabView(homeScreenQuickActionsEnabled: + RootTabView(repo: repo, homeScreenQuickActionsEnabled: demoBypass || (onboarded && acceptedTerms == Terms.currentVersion && automaticLaunchSheetResolved)) if !onboarded && !demoBypass { From 16294302a24a8653193b85c2fc1d2fbd7c4492a9 Mon Sep 17 00:00:00 2001 From: alexanderschmidt2 <67632384+alexanderschmidt2@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:11 +0200 Subject: [PATCH 5/5] Match the installed tab-shell initializer argument order --- StrandiOS/App/RootTabView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index dd52293819..d7f3ac10dd 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -25,12 +25,12 @@ struct RootTabView: View { /// The live gym session, owned at the app root — see `LiftSessionController`. @EnvironmentObject private var liftSession: LiftSessionController + // The shell uses repository actions only; its screens observe the data themselves. + let repo: Repository /// External entry points must wait until the mandatory first-run gates have completed. The root owns /// that state; keeping it explicit here prevents this shell's window-level sheet from covering a gate. let homeScreenQuickActionsEnabled: Bool - // The shell uses repository actions only; its screens observe the data themselves. - let repo: Repository /// Cross-screen navigation requests (e.g. Live → "Manage devices"). Devices isn't a tab — it lives /// behind the More list — so a request presents it as a sheet, matching the quick-action screens. @EnvironmentObject private var router: NavRouter