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:
Millian Lamiaux
2026-05-16 15:28:45 +02:00
parent 95f34e6471
commit dc3ff15e81
5 changed files with 476 additions and 255 deletions

View File

@@ -1,10 +1,44 @@
import ActivityKit
import Foundation
#if canImport(SwiftUI)
import SwiftUI
#endif
enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
case prep
case warmup
case work
case rest
case interBlockRest
case cooldown
case complete
var capitalized: String {
switch self {
case .interBlockRest: return "Inter-Block Rest"
default: return rawValue.capitalized
}
}
#if canImport(SwiftUI)
var color: Color {
switch self {
case .prep: return Color(red: 1.0, green: 0.58, blue: 0.0)
case .warmup: return Color(red: 1.0, green: 0.58, blue: 0.0)
case .work: return Color(red: 1.0, green: 0.42, blue: 0.21)
case .rest, .interBlockRest: return Color(red: 0.35, green: 0.78, blue: 0.98)
case .cooldown: return Color(red: 0.35, green: 0.78, blue: 0.98)
case .complete: return Color(red: 0.19, green: 0.82, blue: 0.35)
}
}
#endif
}
struct WorkoutActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
var exerciseName: String
var phase: String
var phase: WorkoutPhase
var phaseEndDate: Date
var roundCurrent: Int
var roundTotal: Int

View File

@@ -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,