feat: redesign Dynamic Island with phase-driven UI and animations
This commit is contained in:
@@ -41,13 +41,38 @@ struct MusicLiveActivity: Widget {
|
||||
LiveActivityMusicBars()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RadialGradient(
|
||||
stops: [
|
||||
.init(color: Color.green.opacity(context.state.isPlaying ? 0.1 : 0.03), location: 0),
|
||||
.init(color: .clear, location: 1)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 30
|
||||
)
|
||||
)
|
||||
.opacity(context.state.isPlaying ? 1.0 : 0.5)
|
||||
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.artist)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel(context.state.artist)
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(LinearGradient(
|
||||
colors: [.green.opacity(0.4), .mint.opacity(0.3)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text(context.state.artist)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel(context.state.artist)
|
||||
}
|
||||
.opacity(context.state.isPlaying ? 1.0 : 0.5)
|
||||
.animation(.easeInOut(duration: 0.3), value: context.state.isPlaying)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Button(intent: SkipTrackIntent()) {
|
||||
@@ -73,10 +98,14 @@ struct MusicLiveActivity: Widget {
|
||||
.accessibilityLabel("Music playing")
|
||||
} compactTrailing: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
.accessibilityHidden(true)
|
||||
if context.state.isPlaying {
|
||||
LiveActivityMusicBars()
|
||||
} else {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green.opacity(0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(context.state.title)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -3,18 +3,6 @@ import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - App Intents
|
||||
|
||||
struct TogglePauseIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Toggle Pause"
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & OpensIntent {
|
||||
let url = URL(string: "tabatago://togglePause")!
|
||||
return .result(opensIntent: OpenURLIntent(url))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phase Icon
|
||||
|
||||
struct PhaseIcon: View {
|
||||
@@ -242,6 +230,109 @@ struct DIBottomInfoRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DI Countdown Ring (48pt for Dynamic Island Expanded)
|
||||
|
||||
struct DICountdownRing: View {
|
||||
let endDate: Date
|
||||
let phaseDuration: TimeInterval
|
||||
let isPaused: Bool
|
||||
let frozenSeconds: Int
|
||||
let phase: WorkoutPhase
|
||||
let isUrgent: Bool
|
||||
var isLuminanceReduced: Bool = false
|
||||
|
||||
private let diameter: CGFloat = 48
|
||||
|
||||
/// Thinner stroke during rest phases for a lighter visual feel.
|
||||
private var lineWidth: CGFloat {
|
||||
let isRestOrBreak = phase == .rest || phase == .interBlockRest
|
||||
return (isRestOrBreak && !isPaused) ? 4 : 5
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let isRestOrBreak = phase == .rest || phase == .interBlockRest
|
||||
|
||||
TimelineView(.periodic(from: .now, by: 0.5)) { timeline in
|
||||
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
|
||||
let activeProgress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
|
||||
let frozenProgress = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
|
||||
let arcProgress = isPaused ? frozenProgress : activeProgress
|
||||
|
||||
let strokeColor: Color = {
|
||||
if isPaused { return phase.glowColor }
|
||||
return isUrgent ? .orange : phase.color
|
||||
}()
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.08), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: arcProgress)
|
||||
.stroke(
|
||||
strokeColor,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(isLuminanceReduced ? nil : .linear(duration: 0.3), value: arcProgress)
|
||||
|
||||
CountdownText(
|
||||
endDate: endDate,
|
||||
isPaused: isPaused,
|
||||
frozenSeconds: frozenSeconds,
|
||||
isUrgent: isUrgent,
|
||||
size: 14
|
||||
)
|
||||
}
|
||||
// Work-phase scale pulse
|
||||
.scaleEffect(phase == .work && !isPaused ? 1.05 : 1.0)
|
||||
.animation(isLuminanceReduced ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: phase == .work)
|
||||
// Rest-phase visual cooling
|
||||
.opacity(isRestOrBreak && !isPaused ? 0.85 : 1.0)
|
||||
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 1.0), value: isRestOrBreak)
|
||||
.frame(width: diameter, height: diameter)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(isPaused
|
||||
? "Timer paused at \(CountdownText.formatFrozenTime(frozenSeconds))"
|
||||
: "Countdown timer")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact Countdown Ring (16pt for Dynamic Island Compact)
|
||||
|
||||
struct CompactCountdownRing: View {
|
||||
let endDate: Date
|
||||
let phaseDuration: TimeInterval
|
||||
let isPaused: Bool
|
||||
let frozenSeconds: Int
|
||||
let phase: WorkoutPhase
|
||||
let size: CGFloat = 16
|
||||
let lineWidth: CGFloat = 2.5
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0)) { timeline in
|
||||
let remaining = max(0, endDate.timeIntervalSince(timeline.date))
|
||||
let progress = phaseDuration > 0 ? CGFloat(remaining / phaseDuration) : 1.0
|
||||
let frozen = phaseDuration > 0 ? CGFloat(frozenSeconds) / CGFloat(phaseDuration) : 1.0
|
||||
let arc = isPaused ? frozen : progress
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.12), lineWidth: lineWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: arc)
|
||||
.stroke(
|
||||
isPaused ? phase.dimColor : phase.color,
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Widget
|
||||
|
||||
struct WorkoutLiveActivity: Widget {
|
||||
@@ -292,57 +383,171 @@ struct WorkoutLiveActivity: Widget {
|
||||
let paused = context.state.isPaused
|
||||
let frozenSeconds = Int(max(0, context.state.phaseEndDate.timeIntervalSinceNow))
|
||||
let isUrgent = !paused && frozenSeconds > 0 && frozenSeconds <= 5
|
||||
let progress = CGFloat(context.state.roundCurrent) / CGFloat(max(context.state.roundTotal, 1))
|
||||
|
||||
return DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 16)
|
||||
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(paused ? .white.opacity(0.5) : phase.color)
|
||||
}
|
||||
Text("Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.accessibilityLabel(paused
|
||||
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
|
||||
: "\(phase.capitalized) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
EmptyView()
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.exerciseName)
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(paused ? .white.opacity(0.6) : .white)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel("Exercise \(context.state.exerciseName)")
|
||||
@Environment(\.isLuminanceReduced) var isLuminanceReduced
|
||||
|
||||
let elapsedFraction: CGFloat = {
|
||||
let duration = context.state.phaseDuration
|
||||
guard duration > 0 else { return 0 }
|
||||
if context.state.isPaused {
|
||||
return CGFloat(context.state.phaseElapsedSeconds) / CGFloat(duration)
|
||||
}
|
||||
return min(max(0, 1 - CGFloat(max(0, context.state.phaseEndDate.timeIntervalSinceNow)) / CGFloat(duration)), 1)
|
||||
}()
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(context.state.exerciseName)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel("Exercise \(context.state.exerciseName)")
|
||||
|
||||
let phaseSymbol: String = {
|
||||
switch phase {
|
||||
case .rest, .interBlockRest, .cooldown: return "snowflake"
|
||||
case .complete: return "checkmark.circle.fill"
|
||||
default: return "flame.fill"
|
||||
}
|
||||
}()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: phaseSymbol)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(phase.color)
|
||||
Text(paused ? "PAUSED" : phase.capitalized.uppercased())
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(paused ? .white.opacity(0.4) : phase.color)
|
||||
.animation(isLuminanceReduced ? nil : .easeInOut(duration: 0.8), value: phase)
|
||||
Text("· Rd \(context.state.roundCurrent)/\(context.state.roundTotal)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(paused
|
||||
? "Paused, round \(context.state.roundCurrent) of \(context.state.roundTotal)"
|
||||
: "\(phase.capitalized), round \(context.state.roundCurrent) of \(context.state.roundTotal)")
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(.white.opacity(0.1))
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(phase.color)
|
||||
.frame(width: geo.size.width * elapsedFraction)
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 4)
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 14)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(paused
|
||||
? "Paused, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal)"
|
||||
: "\(phase.capitalized) phase, \(context.state.exerciseName), round \(context.state.roundCurrent) of \(context.state.roundTotal), \(CountdownText.formatFrozenTime(frozenSeconds)) remaining")
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 26)
|
||||
.frame(width: 64, alignment: .trailing)
|
||||
Button(intent: TogglePauseIntent()) {
|
||||
Image(systemName: paused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.frame(width: 24, height: 24)
|
||||
.background(.white.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(paused ? "Resume workout" : "Pause workout")
|
||||
.padding(8)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(spacing: 8) {
|
||||
WorkoutProgressBar(progress: progress, color: phase.color, isPaused: paused)
|
||||
VStack(spacing: 6) {
|
||||
// Stats row
|
||||
ZStack {
|
||||
HStack {
|
||||
if context.state.heartRate > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
Text("\(Int(context.state.heartRate))")
|
||||
.font(.system(size: 11).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
}
|
||||
.accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if !context.state.trackTitle.isEmpty {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.green)
|
||||
Text(context.state.trackTitle)
|
||||
.font(.system(size: 10))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white.opacity(paused ? 0.3 : 0.5))
|
||||
}
|
||||
.accessibilityLabel("Now playing \(context.state.trackTitle)")
|
||||
}
|
||||
}
|
||||
|
||||
DIBottomInfoRow(
|
||||
heartRate: context.state.heartRate,
|
||||
trackTitle: context.state.trackTitle,
|
||||
isPaused: paused
|
||||
)
|
||||
// Block indicator dots
|
||||
if context.state.blockCount > 1 {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(1...context.state.blockCount, id: \.self) { idx in
|
||||
Circle()
|
||||
.fill(idx <= context.state.blockIndex ? phase.color : .white.opacity(0.1))
|
||||
.frame(width: 4, height: 4)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Block \(context.state.blockIndex) of \(context.state.blockCount)")
|
||||
}
|
||||
}
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
} compactLeading: {
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 14)
|
||||
.accessibilityLabel(paused ? "Paused" : "\(phase.capitalized) phase, workout in progress")
|
||||
CompactCountdownRing(
|
||||
endDate: context.state.phaseEndDate,
|
||||
phaseDuration: context.state.phaseDuration,
|
||||
isPaused: paused,
|
||||
frozenSeconds: frozenSeconds,
|
||||
phase: phase
|
||||
)
|
||||
.dynamicIsland(verticalPlacement: .belowIfTooWide)
|
||||
.accessibilityLabel(paused
|
||||
? "Workout paused"
|
||||
: "\(phase.capitalized) phase, workout in progress")
|
||||
} compactTrailing: {
|
||||
CountdownText(endDate: context.state.phaseEndDate, isPaused: paused, frozenSeconds: frozenSeconds, isUrgent: isUrgent, size: 12)
|
||||
if !context.state.exerciseShortName.isEmpty {
|
||||
Text(context.state.exerciseShortName)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(paused ? .white.opacity(0.3) : .white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel("Exercise \(context.state.exerciseShortName)")
|
||||
} else {
|
||||
CountdownText(
|
||||
endDate: context.state.phaseEndDate,
|
||||
isPaused: paused,
|
||||
frozenSeconds: frozenSeconds,
|
||||
isUrgent: isUrgent,
|
||||
size: 11
|
||||
)
|
||||
}
|
||||
} minimal: {
|
||||
PhaseIcon(phase: phase, isPaused: paused, size: 10)
|
||||
.accessibilityLabel(paused ? "Paused" : "Workout in progress")
|
||||
Circle()
|
||||
.fill(phase.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.modifier(PulseEffect(active: phase == .work && !paused))
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(paused ? "Paused workout" : "\(phase.capitalized) phase — workout in progress")
|
||||
}
|
||||
.keylineTint(phase.color)
|
||||
.contentMargins(.leading, 8, for: .expanded)
|
||||
@@ -536,7 +741,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "Lose Yourself",
|
||||
trackArtist: "Eminem",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "BURPEES"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -554,7 +762,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "Stronger",
|
||||
trackArtist: "Kanye West",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "MTN CLMB"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -572,7 +783,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: true
|
||||
isPaused: true,
|
||||
blockIndex: 2,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "JMP SQTS"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -590,7 +804,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 4,
|
||||
blockCount: 4,
|
||||
exerciseShortName: ""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -608,7 +825,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "Till I Collapse",
|
||||
trackArtist: "Eminem",
|
||||
isPlaying: true,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "BURPEES"
|
||||
)
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "High Knees",
|
||||
@@ -621,7 +841,31 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 2,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "HI KNEE"
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Dynamic Island Expanded — Rest", as: .dynamicIsland(.expanded), using: WorkoutActivityAttributes()) {
|
||||
WorkoutLiveActivity()
|
||||
} contentStates: {
|
||||
WorkoutActivityAttributes.ContentState(
|
||||
exerciseName: "Mountain Climbers",
|
||||
phase: .rest,
|
||||
phaseEndDate: .now.addingTimeInterval(7),
|
||||
phaseDuration: 10,
|
||||
roundCurrent: 1,
|
||||
roundTotal: 8,
|
||||
heartRate: 120,
|
||||
trackTitle: "Stronger",
|
||||
trackArtist: "Kanye West",
|
||||
isPlaying: true,
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "MTN CLMB"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -639,7 +883,10 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "BURPEES"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -657,6 +904,9 @@ private struct PulseEffect: ViewModifier {
|
||||
trackTitle: "",
|
||||
trackArtist: "",
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
isPaused: false,
|
||||
blockIndex: 1,
|
||||
blockCount: 4,
|
||||
exerciseShortName: "BURPEES"
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user