feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2
@@ -32,7 +32,7 @@
|
||||
5A402D7E31059AB7107B625C /* MusicPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5505FBD6E001AE3AFD413ADA /* MusicPlayerViewModel.swift */; };
|
||||
5B01ABC32F9B8FFD006E707D /* MusicActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */; };
|
||||
5B01ABC82F9B90AF006E707D /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B01ABC62F9B909E006E707D /* ActivityKit.framework */; };
|
||||
5B10095D2FB7B6EC0033DE89 /* MockPrograms 2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B10095C2FB7B6EC0033DE89 /* MockPrograms 2.swift */; };
|
||||
5B10095F2FB7C4080033DE89 /* MockPrograms.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B10095E2FB7C4080033DE89 /* MockPrograms.swift */; };
|
||||
5CE2F2210BEF17AC304F2AC2 /* HealthSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DE8A4DAD846A879B8ED379 /* HealthSnapshot.swift */; };
|
||||
60503F963221C7FCF719C493 /* ActivityTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84123E854DE0BF3E0D4F0912 /* ActivityTab.swift */; };
|
||||
6060D95D485E4188EAABDDED /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEC37E6361DC4C7AE326139 /* WatchRootView.swift */; };
|
||||
@@ -183,7 +183,7 @@
|
||||
5ACBDB7D81F575AC5370E82F /* WatchL10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchL10n.swift; sourceTree = "<group>"; };
|
||||
5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicActivityAttributes.swift; sourceTree = "<group>"; };
|
||||
5B01ABC62F9B909E006E707D /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk/System/Library/Frameworks/ActivityKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
5B10095C2FB7B6EC0033DE89 /* MockPrograms 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MockPrograms 2.swift"; sourceTree = "<group>"; };
|
||||
5B10095E2FB7C4080033DE89 /* MockPrograms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrograms.swift; sourceTree = "<group>"; };
|
||||
5F5D3568A736B7A326874677 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
61E5AA44513F793EA7FEBA00 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
|
||||
63599808389B70FC2F6A43C3 /* HealthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthViewModel.swift; sourceTree = "<group>"; };
|
||||
@@ -548,7 +548,7 @@
|
||||
DC96ED5F68F75A02548ECD40 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5B10095C2FB7B6EC0033DE89 /* MockPrograms 2.swift */,
|
||||
5B10095E2FB7C4080033DE89 /* MockPrograms.swift */,
|
||||
F1DE8A4DAD846A879B8ED379 /* HealthSnapshot.swift */,
|
||||
2C6156C6E0E1A543DAC87A90 /* MusicTrack.swift */,
|
||||
7482C05380DE017FF582C28B /* PreviewData.swift */,
|
||||
@@ -818,7 +818,7 @@
|
||||
files = (
|
||||
22669D283A2B7C8D5F4FE19F /* ActivityRingView.swift in Sources */,
|
||||
60503F963221C7FCF719C493 /* ActivityTab.swift in Sources */,
|
||||
5B10095D2FB7B6EC0033DE89 /* MockPrograms 2.swift in Sources */,
|
||||
5B10095F2FB7C4080033DE89 /* MockPrograms.swift in Sources */,
|
||||
CCCCEFD2D61ED1D7DDB9040C /* AnalyticsService.swift in Sources */,
|
||||
EE6C591611D52C36ED5E03C6 /* AppState.swift in Sources */,
|
||||
14578A06877E3D67A49650A9 /* AudioService.swift in Sources */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -186,3 +186,36 @@ struct LiveActivityMusicBars: View {
|
||||
private let barMaxHeights: [CGFloat] = [8, 14, 6, 12]
|
||||
private let barMin: CGFloat = 3
|
||||
private let barSpeeds: [Double] = [2.2, 1.8, 2.8, 1.5]
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Lock Screen — Playing", as: .content, using: MusicActivityAttributes()) {
|
||||
MusicLiveActivity()
|
||||
} contentStates: {
|
||||
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen — Paused", as: .content, using: MusicActivityAttributes()) {
|
||||
MusicLiveActivity()
|
||||
} contentStates: {
|
||||
MusicActivityAttributes.ContentState(title: "Stronger", artist: "Kanye West", isPlaying: false)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: MusicActivityAttributes()) {
|
||||
MusicLiveActivity()
|
||||
} contentStates: {
|
||||
MusicActivityAttributes.ContentState(title: "Till I Collapse", artist: "Eminem", isPlaying: true)
|
||||
MusicActivityAttributes.ContentState(title: "Eye of the Tiger", artist: "Survivor", isPlaying: false)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: MusicActivityAttributes()) {
|
||||
MusicLiveActivity()
|
||||
} contentStates: {
|
||||
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: MusicActivityAttributes()) {
|
||||
MusicLiveActivity()
|
||||
} contentStates: {
|
||||
MusicActivityAttributes.ContentState(title: "Lose Yourself", artist: "Eminem", isPlaying: true)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,154 @@ import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Decomposed Views
|
||||
|
||||
struct PhasePill: View {
|
||||
let phase: WorkoutPhase
|
||||
let isPaused: Bool
|
||||
|
||||
var body: some View {
|
||||
if isPaused {
|
||||
Text("PAUSED")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(.white.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
} else {
|
||||
Text(phase.capitalized.uppercased())
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(phase.color)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(phase.color.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CountdownText: View {
|
||||
let endDate: Date
|
||||
let isPaused: Bool
|
||||
let frozenSeconds: Int
|
||||
let isUrgent: Bool
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isPaused {
|
||||
Text(Self.formatFrozenTime(frozenSeconds))
|
||||
.font(.system(size: size, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...endDate, countsDown: true)
|
||||
.font(.system(size: size, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(isUrgent ? .orange : .white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isPaused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
}
|
||||
|
||||
static func formatFrozenTime(_ seconds: Int) -> String {
|
||||
let m = seconds / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutProgressBar: View {
|
||||
let progress: CGFloat
|
||||
let color: Color
|
||||
let isPaused: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(.white.opacity(0.08))
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(color.opacity(isPaused ? 0.25 : 0.6))
|
||||
.frame(width: geo.size.width * progress)
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct MusicInfoRow: View {
|
||||
let trackTitle: String
|
||||
let trackArtist: String
|
||||
let isPlaying: Bool
|
||||
let isPaused: Bool
|
||||
let heartRate: Double
|
||||
let isLuminanceReduced: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Music")
|
||||
|
||||
Text(trackArtist.isEmpty
|
||||
? trackTitle
|
||||
: "\(trackTitle) — \(trackArtist)")
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
|
||||
.accessibilityLabel(trackArtist.isEmpty ? trackTitle : "\(trackTitle) by \(trackArtist)")
|
||||
|
||||
Spacer()
|
||||
|
||||
if heartRate > 0 {
|
||||
HeartRateBadge(heartRate: heartRate, isPaused: isPaused)
|
||||
}
|
||||
|
||||
if !isPaused, isPlaying, !isLuminanceReduced {
|
||||
LiveActivityMusicBars()
|
||||
}
|
||||
}
|
||||
.opacity(isPaused ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct HeartRateBadge: View {
|
||||
let heartRate: Double
|
||||
let isPaused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
.accessibilityLabel("Heart rate")
|
||||
Text("\(Int(heartRate))")
|
||||
.font(.system(size: 10).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(isPaused ? 0.3 : 0.5))
|
||||
}
|
||||
.accessibilityLabel("Heart rate \(Int(heartRate)) beats per minute")
|
||||
}
|
||||
}
|
||||
|
||||
struct PhaseIndicatorDot: View {
|
||||
let color: Color
|
||||
let isPaused: Bool
|
||||
let size: CGFloat
|
||||
let isPulsing: Bool
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isPaused ? color.opacity(0.4) : color)
|
||||
.frame(width: size, height: size)
|
||||
.modifier(PulseEffect(active: isPulsing))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Widget
|
||||
|
||||
struct WorkoutLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in
|
||||
@@ -9,71 +157,36 @@ struct WorkoutLiveActivity: Widget {
|
||||
@Environment(\.isActivityFullscreen) var isFullscreen
|
||||
@Environment(\.isLuminanceReduced) var isLuminanceReduced
|
||||
|
||||
let phaseColor = Self.colorForPhase(context.state.phase)
|
||||
let phaseLabel = context.state.phase.capitalized
|
||||
let paused = context.state.isPaused
|
||||
let timeRemaining = max(0, paused
|
||||
? context.state.phaseEndDate.timeIntervalSinceNow
|
||||
: context.state.phaseEndDate.timeIntervalSinceNow)
|
||||
let isUrgent = !paused && timeRemaining > 0 && timeRemaining <= 5
|
||||
|
||||
switch activityFamily {
|
||||
case .small:
|
||||
smallLockScreenView(
|
||||
context: context,
|
||||
phaseColor: phaseColor,
|
||||
phaseLabel: phaseLabel,
|
||||
paused: paused,
|
||||
frozenSeconds: Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
|
||||
)
|
||||
default:
|
||||
lockScreenView(
|
||||
context: context,
|
||||
phaseColor: phaseColor,
|
||||
phaseLabel: phaseLabel,
|
||||
isFullscreen: isFullscreen,
|
||||
isLuminanceReduced: isLuminanceReduced,
|
||||
isUrgent: isUrgent,
|
||||
paused: paused,
|
||||
frozenSeconds: Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
|
||||
)
|
||||
}
|
||||
} dynamicIsland: { context in
|
||||
let phaseColor = Self.colorForPhase(context.state.phase)
|
||||
let phaseLabel = context.state.phase.capitalized
|
||||
let phase = context.state.phase
|
||||
let paused = context.state.isPaused
|
||||
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
|
||||
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
|
||||
let isWork = context.state.phase == "work" && !paused
|
||||
|
||||
switch activityFamily {
|
||||
case .small:
|
||||
WorkoutSmallView(phase: phase, paused: paused, frozenSeconds: frozenSeconds, exerciseName: context.state.exerciseName, roundCurrent: context.state.roundCurrent, roundTotal: context.state.roundTotal, endDate: context.state.phaseEndDate)
|
||||
default:
|
||||
WorkoutLockScreenView(phase: phase, paused: paused, frozenSeconds: frozenSeconds, isFullscreen: isFullscreen, isLuminanceReduced: isLuminanceReduced, isUrgent: isUrgent, isStale: context.isStale, exerciseName: context.state.exerciseName, roundCurrent: context.state.roundCurrent, roundTotal: context.state.roundTotal, endDate: context.state.phaseEndDate, heartRate: context.state.heartRate, trackTitle: context.state.trackTitle, trackArtist: context.state.trackArtist, isPlaying: context.state.isPlaying)
|
||||
}
|
||||
} dynamicIsland: { context in
|
||||
let phase = context.state.phase
|
||||
let paused = context.state.isPaused
|
||||
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
|
||||
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
|
||||
let isWork = phase == .work && !paused
|
||||
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
|
||||
|
||||
return DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
HStack(spacing: 4) {
|
||||
if paused {
|
||||
Text("PAUSED")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(.white.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
} else {
|
||||
Text(phaseLabel.uppercased())
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(phaseColor)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(phaseColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
PhasePill(phase: phase, isPaused: paused)
|
||||
Text("\(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(paused
|
||||
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
|
||||
: "\(phaseLabel) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
: "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.exerciseName)
|
||||
@@ -83,95 +196,34 @@ struct WorkoutLiveActivity: Widget {
|
||||
.accessibilityLabel("Exercise \(context.state.exerciseName)")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Group {
|
||||
if paused {
|
||||
Text(Self.formatFrozenTime(frozenSeconds))
|
||||
.font(.system(size: 20, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
|
||||
.font(.system(size: 20, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(isUrgent ? .orange : .white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
}
|
||||
.frame(width: 56, alignment: .trailing)
|
||||
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 20)
|
||||
.frame(width: 56, alignment: .trailing)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 6) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(.white.opacity(0.08))
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(phaseColor.opacity(paused ? 0.25 : 0.6))
|
||||
.frame(width: geo.size.width * progress)
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
.frame(height: 2)
|
||||
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Music")
|
||||
Text(context.state.trackArtist.isEmpty
|
||||
? context.state.trackTitle
|
||||
: "\(context.state.trackTitle) — \(context.state.trackArtist)")
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
.accessibilityLabel(context.state.trackArtist.isEmpty ? context.state.trackTitle : "\(context.state.trackTitle) by \(context.state.trackArtist)")
|
||||
Spacer()
|
||||
if context.state.heartRate > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
.accessibilityLabel("Heart rate")
|
||||
Text("\(Int(context.state.heartRate))")
|
||||
.font(.system(size: 10).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
}
|
||||
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
|
||||
}
|
||||
if !paused, context.state.isPlaying {
|
||||
LiveActivityMusicBars()
|
||||
}
|
||||
}
|
||||
.opacity(paused ? 0.5 : 1)
|
||||
MusicInfoRow(
|
||||
trackTitle: context.state.trackTitle,
|
||||
trackArtist: context.state.trackArtist,
|
||||
isPlaying: context.state.isPlaying,
|
||||
isPaused: paused,
|
||||
heartRate: context.state.heartRate,
|
||||
isLuminanceReduced: false
|
||||
)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
} compactLeading: {
|
||||
Circle()
|
||||
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
|
||||
.frame(width: 10, height: 10)
|
||||
.modifier(PulseEffect(active: isWork))
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase, workout in progress")
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 10, isPulsing: isWork)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
|
||||
} compactTrailing: {
|
||||
Group {
|
||||
if paused {
|
||||
Text(Self.formatFrozenTime(frozenSeconds))
|
||||
.font(.system(size: 12, weight: .heavy).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
|
||||
.font(.system(size: 12, weight: .heavy).monospacedDigit())
|
||||
.foregroundStyle(isUrgent ? .orange : .white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
|
||||
} minimal: {
|
||||
Circle()
|
||||
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
|
||||
.frame(width: 6, height: 6)
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 6, isPulsing: false)
|
||||
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
|
||||
}
|
||||
.keylineTint(phaseColor)
|
||||
.keylineTint(phase.color)
|
||||
.contentMargins(.leading, 8, for: .expanded)
|
||||
.contentMargins(.trailing, 8, for: .expanded)
|
||||
.contentMargins(.bottom, 4, for: .expanded)
|
||||
@@ -179,45 +231,58 @@ struct WorkoutLiveActivity: Widget {
|
||||
}
|
||||
.supplementalActivityFamilies([.small, .medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen / Banner
|
||||
// MARK: - Lock Screen / Banner
|
||||
|
||||
@ViewBuilder
|
||||
private func lockScreenView(
|
||||
context: ActivityViewContext<WorkoutActivityAttributes>,
|
||||
phaseColor: Color,
|
||||
phaseLabel: String,
|
||||
isFullscreen: Bool,
|
||||
isLuminanceReduced: Bool,
|
||||
isUrgent: Bool,
|
||||
paused: Bool,
|
||||
frozenSeconds: Int
|
||||
) -> some View {
|
||||
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
|
||||
struct WorkoutLockScreenView: View {
|
||||
let phase: WorkoutPhase
|
||||
let paused: Bool
|
||||
let frozenSeconds: Int
|
||||
let isFullscreen: Bool
|
||||
let isLuminanceReduced: Bool
|
||||
let isUrgent: Bool
|
||||
let isStale: Bool
|
||||
let exerciseName: String
|
||||
let roundCurrent: Int
|
||||
let roundTotal: Int
|
||||
let endDate: Date
|
||||
let heartRate: Double
|
||||
let trackTitle: String
|
||||
let trackArtist: String
|
||||
let isPlaying: Bool
|
||||
|
||||
var progress: CGFloat { CGFloat(roundCurrent) / CGFloat(max(roundTotal, 1)) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
|
||||
.fill(paused ? phase.color.opacity(0.4) : phase.color)
|
||||
.frame(width: 4, height: 38)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.state.exerciseName)
|
||||
Text(exerciseName)
|
||||
.font(isFullscreen ? .title2 : .headline)
|
||||
.foregroundStyle(.primary)
|
||||
.minimumScaleFactor(0.7)
|
||||
.accessibilityLabel(context.state.exerciseName)
|
||||
.accessibilityLabel(exerciseName)
|
||||
if paused {
|
||||
Text("PAUSED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.accessibilityLabel("Paused")
|
||||
} else {
|
||||
Text(phaseLabel)
|
||||
Text(phase.capitalized)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(phaseColor)
|
||||
.accessibilityLabel("\(phaseLabel) phase")
|
||||
.foregroundStyle(phase.color)
|
||||
.accessibilityLabel("\(phase.capitalized) phase")
|
||||
}
|
||||
if isStale {
|
||||
Text("Last updated")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
@@ -227,74 +292,40 @@ struct WorkoutLiveActivity: Widget {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Group {
|
||||
if paused {
|
||||
Text(Self.formatFrozenTime(frozenSeconds))
|
||||
Text(CountdownText.formatFrozenTime(frozenSeconds))
|
||||
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
|
||||
.fontDesign(.monospaced)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
|
||||
Text(timerInterval: Date()...endDate, countsDown: true)
|
||||
.font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold))
|
||||
.fontDesign(.monospaced)
|
||||
.foregroundStyle(isUrgent ? .orange : .primary)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
.accessibilityLabel(paused ? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
Text("Round \(roundCurrent)/\(roundTotal)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
.accessibilityLabel("Round \(roundCurrent) of \(roundTotal)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, isFullscreen ? 20 : 12)
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(.white.opacity(0.06))
|
||||
RoundedRectangle(cornerRadius: 1, style: .continuous)
|
||||
.fill(phaseColor.opacity(paused ? 0.2 : 0.5))
|
||||
.frame(width: geo.size.width * progress)
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
.frame(height: 2)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityLabel("Music")
|
||||
|
||||
Text(context.state.trackTitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel(context.state.trackTitle)
|
||||
|
||||
Spacer()
|
||||
|
||||
if context.state.heartRate > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
.accessibilityHidden(true)
|
||||
Text("\(Int(context.state.heartRate))")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
}
|
||||
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
|
||||
}
|
||||
|
||||
if !paused, context.state.isPlaying, !isLuminanceReduced {
|
||||
LiveActivityMusicBars()
|
||||
}
|
||||
}
|
||||
.opacity(paused ? 0.5 : 1)
|
||||
MusicInfoRow(
|
||||
trackTitle: trackTitle,
|
||||
trackArtist: trackArtist,
|
||||
isPlaying: isPlaying,
|
||||
isPaused: paused,
|
||||
heartRate: heartRate,
|
||||
isLuminanceReduced: isLuminanceReduced
|
||||
)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 10)
|
||||
@@ -302,76 +333,48 @@ struct WorkoutLiveActivity: Widget {
|
||||
.activityBackgroundTint(.black.opacity(0.9))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Small (Apple Watch / CarPlay)
|
||||
// MARK: - Small (Apple Watch / CarPlay)
|
||||
|
||||
@ViewBuilder
|
||||
private func smallLockScreenView(
|
||||
context: ActivityViewContext<WorkoutActivityAttributes>,
|
||||
phaseColor: Color,
|
||||
phaseLabel: String,
|
||||
paused: Bool,
|
||||
frozenSeconds: Int
|
||||
) -> some View {
|
||||
struct WorkoutSmallView: View {
|
||||
let phase: WorkoutPhase
|
||||
let paused: Bool
|
||||
let frozenSeconds: Int
|
||||
let exerciseName: String
|
||||
let roundCurrent: Int
|
||||
let roundTotal: Int
|
||||
let endDate: Date
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(paused ? phaseColor.opacity(0.4) : phaseColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phaseLabel) phase")
|
||||
PhaseIndicatorDot(color: phase.color, isPaused: paused, size: 8, isPulsing: false)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase")
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(context.state.exerciseName)
|
||||
Text(exerciseName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(paused ? Color.primary.opacity(0.6) : Color.primary)
|
||||
.accessibilityLabel(context.state.exerciseName)
|
||||
Text(paused ? "PAUSED" : "\(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
.accessibilityLabel(exerciseName)
|
||||
Text(paused ? "PAUSED" : "\(roundCurrent)/\(roundTotal)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(paused ? "Paused" : "Round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
.accessibilityLabel(paused ? "Paused" : "Round \(roundCurrent) of \(roundTotal)")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Group {
|
||||
if paused {
|
||||
Text(Self.formatFrozenTime(frozenSeconds))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.primary.opacity(0.5))
|
||||
} else {
|
||||
Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.primary)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(paused ? "Timer paused at \(Self.formatFrozenTime(frozenSeconds))" : "Countdown timer")
|
||||
CountdownText(endDate: endDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: false, size: 12)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.activityBackgroundTint(.black.opacity(0.9))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func formatFrozenTime(_ seconds: Int) -> String {
|
||||
let m = seconds / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
}
|
||||
|
||||
static func colorForPhase(_ phase: String) -> Color {
|
||||
switch phase {
|
||||
case "prep": 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)
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pulse Effect
|
||||
|
||||
private struct PulseEffect: ViewModifier {
|
||||
let active: Bool
|
||||
@State private var isPulsing = false
|
||||
@@ -390,3 +393,136 @@ private struct PulseEffect: ViewModifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Lock Screen — Work", as: .content, using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(20),
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 142,
|
||||
trackTitle: "Lose Yourself",
|
||||
trackArtist: "Eminem",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen — Rest", as: .content, using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Mountain Climbers",
|
||||
phase: .rest,
|
||||
phaseEndDate: .now.addingTimeInterval(8),
|
||||
roundCurrent: 4,
|
||||
roundTotal: 8,
|
||||
heartRate: 128,
|
||||
trackTitle: "Stronger",
|
||||
trackArtist: "Kanye West",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen — Paused", as: .content, using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Jump Squats",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(15),
|
||||
roundCurrent: 5,
|
||||
roundTotal: 8,
|
||||
heartRate: 135,
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: true
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Lock Screen — Complete", as: .content, using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Workout Complete",
|
||||
phase: .complete,
|
||||
phaseEndDate: .now,
|
||||
roundCurrent: 8,
|
||||
roundTotal: 8,
|
||||
heartRate: 110,
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Expanded", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 145,
|
||||
trackTitle: "Till I Collapse",
|
||||
trackArtist: "Eminem",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
)
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "High Knees",
|
||||
phase: .rest,
|
||||
phaseEndDate: .now.addingTimeInterval(6),
|
||||
roundCurrent: 6,
|
||||
roundTotal: 8,
|
||||
heartRate: 120,
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 0,
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Burpees",
|
||||
phase: .work,
|
||||
phaseEndDate: .now.addingTimeInterval(18),
|
||||
roundCurrent: 3,
|
||||
roundTotal: 8,
|
||||
heartRate: 0,
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user