Root cause: observeActivityState() prematurely set workoutActivity=nil
when the activity went .stale (e.g. app backgrounded >2 minutes). This
prevented endActivity() from calling .end() on the stale activity,
leaving it visible on the Lock Screen and Dynamic Island indefinitely.
Fixes (all in PlayerViewModel.swift):
1. observeActivityState(): Split the monolithic stale/ended/dismissed
handler. .stale now only stops the sync timer but keeps the
workoutActivity reference so endActivity() can still call .end()
to properly dismiss the stale Live Activity.
2. syncActivity() nil guard: Changed from != .active to explicit
== .ended || == .dismissed so stale activities are not prematurely
discarded when tick() re-enters syncActivity().
3. endActivity(): Added stopActivitySyncTimer() defensive call at top
to prevent orphaned timer from racing in and recreating the activity
during .end(). Also relaxed the guard from == .active to
!= .ended && != .dismissed so stale activities can be ended.
4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false
before cleanup to prevent accidental Live Activity recreation.
- Add CountdownRing with real-time arc progress on lock screen
- Replace generic dots with phase-specific SF Symbols (flame, snowflake, etc.)
- Remove horizontal progress bar in favor of round counter text
- Increase Dynamic Island expanded font sizes for better visibility
- Increase live activity sync frequency from 5s to 1s for smoother arc updates
- Add pause/resume button via TogglePauseIntent AppIntent
- Remove AlertConfiguration to silence notification sounds on updates
- Replace raw string phase model with WorkoutPhase enum (Codable, Sendable, CaseIterable)
with built-in .capitalized display name and SwiftUI .color per phase
- Decompose WorkoutLiveActivity into reusable view structs: PhasePill, CountdownText,
WorkoutProgressBar, MusicInfoRow, HeartRateBadge, PhaseIndicatorDot, WorkoutLockScreenView,
WorkoutSmallView — following CraftingSwift iOS 26 architecture patterns
- Add AlertConfiguration on work/rest/complete phase transitions so Dynamic Island
expands and lights up at key moments
- Add 13 #Preview blocks across both widgets covering all presentation types:
lock screen, expanded, compact, minimal — for instant Xcode Canvas feedback
- Add stale state handling (context.isStale shows 'Last updated' indicator)
- MusicLiveActivity: 5 new #Preview blocks for playing/paused/expanded/compact/minimal
- Add isPaused to WorkoutActivityAttributes.ContentState
- Show PAUSED badge, freeze timer to static text, dim content when paused
- Prevent stale spinner on pause by extending staleDate to 1 hour
- Add 6s timer warning color, progress bar, compact heavy timer
- Pulsing compact indicator during WORK phase
- Lock Screen margins aligned to Apple's 14pt HIG spec
When airplane mode is active, the Supabase client hung indefinitely
waiting for a network response, blocking the mock track fallback.
Now races the query against a 6-second Task.sleep so mock tracks
load immediately after timeout.
- Add Sendable conformance to MusicActivityAttributes.ContentState
- Remove @preconcurrency on ActivityKit import
- Use nonisolated(unsafe) guards for Activity refs in task closures
- Add observeActivityState() to handle stale/ended/dismissed activity states
- Set staleDate (120s) instead of nil for push notification support
**Architecture (PlayerViewModel):**
- Move ActivityKit lifecycle from SwiftUI View to ViewModel (MVVM correction)
- call syncActivity() at END of enterPhase() — after all state is set,
eliminating the race where phase was Published before timeRemaining
- Always recalculate phaseEndDate = Date() + timeRemaining (no stale cache)
- Dedicated Timer in ViewModel for periodic heart-rate/track sync (5s)
- Start/stop activity sync timer on play/pause/resume/abandon/finish
- stale activity reference discard + recreate-on-failure fallback
- Modern iOS 16.2+ API: ActivityContent, non-throwing update()
**PlayerView:**
- Remove all ActivityKit code (import, @State workoutActivity,
phaseEndDate, dynamicIslandAvailable, 4 methods, .onReceive timer)
- Delegate to ViewModel: onChange(musicVM.currentTrack) sets vm.trackTitle/Artist
and calls vm.syncActivity(); onDisappear calls await vm.endActivity()
- Music/audio onChange handlers no longer contain activity logic
**Info.plist:**
- Add UIBackgroundModes → audio so music continues and app stays alive
in background, allowing Timer-based activity updates
- Widget Info.plist: add NSSupportsLiveActivitiesFrequentUpdates
**WorkoutActivityAttributes.ContentState:**
- Add Sendable conformance for Swift 6 strict concurrency
Fixes: timer stuck at 0 on first work phase, exercise name missing,
music stopping in background, Dynamic Island freezing in background,
widget drift due to cached phaseEndDate
## 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
The iOS app target was missing the com.apple.developer.healthkit entitlement
and the Xcode project was out of sync with project.yml, causing a crash when
HealthKitService requested write authorization for active energy, workouts, and
heart rate types.
- Replace external pill badges (level + FREE) with immersive overlay card:
level tag pinned top-right with ultraThinMaterial blur, FREE shown inline
with checkmark.seal.fill icon, dark scrim for text legibility
- Remove card drop shadow
- Move Browse by Zone section above Featured in Home tab scroll order
- Add purchaseSucceeded flag to PurchaseViewModel, set on purchase success
- PaywallView observes the flag and dismisses itself automatically
- ProfileTab.syncSubscription() writes PurchaseService.currentPlan back
to UserProfile.subscriptionRaw via SwiftData on sheet dismiss
Swap the two horizontal FilterChip scroll rows with a native segmented
Picker for body zone and a toolbar Menu for difficulty level. Fix zone
values to match Supabase enum (upper-body, lower-body, full-body).
Remove unused FilterChip struct.