feat: redesign Dynamic Island with phase-driven UI and animations
This commit is contained in:
@@ -2,12 +2,13 @@ import Foundation
|
||||
import Observation
|
||||
|
||||
/// Global app bootstrap state — initialises all services once at launch.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppState {
|
||||
|
||||
var isBootstrapped = false
|
||||
static let shared = AppState()
|
||||
|
||||
@MainActor
|
||||
var isBootstrapped = false
|
||||
func bootstrap() async {
|
||||
guard !isBootstrapped else { return }
|
||||
guard !AppEnvironment.isPreview else { isBootstrapped = true; return }
|
||||
@@ -15,4 +16,6 @@ final class AppState {
|
||||
AnalyticsService.shared.initialize()
|
||||
isBootstrapped = true
|
||||
}
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,19 @@ import SwiftData
|
||||
|
||||
extension Notification.Name {
|
||||
static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity")
|
||||
// togglePauseFromActivity is declared in WorkoutActivityAttributes.swift (shared with widget)
|
||||
}
|
||||
|
||||
@main
|
||||
struct TabataGoApp: App {
|
||||
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(appState)
|
||||
.environment(AppState.shared)
|
||||
.modelContainer(TabataGoSchema.container)
|
||||
.task {
|
||||
await appState.bootstrap()
|
||||
await AppState.shared.bootstrap()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.scheme == "tabatago", url.host == "skipTrack" {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
#endif
|
||||
|
||||
// MARK: — Shared notification names (used by LiveActivityIntent + PlayerView)
|
||||
|
||||
extension Notification.Name {
|
||||
static let togglePauseFromActivity = Notification.Name("togglePauseFromActivity")
|
||||
}
|
||||
|
||||
// MARK: — Phase enum
|
||||
|
||||
enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case prep
|
||||
case warmup
|
||||
@@ -32,9 +41,49 @@ enum WorkoutPhase: String, Codable, Hashable, Sendable, CaseIterable {
|
||||
case .complete: return Color(red: 0.19, green: 0.82, blue: 0.35)
|
||||
}
|
||||
}
|
||||
|
||||
var dimColor: Color {
|
||||
color.opacity(0.3)
|
||||
}
|
||||
|
||||
var glowColor: Color {
|
||||
color.opacity(0.5)
|
||||
}
|
||||
|
||||
var gradientStops: [Gradient.Stop] {
|
||||
switch self {
|
||||
case .work:
|
||||
return [
|
||||
.init(color: Color(red: 1.0, green: 0.42, blue: 0.21).opacity(0.15), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
]
|
||||
case .rest, .interBlockRest:
|
||||
return [
|
||||
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.12), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
]
|
||||
case .prep, .warmup:
|
||||
return [
|
||||
.init(color: Color(red: 1.0, green: 0.58, blue: 0.0).opacity(0.12), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
]
|
||||
case .cooldown:
|
||||
return [
|
||||
.init(color: Color(red: 0.35, green: 0.78, blue: 0.98).opacity(0.08), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
]
|
||||
case .complete:
|
||||
return [
|
||||
.init(color: Color(red: 0.19, green: 0.82, blue: 0.35).opacity(0.15), location: 0),
|
||||
.init(color: .clear, location: 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: — Activity Attributes
|
||||
|
||||
struct WorkoutActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
var exerciseName: String
|
||||
@@ -48,5 +97,47 @@ struct WorkoutActivityAttributes: ActivityAttributes {
|
||||
var trackArtist: String
|
||||
var isPlaying: Bool
|
||||
var isPaused: Bool
|
||||
var blockIndex: Int = 0
|
||||
var blockCount: Int = 0
|
||||
var exerciseShortName: String = ""
|
||||
var phaseElapsedSeconds: TimeInterval = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Concurrency-safe throttle
|
||||
|
||||
private actor TogglePauseThrottle {
|
||||
private var lastFireTime: Date = .distantPast
|
||||
|
||||
func shouldFire() -> Bool {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastFireTime) > 3.0 else { return false }
|
||||
lastFireTime = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Live Activity Intents (run in the main app process)
|
||||
|
||||
struct TogglePauseIntent: LiveActivityIntent {
|
||||
|
||||
static let title: LocalizedStringResource = "Toggle Pause"
|
||||
private static let throttle = TogglePauseThrottle()
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
guard await Self.throttle.shouldFire() else {
|
||||
print("[LiveActivityIntent] TogglePauseIntent throttled")
|
||||
return .result()
|
||||
}
|
||||
|
||||
print("[LiveActivityIntent] TogglePauseIntent.perform() fired")
|
||||
|
||||
NotificationCenter.default.post(name: .togglePauseFromActivity, object: nil)
|
||||
|
||||
// Small sleep keeps widget button in a "processing" visual state
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ final class MusicPlayerViewModel: ObservableObject {
|
||||
|
||||
func play() {
|
||||
guard audio.isMusicEnabled, player != nil else { return }
|
||||
// Reactivate the audio session in case the system deactivated it while backgrounded
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
player?.volume = audio.musicVolume
|
||||
player?.play()
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ final class PlayerViewModel: ObservableObject {
|
||||
private var warmupIndex: Int = 0
|
||||
// Cooldown phase index
|
||||
private var cooldownIndex: Int = 0
|
||||
// Throttle rapid toggle from widget
|
||||
private var lastToggleTimestamp: Date = .distantPast
|
||||
|
||||
private var currentBlock: TabataBlock? {
|
||||
guard currentBlockIndex < program.blocks.count else { return nil }
|
||||
@@ -91,6 +93,14 @@ final class PlayerViewModel: ObservableObject {
|
||||
// ─── Controls ─────────────────────────────────────────────────
|
||||
|
||||
func togglePlayPause() {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastToggleTimestamp) > 0.6 else {
|
||||
print("[PlayerVM] TogglePlayPause throttled (last tap was < 0.6s ago)")
|
||||
return
|
||||
}
|
||||
lastToggleTimestamp = now
|
||||
print("[PlayerVM] TogglePlayPause — isPaused=\(isPaused), isRunning=\(isRunning)")
|
||||
|
||||
if !isRunning {
|
||||
startWorkout()
|
||||
} else if isPaused {
|
||||
@@ -384,7 +394,11 @@ final class PlayerViewModel: ObservableObject {
|
||||
trackTitle: currentTrackTitle,
|
||||
trackArtist: currentTrackArtist,
|
||||
isPlaying: isPlayingMusic,
|
||||
isPaused: isPaused
|
||||
isPaused: isPaused,
|
||||
blockIndex: currentBlockIndex + 1,
|
||||
blockCount: program.blocks.count,
|
||||
exerciseShortName: String(currentExercise?.nameEn.prefix(8) ?? ""),
|
||||
phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining))
|
||||
)
|
||||
|
||||
if let existing = workoutActivity, existing.activityState != .active {
|
||||
@@ -452,7 +466,11 @@ final class PlayerViewModel: ObservableObject {
|
||||
trackTitle: safeActivity.content.state.trackTitle,
|
||||
trackArtist: safeActivity.content.state.trackArtist,
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: safeActivity.content.state.blockIndex,
|
||||
blockCount: safeActivity.content.state.blockCount,
|
||||
exerciseShortName: safeActivity.content.state.exerciseShortName,
|
||||
phaseElapsedSeconds: safeActivity.content.state.phaseElapsedSeconds
|
||||
)
|
||||
await safeActivity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
|
||||
}
|
||||
|
||||
@@ -166,6 +166,10 @@ struct PlayerView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
|
||||
musicVM.skipTrack()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .togglePauseFromActivity)) { _ in
|
||||
print("[PlayerView] Received togglePauseFromActivity notification (fallback)")
|
||||
vm.togglePlayPause()
|
||||
}
|
||||
.navigationDestination(isPresented: $vm.isComplete) {
|
||||
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
|
||||
.navigationBarBackButtonHidden()
|
||||
|
||||
@@ -317,5 +317,5 @@ struct ProgramRow: View {
|
||||
#Preview {
|
||||
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@ struct MainTabView: View {
|
||||
#Preview {
|
||||
MainTabView()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -139,5 +139,5 @@ struct ProfileRow: View {
|
||||
#Preview {
|
||||
ProfileTab()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
@@ -103,5 +103,5 @@ struct ProgramsTab: View {
|
||||
#Preview {
|
||||
ProgramsTab()
|
||||
.modelContainer(TabataGoSchema.previewContainer)
|
||||
.environment(AppState())
|
||||
.environment(AppState.shared)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user