diff --git a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj index 0673051..f9869e6 100644 --- a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj +++ b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj @@ -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 = ""; }; 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicActivityAttributes.swift; sourceTree = ""; }; 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 = ""; }; + 5B10095E2FB7C4080033DE89 /* MockPrograms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrograms.swift; sourceTree = ""; }; 5F5D3568A736B7A326874677 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 61E5AA44513F793EA7FEBA00 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 63599808389B70FC2F6A43C3 /* HealthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthViewModel.swift; sourceTree = ""; }; @@ -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 */, diff --git a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift index ca4c700..bb28a20 100644 --- a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift +++ b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift @@ -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 diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index dec9854..88fe888 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -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, diff --git a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift index df9ac89..feba60f 100644 --- a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift @@ -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) +} diff --git a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift index 3d92f5b..352e040 100644 --- a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift @@ -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, - 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, - 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 + ) +}