feat: redesign Dynamic Island with phase-driven UI and animations

This commit is contained in:
Millian Lamiaux
2026-05-21 10:21:22 +02:00
parent 67e2bdc8c3
commit c152c22ffb
12 changed files with 472 additions and 76 deletions

View File

@@ -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()
}
}