feat: production-grade Live Activity with type-safe phases, decomposed views, previews, and alert transitions
- Replace raw string phase model with WorkoutPhase enum (Codable, Sendable, CaseIterable) with built-in .capitalized display name and SwiftUI .color per phase - Decompose WorkoutLiveActivity into reusable view structs: PhasePill, CountdownText, WorkoutProgressBar, MusicInfoRow, HeartRateBadge, PhaseIndicatorDot, WorkoutLockScreenView, WorkoutSmallView — following CraftingSwift iOS 26 architecture patterns - Add AlertConfiguration on work/rest/complete phase transitions so Dynamic Island expands and lights up at key moments - Add 13 #Preview blocks across both widgets covering all presentation types: lock screen, expanded, compact, minimal — for instant Xcode Canvas feedback - Add stale state handling (context.isStale shows 'Last updated' indicator) - MusicLiveActivity: 5 new #Preview blocks for playing/paused/expanded/compact/minimal
This commit is contained in:
@@ -371,10 +371,11 @@ final class PlayerViewModel: ObservableObject {
|
||||
|
||||
let isPlayingMusic = (phase == .work || phase == .rest) && isRunning && !isPaused
|
||||
let phaseEnd = Date().addingTimeInterval(Double(timeRemaining))
|
||||
let workoutPhase = WorkoutPhase(rawValue: phase.rawValue) ?? .prep
|
||||
|
||||
let state = WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: currentExercise?.nameEn ?? "",
|
||||
phase: phase.rawValue,
|
||||
phase: workoutPhase,
|
||||
phaseEndDate: phaseEnd,
|
||||
roundCurrent: currentRound,
|
||||
roundTotal: totalRoundsInBlock,
|
||||
@@ -385,7 +386,6 @@ final class PlayerViewModel: ObservableObject {
|
||||
isPaused: isPaused
|
||||
)
|
||||
|
||||
// Discard stale reference — user may have dismissed the Dynamic Island
|
||||
if let existing = workoutActivity, existing.activityState != .active {
|
||||
workoutActivity = nil
|
||||
}
|
||||
@@ -393,14 +393,32 @@ final class PlayerViewModel: ObservableObject {
|
||||
if let existing = workoutActivity {
|
||||
nonisolated(unsafe) let safeExisting = existing
|
||||
let staleDate = Date().addingTimeInterval(isPaused ? 3600 : 120)
|
||||
let alert = alertForPhase(workoutPhase)
|
||||
Task { @MainActor in
|
||||
await safeExisting.update(ActivityContent(state: state, staleDate: staleDate))
|
||||
if let alert {
|
||||
await safeExisting.update(ActivityContent(state: state, staleDate: staleDate), alertConfiguration: alert)
|
||||
} else {
|
||||
await safeExisting.update(ActivityContent(state: state, staleDate: staleDate))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createOrUpdateActivity(with: state)
|
||||
}
|
||||
}
|
||||
|
||||
private func alertForPhase(_ phase: WorkoutPhase) -> AlertConfiguration? {
|
||||
switch phase {
|
||||
case .work:
|
||||
return AlertConfiguration(title: "Work!", body: "Round \(currentRound) of \(totalRoundsInBlock)", sound: .default)
|
||||
case .rest:
|
||||
return AlertConfiguration(title: "Rest", body: "Recover before the next round", sound: .default)
|
||||
case .complete:
|
||||
return AlertConfiguration(title: "Workout Complete!", body: "Great job!", sound: .default)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createOrUpdateActivity(with state: WorkoutActivityAttributes.ContentState) {
|
||||
let attrs = WorkoutActivityAttributes()
|
||||
do {
|
||||
@@ -424,7 +442,7 @@ final class PlayerViewModel: ObservableObject {
|
||||
guard safeActivity.activityState == .active else { return }
|
||||
let finalState = WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: safeActivity.content.state.exerciseName,
|
||||
phase: "complete",
|
||||
phase: .complete,
|
||||
phaseEndDate: safeActivity.content.state.phaseEndDate,
|
||||
roundCurrent: safeActivity.content.state.roundCurrent,
|
||||
roundTotal: safeActivity.content.state.roundTotal,
|
||||
|
||||
Reference in New Issue
Block a user