feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 3s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m5s
CI / Deploy Edge Functions (pull_request) Has been skipped
Some checks failed
CI / TypeScript (pull_request) Failing after 5s
CI / ESLint (pull_request) Failing after 3s
CI / Tests (pull_request) Failing after 5s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m5s
CI / Deploy Edge Functions (pull_request) Has been skipped
## What changed ### Player Redesign (video-first layout) - New compact timer ring (110pt) with phase label, replaces 240pt ring - Auto-hide top bar with block progress dots (3s auto-dismiss) - Expandable now-playing music pill with skip control - Bottom control bar with heart rate, play/pause, and skip - Exercise caption with 'Next' preview during rest phases - Compact round counter (capsule dots) ### Dynamic Island & Live Activities - WorkoutLiveActivity widget: expanded, compact, and minimal views - Phase-colored timers with Text(timerInterval:) countdown - Shows exercise name, round progress, heart rate, music track - MusicLiveActivity: standalone music now-playing widget - LiveActivityMusicBars animated component - Deep link from Dynamic Island back to app ### Timer Drift Fix (critical) - Store a stable phaseEndDate once per phase instead of recalculating Date() + timeRemaining on every update - Prevents dynamic island countdown from rubber-banding due to 5-second periodic update recalculation drift - Reset phaseEndDate on phase change and resume from pause - Guard Live Activity updates behind vm.isRunning to prevent premature creation when music track loads before workout start - Fixes timer showing 0 in Dynamic Island when expanding from home screen ### New PlayerViewModel timer engine - Full phase support: prep, warmup, work, rest, interBlockRest, cooldown, complete - 1-second countdown with audio cues at 3-2-1 - Phase transitions with spring animation and haptics - HealthKit live session integration - Workout session recording with completion ### Music Service - New MusicPlayerViewModel with vibe-based playlist loading - Track info exposed for Dynamic Island display - Skip track support from Dynamic Island notification action - Automatic play/pause based on phase and running state ### Additional - ZoneHighlightIcon component for HomeTab zone cards - Updated watchOS localizations with complication strings - Info.plist updated for widget extension
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
extension Notification.Name {
|
||||
static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity")
|
||||
}
|
||||
|
||||
@main
|
||||
struct TabataGoApp: App {
|
||||
|
||||
@@ -14,6 +18,11 @@ struct TabataGoApp: App {
|
||||
.task {
|
||||
await appState.bootstrap()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.scheme == "tabatago", url.host == "skipTrack" {
|
||||
NotificationCenter.default.post(name: .skipTrackFromActivity, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import ActivityKit
|
||||
|
||||
struct MusicActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var title: String
|
||||
var artist: String
|
||||
var isPlaying: Bool
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
struct WorkoutActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var exerciseName: String
|
||||
var phase: String
|
||||
var phaseEndDate: Date
|
||||
var roundCurrent: Int
|
||||
var roundTotal: Int
|
||||
var heartRate: Double
|
||||
var trackTitle: String
|
||||
var trackArtist: String
|
||||
var isPlaying: Bool
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,23 @@
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>tabatago</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<string>TabataGo reads your health data to show fitness stats and personalize your workouts.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>TabataGo saves your Tabata workouts to Apple Health to track calories, heart rate, and contribute to your Activity Rings.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>TabataGo uses motion data to improve calorie estimates during workouts.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>POSTHOG_API_KEY</key>
|
||||
<string>$(POSTHOG_API_KEY)</string>
|
||||
<key>REVENUECAT_API_KEY</key>
|
||||
|
||||
@@ -9,9 +9,6 @@
|
||||
},
|
||||
"%@ / year — save 40%%" : {
|
||||
|
||||
},
|
||||
"%@ bpm" : {
|
||||
|
||||
},
|
||||
"%@ kcal" : {
|
||||
|
||||
@@ -2202,6 +2199,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Next" : {
|
||||
|
||||
},
|
||||
"No workouts yet" : {
|
||||
"extractionState" : "manual",
|
||||
@@ -5725,6 +5725,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Skip" : {
|
||||
|
||||
},
|
||||
"Subscription purchases are handled by Apple's App Store and RevenueCat. We do not store your payment information." : {
|
||||
|
||||
@@ -6284,25 +6287,25 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Ganzkörpertraining von Kopf bis Fuß"
|
||||
"value" : "Zielt auf alle großen Muskelgruppen — das ultimative Ganzkörper-Tabata-Workout"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Total body burn, head to toe"
|
||||
"value" : "Targets every major muscle group — the ultimate full-body Tabata burn"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Quema total del cuerpo, de pies a cabeza"
|
||||
"value" : "Dirige a todos los grupos musculares principales — la quema Tabata de cuerpo completo definitiva"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Brûlure totale du corps, de la tête aux pieds"
|
||||
"value" : "Cible tous les groupes musculaires — le brûleur Tabata corps entier ultime"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6342,25 +6345,25 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Beine, Gesäß und Körpermitte"
|
||||
"value" : "Zielt auf Quads, Gesäß, Beinbeuger und Waden mit explosiven Intervallen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Legs, glutes & core stability"
|
||||
"value" : "Targets quads, glutes, hamstrings & calves with explosive intervals"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Piernas, glúteos y estabilidad del core"
|
||||
"value" : "Dirige a cuádriceps, glúteos, isquiotibiales y pantorrillas con intervalos explosivos"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Jambes, fessiers et gainage"
|
||||
"value" : "Cible quadriceps, fessiers, ischio-jambiers et mollets avec des intervals explosifs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6400,25 +6403,25 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Arme, Brust, Schultern und Rücken"
|
||||
"value" : "Zielt auf Bizeps, Schultern, Brust und Rücken mit High-Intensity-Intervallen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Arms, chest, shoulders & back"
|
||||
"value" : "Targets biceps, shoulders, chest & back with high-intensity intervals"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Brazos, pecho, hombros y espalda"
|
||||
"value" : "Dirige a bíceps, hombros, pecho y espalda con intervalos de alta intensidad"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bras, poitrine, épaules et dos"
|
||||
"value" : "Cible biceps, épaules, poitrine et dos avec des intervals haute intensité"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ actor MusicService {
|
||||
let parts = raw.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
return (parts[0], parts.dropFirst().joined(separator: " - "))
|
||||
}
|
||||
return ("YouTube Music", raw)
|
||||
return ("", raw)
|
||||
}
|
||||
|
||||
// ─── Mock Tracks ─────────────────────────────────────────────
|
||||
|
||||
@@ -111,8 +111,11 @@ enum Theme {
|
||||
.monospacedDigit()
|
||||
static let timerSmallFont = Font.system(size: 60, weight: .bold, design: .rounded)
|
||||
.monospacedDigit()
|
||||
static let timerCompactFont = Font.system(size: 42, weight: .bold, design: .rounded)
|
||||
.monospacedDigit()
|
||||
static let roundFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||
static let phaseFont = Font.system(size: 18, weight: .bold, design: .rounded)
|
||||
static let phaseCompactFont = Font.system(size: 11, weight: .bold, design: .rounded)
|
||||
}
|
||||
|
||||
// ─── Glass Effect Modifier ────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,15 @@ final class PlayerViewModel: ObservableObject {
|
||||
return program.blocks[currentBlockIndex]
|
||||
}
|
||||
|
||||
var blockProgress: Double {
|
||||
guard !program.blocks.isEmpty else { return 1 }
|
||||
return Double(currentBlockIndex + 1) / Double(program.blocks.count)
|
||||
}
|
||||
|
||||
var isRestPhase: Bool {
|
||||
phase == .rest || phase == .interBlockRest
|
||||
}
|
||||
|
||||
private let audio = AudioService.shared
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .rigid)
|
||||
private let softHaptics = UIImpactFeedbackGenerator(style: .soft)
|
||||
@@ -107,13 +116,13 @@ final class PlayerViewModel: ObservableObject {
|
||||
// Start HealthKit live session
|
||||
Task {
|
||||
try? await HealthKitService.shared.requestAuthorization()
|
||||
try? await liveSession.start(startDate: startedAt!)
|
||||
liveSession.onHeartRateUpdate = { [weak self] hr in
|
||||
Task { @MainActor in self?.heartRate = hr }
|
||||
}
|
||||
liveSession.onCaloriesUpdate = { [weak self] cal in
|
||||
Task { @MainActor in self?.liveCalories = cal }
|
||||
}
|
||||
try? await liveSession.start(startDate: startedAt!)
|
||||
}
|
||||
|
||||
AnalyticsService.shared.workoutStarted(
|
||||
|
||||
131
tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift
Normal file
131
tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BodySilhouetteShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
let mx = w * 0.5
|
||||
var p = Path()
|
||||
|
||||
let headCY = h * 0.09
|
||||
let headR = w * 0.12
|
||||
p.addEllipse(in: CGRect(
|
||||
x: mx - headR, y: headCY - headR,
|
||||
width: headR * 2, height: headR * 2
|
||||
))
|
||||
|
||||
let neckW = w * 0.06
|
||||
let neckTop = headCY + headR
|
||||
let shoulderY = h * 0.20
|
||||
p.addRect(CGRect(
|
||||
x: mx - neckW, y: neckTop,
|
||||
width: neckW * 2, height: shoulderY - neckTop
|
||||
))
|
||||
|
||||
let shoulderHW = w * 0.42
|
||||
let armOuterBotY = h * 0.44
|
||||
let armInnerBotY = h * 0.44
|
||||
let armOuterX = w * 0.06
|
||||
let armInnerX = w * 0.12
|
||||
|
||||
p.move(to: CGPoint(x: mx - neckW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx - shoulderHW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx - armOuterX, y: armOuterBotY))
|
||||
p.addLine(to: CGPoint(x: mx - armInnerX, y: armInnerBotY))
|
||||
p.closeSubpath()
|
||||
|
||||
p.move(to: CGPoint(x: mx + neckW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx + shoulderHW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx + armOuterX, y: armOuterBotY))
|
||||
p.addLine(to: CGPoint(x: mx + armInnerX, y: armInnerBotY))
|
||||
p.closeSubpath()
|
||||
|
||||
let torsoHW = w * 0.18
|
||||
let waistY = h * 0.46
|
||||
let waistHW = w * 0.15
|
||||
p.move(to: CGPoint(x: mx - torsoHW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx + torsoHW, y: shoulderY))
|
||||
p.addLine(to: CGPoint(x: mx + waistHW, y: waistY))
|
||||
p.addLine(to: CGPoint(x: mx - waistHW, y: waistY))
|
||||
p.closeSubpath()
|
||||
|
||||
let hipHW = w * 0.22
|
||||
let hipY = h * 0.52
|
||||
p.move(to: CGPoint(x: mx - waistHW, y: waistY))
|
||||
p.addLine(to: CGPoint(x: mx + waistHW, y: waistY))
|
||||
p.addLine(to: CGPoint(x: mx + hipHW, y: hipY))
|
||||
p.addLine(to: CGPoint(x: mx - hipHW, y: hipY))
|
||||
p.closeSubpath()
|
||||
|
||||
let gap = w * 0.02
|
||||
let legBotY = h * 0.92
|
||||
let footH = h * 0.05
|
||||
let footExtra = w * 0.04
|
||||
|
||||
p.move(to: CGPoint(x: mx - hipHW, y: hipY))
|
||||
p.addLine(to: CGPoint(x: mx - gap, y: hipY))
|
||||
p.addLine(to: CGPoint(x: mx - gap, y: legBotY))
|
||||
p.addLine(to: CGPoint(x: mx - hipHW - footExtra, y: legBotY + footH))
|
||||
p.addLine(to: CGPoint(x: mx - hipHW, y: legBotY))
|
||||
p.closeSubpath()
|
||||
|
||||
p.move(to: CGPoint(x: mx + gap, y: hipY))
|
||||
p.addLine(to: CGPoint(x: mx + hipHW, y: hipY))
|
||||
p.addLine(to: CGPoint(x: mx + hipHW, y: legBotY))
|
||||
p.addLine(to: CGPoint(x: mx + hipHW + footExtra, y: legBotY + footH))
|
||||
p.addLine(to: CGPoint(x: mx + gap, y: legBotY))
|
||||
p.closeSubpath()
|
||||
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
struct ZoneHighlightIcon: View {
|
||||
let zone: String
|
||||
|
||||
private var waistFraction: CGFloat { 0.50 }
|
||||
|
||||
var body: some View {
|
||||
let shape = BodySilhouetteShape()
|
||||
ZStack {
|
||||
shape
|
||||
.fill(.white.opacity(0.12))
|
||||
|
||||
shape
|
||||
.fill(zoneGradient)
|
||||
.mask(zoneMask)
|
||||
}
|
||||
.frame(width: 56, height: 80)
|
||||
}
|
||||
|
||||
private var zoneGradient: LinearGradient {
|
||||
switch zone {
|
||||
case "upper-body":
|
||||
LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .top, endPoint: .bottom)
|
||||
case "lower-body":
|
||||
LinearGradient(colors: [.blue, .purple.opacity(0.8)], startPoint: .top, endPoint: .bottom)
|
||||
case "full-body":
|
||||
LinearGradient(colors: [Theme.brand, .purple], startPoint: .top, endPoint: .bottom)
|
||||
default:
|
||||
LinearGradient(colors: [.gray, .secondary], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var zoneMask: some View {
|
||||
switch zone {
|
||||
case "upper-body":
|
||||
Rectangle()
|
||||
.frame(height: 80 * waistFraction)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
case "lower-body":
|
||||
Rectangle()
|
||||
.frame(height: 80 * waistFraction)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
case "full-body":
|
||||
Rectangle()
|
||||
default:
|
||||
Rectangle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
@preconcurrency import ActivityKit
|
||||
|
||||
/// Full-screen Tabata workout player with Liquid Glass timer.
|
||||
/// Full-screen Tabata workout player — video-first layout with overlay timer.
|
||||
struct PlayerView: View {
|
||||
let program: WorkoutProgram
|
||||
@StateObject private var vm: PlayerViewModel
|
||||
@@ -8,6 +9,12 @@ struct PlayerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var topBarVisible = true
|
||||
@State private var nowPlayingExpanded = false
|
||||
@State private var autoHideTask: Task<Void, Never>?
|
||||
@State private var workoutActivity: Activity<WorkoutActivityAttributes>?
|
||||
@State private var phaseEndDate: Date?
|
||||
|
||||
init(program: WorkoutProgram) {
|
||||
self.program = program
|
||||
_vm = StateObject(wrappedValue: PlayerViewModel(program: program))
|
||||
@@ -18,125 +25,255 @@ struct PlayerView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// ── Animated Phase Background ──────────────────────────
|
||||
PhaseBackground(phase: vm.phase)
|
||||
.ignoresSafeArea()
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 0 — Full-screen background (video placeholder)
|
||||
// ══════════════════════════════════════════════════
|
||||
PhaseBackground(phase: vm.phase)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// ── Content ────────────────────────────────────────────
|
||||
VStack(spacing: 0) {
|
||||
PlayerTopBar(
|
||||
title: program.titleEn,
|
||||
block: vm.currentBlockIndex + 1,
|
||||
totalBlocks: program.blocks.count,
|
||||
onClose: { vm.showExitConfirmation = true }
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Exercise Label ─────────────────────────────────
|
||||
if let exercise = vm.currentExercise {
|
||||
Text(exercise.nameEn)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 1 — Bottom gradient sheen for legibility
|
||||
// ══════════════════════════════════════════════════
|
||||
VStack {
|
||||
Spacer()
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.65)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 280)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// ── Phase Badge ────────────────────────────────────
|
||||
Text(Theme.phaseLabel(vm.phase))
|
||||
.font(Theme.phaseFont)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.padding(.top, 8)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 2 — Tap to reveal top bar
|
||||
// ══════════════════════════════════════════════════
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { showTopBar() }
|
||||
.allowsHitTesting(!topBarVisible)
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Timer Ring ─────────────────────────────────────
|
||||
TimerRing(
|
||||
timeRemaining: vm.timeRemaining,
|
||||
total: vm.totalPhaseTime,
|
||||
phase: vm.phase
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Round Counter ──────────────────────────────────
|
||||
RoundCounter(
|
||||
current: vm.currentRound,
|
||||
total: vm.totalRoundsInBlock,
|
||||
phase: vm.phase
|
||||
)
|
||||
|
||||
// ── Live Stats (HealthKit) ─────────────────────────
|
||||
if vm.heartRate > 0 || vm.liveCalories > 0 {
|
||||
LiveStatsBar(heartRate: vm.heartRate, calories: vm.liveCalories)
|
||||
.padding(.top, 12)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 3 — Auto-hide top bar
|
||||
// ══════════════════════════════════════════════════
|
||||
if topBarVisible {
|
||||
VStack {
|
||||
AutoHideTopBar(
|
||||
title: program.titleEn,
|
||||
block: vm.currentBlockIndex + 1,
|
||||
totalBlocks: program.blocks.count,
|
||||
onClose: { vm.showExitConfirmation = true }
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Now Playing (Music) ───────────────────────────
|
||||
NowPlayingView(
|
||||
track: musicVM.currentTrack,
|
||||
isReady: musicVM.isReady,
|
||||
onSkip: { musicVM.skipTrack() }
|
||||
)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 8)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 4 — Compact timer ring (top-right corner)
|
||||
// ══════════════════════════════════════════════════
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
CompactTimerRing(
|
||||
timeRemaining: vm.timeRemaining,
|
||||
total: vm.totalPhaseTime,
|
||||
phase: vm.phase
|
||||
)
|
||||
.padding(.top, 60)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// ── Controls ───────────────────────────────────────
|
||||
PlayerControls(
|
||||
isRunning: vm.isRunning,
|
||||
isPaused: vm.isPaused,
|
||||
onStartPause: { vm.togglePlayPause() },
|
||||
onSkip: { vm.skipPhase() }
|
||||
)
|
||||
.padding(.bottom, 40)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Layer 5 — Bottom overlay: caption, pips, music, controls
|
||||
// ══════════════════════════════════════════════════
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(spacing: 6) {
|
||||
ExerciseCaption(
|
||||
name: vm.currentExercise?.nameEn,
|
||||
phase: vm.phase,
|
||||
isRestPhase: vm.isRestPhase
|
||||
)
|
||||
CompactRoundCounter(
|
||||
current: vm.currentRound,
|
||||
total: vm.totalRoundsInBlock,
|
||||
phase: vm.phase
|
||||
)
|
||||
.padding(.top, 8)
|
||||
if let track = musicVM.currentTrack, musicVM.isReady {
|
||||
ExpandableNowPlayingPill(
|
||||
track: track,
|
||||
isExpanded: $nowPlayingExpanded,
|
||||
onSkip: { musicVM.skipTrack() }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
BottomControlBar(
|
||||
heartRate: vm.heartRate,
|
||||
isRunning: vm.isRunning,
|
||||
isPaused: vm.isPaused,
|
||||
phase: vm.phase,
|
||||
onStartPause: { vm.togglePlayPause() },
|
||||
onSkip: { vm.skipPhase() }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.statusBarHidden(true)
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
vm.setup(modelContext: context)
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
Task { await musicVM.load() }
|
||||
showTopBar()
|
||||
PhoneConnectivityManager.shared.onHeartRateUpdate = { [weak vm] hr in
|
||||
vm?.heartRate = hr
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
autoHideTask?.cancel()
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
musicVM.stop()
|
||||
endWorkoutActivity()
|
||||
}
|
||||
.onChange(of: vm.isRunning) { _, running in
|
||||
let musicPhase = vm.phase == .work || vm.phase == .rest
|
||||
let shouldPlay = running && !vm.isPaused && musicPhase
|
||||
musicVM.setPlaying(shouldPlay)
|
||||
if running { phaseEndDate = nil }
|
||||
updateWorkoutActivity()
|
||||
}
|
||||
.onChange(of: vm.isPaused) { _, paused in
|
||||
let musicPhase = vm.phase == .work || vm.phase == .rest
|
||||
let shouldPlay = vm.isRunning && !paused && musicPhase
|
||||
musicVM.setPlaying(shouldPlay)
|
||||
if !paused { phaseEndDate = nil }
|
||||
updateWorkoutActivity()
|
||||
if paused { showTopBar() }
|
||||
}
|
||||
.onChange(of: vm.phase) { _, phase in
|
||||
let musicPhase = phase == .work || phase == .rest
|
||||
let shouldPlay = vm.isRunning && !vm.isPaused && musicPhase
|
||||
musicVM.setPlaying(shouldPlay)
|
||||
phaseEndDate = nil
|
||||
updateWorkoutActivity()
|
||||
showTopBar()
|
||||
}
|
||||
.onChange(of: musicVM.currentTrack) { _, _ in
|
||||
updateWorkoutActivity()
|
||||
}
|
||||
.onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in
|
||||
updateWorkoutActivity()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
|
||||
musicVM.skipTrack()
|
||||
}
|
||||
.navigationDestination(isPresented: $vm.isComplete) {
|
||||
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
|
||||
.navigationBarBackButtonHidden()
|
||||
}
|
||||
.alert(String(localized: L10n.player.endWorkout), isPresented: $vm.showExitConfirmation) {
|
||||
Button(String(localized: L10n.player.endWorkout), role: .destructive) {
|
||||
vm.abandonWorkout()
|
||||
dismiss()
|
||||
}
|
||||
Button(String(localized: L10n.player.keepGoing), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L10n.player.endWorkoutMessage)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.statusBarHidden(true)
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
vm.setup(modelContext: context)
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
Task { await musicVM.load() }
|
||||
}
|
||||
|
||||
// ─── Auto-hide logic ─────────────────────────────────────────
|
||||
|
||||
private func showTopBar() {
|
||||
autoHideTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.25)) { topBarVisible = true }
|
||||
autoHideTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.6)) { topBarVisible = false }
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
musicVM.stop()
|
||||
}
|
||||
|
||||
// ─── Dynamic Island ───────────────────────────────────────────
|
||||
|
||||
private var dynamicIslandAvailable: Bool {
|
||||
#if targetEnvironment(simulator)
|
||||
true
|
||||
#else
|
||||
ActivityAuthorizationInfo().areActivitiesEnabled
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateWorkoutActivity() {
|
||||
guard dynamicIslandAvailable else { return }
|
||||
guard vm.isRunning else { return }
|
||||
let phaseEnd: Date
|
||||
if let stored = phaseEndDate {
|
||||
phaseEnd = stored
|
||||
} else {
|
||||
let calculated = Date().addingTimeInterval(Double(vm.timeRemaining))
|
||||
phaseEndDate = calculated
|
||||
phaseEnd = calculated
|
||||
}
|
||||
.onChange(of: vm.isRunning) { _, running in
|
||||
let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete
|
||||
musicVM.setPlaying(running && !vm.isPaused && musicPhase)
|
||||
let isPlaying = (vm.phase == .work || vm.phase == .rest) && vm.isRunning && !vm.isPaused
|
||||
let track = musicVM.currentTrack
|
||||
|
||||
let state = WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: vm.currentExercise?.nameEn ?? "",
|
||||
phase: vm.phase.rawValue,
|
||||
phaseEndDate: phaseEnd,
|
||||
roundCurrent: vm.currentRound,
|
||||
roundTotal: vm.totalRoundsInBlock,
|
||||
heartRate: vm.heartRate,
|
||||
trackTitle: track?.title ?? "",
|
||||
trackArtist: track?.artist ?? "",
|
||||
isPlaying: isPlaying
|
||||
)
|
||||
|
||||
if let existing = workoutActivity {
|
||||
Task { await existing.update(using: state) }
|
||||
} else {
|
||||
let attrs = WorkoutActivityAttributes()
|
||||
workoutActivity = try? Activity.request(attributes: attrs, contentState: state, pushType: nil)
|
||||
}
|
||||
.onChange(of: vm.isPaused) { _, paused in
|
||||
let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete
|
||||
musicVM.setPlaying(vm.isRunning && !paused && musicPhase)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func endWorkoutActivity() {
|
||||
guard let activity = workoutActivity else { return }
|
||||
let state = WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: activity.contentState.exerciseName,
|
||||
phase: activity.contentState.phase,
|
||||
phaseEndDate: activity.contentState.phaseEndDate,
|
||||
roundCurrent: activity.contentState.roundCurrent,
|
||||
roundTotal: activity.contentState.roundTotal,
|
||||
heartRate: activity.contentState.heartRate,
|
||||
trackTitle: activity.contentState.trackTitle,
|
||||
trackArtist: activity.contentState.trackArtist,
|
||||
isPlaying: false
|
||||
)
|
||||
Task {
|
||||
await activity.end(using: state, dismissalPolicy: .immediate)
|
||||
workoutActivity = nil
|
||||
}
|
||||
.onChange(of: vm.phase) { _, phase in
|
||||
let musicPhase = phase != .prep && phase != .warmup && phase != .complete
|
||||
musicVM.setPlaying(vm.isRunning && !vm.isPaused && musicPhase)
|
||||
}
|
||||
.navigationDestination(isPresented: $vm.isComplete) {
|
||||
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
|
||||
.navigationBarBackButtonHidden()
|
||||
}
|
||||
.alert(String(localized: L10n.player.endWorkout), isPresented: $vm.showExitConfirmation) {
|
||||
Button(String(localized: L10n.player.endWorkout), role: .destructive) {
|
||||
vm.abandonWorkout()
|
||||
dismiss()
|
||||
}
|
||||
Button(String(localized: L10n.player.keepGoing), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L10n.player.endWorkoutMessage)
|
||||
}
|
||||
} // NavigationStack
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────
|
||||
|
||||
// MARK: - PhaseBackground
|
||||
|
||||
struct PhaseBackground: View {
|
||||
let phase: TimerPhase
|
||||
@State private var animating = false
|
||||
@@ -158,46 +295,186 @@ struct PhaseBackground: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerTopBar: View {
|
||||
// MARK: - AutoHideTopBar
|
||||
|
||||
struct AutoHideTopBar: View {
|
||||
let title: String
|
||||
let block: Int
|
||||
let totalBlocks: Int
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(spacing: 0) {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(10)
|
||||
.frame(width: 37, height: 37)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
VStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(String(format: String(localized: L10n.programDetail.blockOfFmt), block, totalBlocks))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
BlockProgressDots(current: block, total: totalBlocks)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Placeholder for symmetry
|
||||
Color.clear.frame(width: 37, height: 37)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 16)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.55), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 120)
|
||||
.offset(y: -60)
|
||||
.allowsHitTesting(false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct TimerRing: View {
|
||||
struct BlockProgressDots: View {
|
||||
let current: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(1...max(total, 1), id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i <= current ? .white : .white.opacity(0.3))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ExpandableNowPlayingPill
|
||||
|
||||
struct ExpandableNowPlayingPill: View {
|
||||
let track: MusicTrack
|
||||
@Binding var isExpanded: Bool
|
||||
let onSkip: () -> Void
|
||||
|
||||
@State private var collapseTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(Theme.success)
|
||||
|
||||
Text(track.title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
|
||||
MusicBars()
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { toggle() }
|
||||
|
||||
if isExpanded {
|
||||
HStack(spacing: 8) {
|
||||
Text(track.artist)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onSkip) {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
Text("Skip")
|
||||
.font(.caption2.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.white.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, isExpanded ? 10 : 7)
|
||||
.padding(.horizontal, 16)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: isExpanded ? 16 : 24, style: .continuous))
|
||||
.animation(.spring(duration: 0.3), value: isExpanded)
|
||||
.onChange(of: isExpanded) { _, expanded in
|
||||
if expanded { scheduleCollapse() }
|
||||
}
|
||||
}
|
||||
|
||||
private func toggle() {
|
||||
collapseTask?.cancel()
|
||||
if isExpanded {
|
||||
isExpanded = false
|
||||
} else {
|
||||
isExpanded = true
|
||||
scheduleCollapse()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCollapse() {
|
||||
collapseTask?.cancel()
|
||||
collapseTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.spring(duration: 0.3)) { isExpanded = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MusicBars
|
||||
|
||||
struct MusicBars: View {
|
||||
private let heights: [CGFloat] = [10, 14, 8, 12]
|
||||
private let durations: [Double] = [0.4, 0.55, 0.35, 0.5]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(0..<4, id: \.self) { i in
|
||||
AnimatedBar(height: heights[i], duration: durations[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatedBar: View {
|
||||
let height: CGFloat
|
||||
let duration: Double
|
||||
@State private var trigger = false
|
||||
|
||||
var body: some View {
|
||||
Capsule()
|
||||
.fill(Theme.success)
|
||||
.frame(width: 2, height: trigger ? height : 4)
|
||||
.animation(.easeInOut(duration: duration).repeatForever(autoreverses: true), value: trigger)
|
||||
.onAppear { trigger = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CompactTimerRing
|
||||
|
||||
struct CompactTimerRing: View {
|
||||
let timeRemaining: Int
|
||||
let total: Int
|
||||
let phase: TimerPhase
|
||||
@@ -209,126 +486,141 @@ struct TimerRing: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background ring
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.1), lineWidth: 16)
|
||||
.frame(width: 240, height: 240)
|
||||
.stroke(.white.opacity(0.1), lineWidth: 10)
|
||||
.frame(width: 110, height: 110)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
Theme.phaseColor(phase),
|
||||
style: StrokeStyle(lineWidth: 16, lineCap: .round)
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round)
|
||||
)
|
||||
.frame(width: 240, height: 240)
|
||||
.frame(width: 110, height: 110)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.linear(duration: 1), value: progress)
|
||||
|
||||
// Glass disc
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 200, height: 200)
|
||||
.frame(width: 90, height: 90)
|
||||
|
||||
// Timer digits
|
||||
Text("\(timeRemaining)")
|
||||
.font(Theme.timerFont)
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.spring(duration: 0.3), value: timeRemaining)
|
||||
VStack(spacing: 0) {
|
||||
Text("\(timeRemaining)")
|
||||
.font(Theme.timerCompactFont)
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.spring(duration: 0.3), value: timeRemaining)
|
||||
|
||||
Text(Theme.phaseLabel(phase))
|
||||
.font(Theme.phaseCompactFont)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RoundCounter: View {
|
||||
// MARK: - ExerciseCaption
|
||||
|
||||
struct ExerciseCaption: View {
|
||||
let name: String?
|
||||
let phase: TimerPhase
|
||||
let isRestPhase: Bool
|
||||
|
||||
var body: some View {
|
||||
if let name {
|
||||
VStack(spacing: 4) {
|
||||
if isRestPhase {
|
||||
Text("Next")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Theme.rest.opacity(0.85))
|
||||
}
|
||||
Text(name)
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
.id(name)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
.animation(.spring(duration: 0.4), value: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CompactRoundCounter
|
||||
|
||||
struct CompactRoundCounter: View {
|
||||
let current: Int
|
||||
let total: Int
|
||||
let phase: TimerPhase
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(1...max(total, 1), id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i < current ? Theme.phaseColor(phase) :
|
||||
i == current ? .white :
|
||||
.white.opacity(0.25))
|
||||
.frame(width: i == current ? 24 : 8, height: 8)
|
||||
.white.opacity(0.2))
|
||||
.frame(width: i == current ? 20 : 6, height: 6)
|
||||
.animation(.spring(duration: 0.3), value: current)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveStatsBar: View {
|
||||
// MARK: - BottomControlBar
|
||||
|
||||
struct BottomControlBar: View {
|
||||
let heartRate: Double
|
||||
let calories: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 24) {
|
||||
if heartRate > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("\(Int(heartRate)) bpm")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
if calories > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundStyle(Theme.brand)
|
||||
Text("\(Int(calories)) kcal")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControls: View {
|
||||
let isRunning: Bool
|
||||
let isPaused: Bool
|
||||
let phase: TimerPhase
|
||||
let onStartPause: () -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 40) {
|
||||
// Skip button
|
||||
Button(action: onSkip) {
|
||||
Image(systemName: "forward.end.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.padding(16)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(heartRate > 0 ? .red : .red.opacity(0.4))
|
||||
.font(.system(size: 12))
|
||||
Text(heartRate > 0 ? "\(Int(heartRate))" : "--")
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(heartRate > 0 ? .white : .white.opacity(0.4))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Play / Pause
|
||||
Button(action: onStartPause) {
|
||||
Image(systemName: isRunning && !isPaused ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 72, height: 72)
|
||||
.background(Theme.phaseColor(.work))
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Theme.phaseColor(phase))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: Theme.brand.opacity(0.4), radius: 16, y: 6)
|
||||
.shadow(color: Theme.phaseColor(phase).opacity(0.4), radius: 12, y: 4)
|
||||
}
|
||||
.scaleEffect(isRunning ? 1.0 : 1.05)
|
||||
.scaleEffect(isRunning && !isPaused ? 1.0 : 1.05)
|
||||
.animation(.spring(duration: 0.3), value: isRunning)
|
||||
|
||||
// Spacer for symmetry
|
||||
Color.clear.frame(width: 54, height: 54)
|
||||
Spacer()
|
||||
|
||||
// Skip
|
||||
Button(action: onSkip) {
|
||||
Image(systemName: "forward.end.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 20)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -238,9 +238,7 @@ struct ZoneCard: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: zoneIcon)
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.25))
|
||||
ZoneHighlightIcon(zone: zone)
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
@@ -260,14 +258,7 @@ struct ZoneCard: View {
|
||||
L10n.zone.description(for: zone)
|
||||
}
|
||||
|
||||
private var zoneIcon: String {
|
||||
switch zone {
|
||||
case "upper-body": return "figure.arms.open"
|
||||
case "lower-body": return "figure.walk"
|
||||
case "full-body": return "figure.highintensity.intervaltraining"
|
||||
default: return "figure.run"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct LevelBadge: View {
|
||||
|
||||
Reference in New Issue
Block a user