From fe005ee7f34b381d56988f503ba2fdd68281e3b8 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Fri, 15 May 2026 22:41:20 +0200 Subject: [PATCH] feat: Live Activity accessibility and supplemental families (small/medium) - Add @Environment activityFamily, isActivityFullscreen, isLuminanceReduced - Split into lockScreenView() and smallLockScreenView() variants - Add supplementalActivityFamilies([.small, .medium]) support - Add keylineTint and contentMargins to Dynamic Island - Add accessibility labels throughout (VoiceOver support) - Hide music bar animation when isLuminanceReduced --- .../TabataGoWidget/MusicLiveActivity.swift | 121 +++++++--- .../TabataGoWidget/WorkoutLiveActivity.swift | 228 ++++++++++++------ 2 files changed, 246 insertions(+), 103 deletions(-) diff --git a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift index e725e1b..df9ac89 100644 --- a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift @@ -16,57 +16,38 @@ struct SkipTrackIntent: AppIntent { struct MusicLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: MusicActivityAttributes.self) { context in - // ── Lock Screen / Banner ─────────────────── - VStack(spacing: 0) { - HStack(spacing: 10) { - Image(systemName: "music.note") - .font(.title3.weight(.semibold)) - .foregroundStyle(.green) + @Environment(\.activityFamily) var activityFamily + @Environment(\.isActivityFullscreen) var isFullscreen + @Environment(\.isLuminanceReduced) var isLuminanceReduced - VStack(alignment: .leading, spacing: 2) { - Text(context.state.title) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(1) - Text(context.state.artist) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if context.state.isPlaying { - LiveActivityMusicBars() - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) + switch activityFamily { + case .small: + smallLockScreenView(context: context) + default: + lockScreenView(context: context, isFullscreen: isFullscreen, isLuminanceReduced: isLuminanceReduced) } - .activityBackgroundTint(.black.opacity(0.9)) - .activitySystemActionForegroundColor(.white) - } dynamicIsland: { context in DynamicIsland { - // ── Expanded ──────────────────────────── DynamicIslandExpandedRegion(.leading) { HStack(spacing: 6) { Image(systemName: "music.note") .foregroundStyle(.green) + .accessibilityLabel("Music") Text(context.state.title) .font(.caption.weight(.semibold)) .lineLimit(1) + .accessibilityLabel(context.state.title) if context.state.isPlaying { LiveActivityMusicBars() } } - .padding(.leading, 8) } DynamicIslandExpandedRegion(.center) { Text(context.state.artist) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) + .accessibilityLabel(context.state.artist) } DynamicIslandExpandedRegion(.trailing) { Button(intent: SkipTrackIntent()) { @@ -83,31 +64,102 @@ struct MusicLiveActivity: Widget { .clipShape(Capsule()) } .buttonStyle(.plain) - .padding(.trailing, 8) + .accessibilityLabel("Skip track") } } compactLeading: { - // ── Compact Leading ───────────────────── Image(systemName: "music.note") .foregroundStyle(.green) .font(.system(size: 14)) + .accessibilityLabel("Music playing") } compactTrailing: { - // ── Compact Trailing ──────────────────── HStack(spacing: 3) { Image(systemName: "music.note") .font(.system(size: 10)) .foregroundStyle(.green) + .accessibilityHidden(true) Text(context.state.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) + .accessibilityLabel(context.state.title) } + .accessibilityLabel("Now playing: \(context.state.title)") } minimal: { - // ── Minimal ───────────────────────────── Image(systemName: "music.note") .foregroundStyle(.green) .font(.system(size: 13)) + .accessibilityLabel("Music playing") } + .keylineTint(.green) + .contentMargins(.leading, 8, for: .expanded) + .contentMargins(.trailing, 8, for: .expanded) .widgetURL(URL(string: "tabatago://music")!) } + .supplementalActivityFamilies([.small, .medium]) + } + + // MARK: - Lock Screen / Banner + + @ViewBuilder + private func lockScreenView( + context: ActivityViewContext, + isFullscreen: Bool, + isLuminanceReduced: Bool + ) -> some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "music.note") + .font(isFullscreen ? .title.weight(.semibold) : .title3.weight(.semibold)) + .foregroundStyle(.green) + .accessibilityLabel("Music") + + VStack(alignment: .leading, spacing: 2) { + Text(context.state.title) + .font(isFullscreen ? .title2 : .headline) + .foregroundStyle(.primary) + .lineLimit(1) + .accessibilityLabel(context.state.title) + Text(context.state.artist) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityLabel(context.state.artist) + } + + Spacer() + + if context.state.isPlaying, !isLuminanceReduced { + LiveActivityMusicBars() + } + } + .padding(.horizontal, 16) + .padding(.vertical, isFullscreen ? 20 : 12) + } + .activityBackgroundTint(.black.opacity(0.9)) + .activitySystemActionForegroundColor(.white) + } + + // MARK: - Small (Apple Watch / CarPlay) + + @ViewBuilder + private func smallLockScreenView(context: ActivityViewContext) -> some View { + HStack(spacing: 6) { + Image(systemName: "music.note") + .font(.caption) + .foregroundStyle(.green) + .accessibilityLabel("Music") + Text(context.state.title) + .font(.caption.weight(.medium)) + .lineLimit(1) + .accessibilityLabel(context.state.title) + Spacer() + Text(context.state.artist) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .activityBackgroundTint(.black.opacity(0.9)) } } @@ -127,6 +179,7 @@ struct LiveActivityMusicBars: View { } } } + .accessibilityHidden(true) } } diff --git a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift index b2453eb..2259255 100644 --- a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift +++ b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift @@ -5,78 +5,25 @@ import SwiftUI struct WorkoutLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in + @Environment(\.activityFamily) var activityFamily + @Environment(\.isActivityFullscreen) var isFullscreen + @Environment(\.isLuminanceReduced) var isLuminanceReduced + let phaseColor = Self.colorForPhase(context.state.phase) let phaseLabel = context.state.phase.capitalized - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 12) { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(phaseColor) - .frame(width: 4, height: 38) - - VStack(alignment: .leading, spacing: 2) { - Text(context.state.exerciseName) - .font(.headline) - .foregroundStyle(.primary) - .minimumScaleFactor(0.7) - Text(phaseLabel) - .font(.caption.weight(.semibold)) - .foregroundStyle(phaseColor) - } - .layoutPriority(1) - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) - .font(.title2.weight(.bold).monospacedDigit()) - .foregroundStyle(.primary) - .contentTransition(.numericText(countsDown: true)) - Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 16) - .padding(.top, 12) - - Divider() - .padding(.horizontal, 16) - .padding(.vertical, 6) - - HStack(spacing: 10) { - Image(systemName: "music.note") - .font(.caption.weight(.semibold)) - .foregroundStyle(.green) - - Text(context.state.trackTitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer() - - if context.state.heartRate > 0 { - HStack(spacing: 3) { - Image(systemName: "heart.fill") - .font(.system(size: 10)) - .foregroundStyle(.red) - Text("\(Int(context.state.heartRate))") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - - if context.state.isPlaying { - LiveActivityMusicBars() - } - } - .padding(.horizontal, 16) - .padding(.bottom, 12) + switch activityFamily { + case .small: + smallLockScreenView(context: context, phaseColor: phaseColor, phaseLabel: phaseLabel) + default: + lockScreenView( + context: context, + phaseColor: phaseColor, + phaseLabel: phaseLabel, + isFullscreen: isFullscreen, + isLuminanceReduced: isLuminanceReduced + ) } - .activityBackgroundTint(.black.opacity(0.9)) - .activitySystemActionForegroundColor(.white) - } dynamicIsland: { context in let phaseColor = Self.colorForPhase(context.state.phase) let phaseLabel = context.state.phase.capitalized @@ -95,64 +42,207 @@ struct WorkoutLiveActivity: Widget { .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } + .accessibilityLabel("\(phaseLabel) phase, round \(context.state.roundCurrent) of \(context.state.roundTotal)") } DynamicIslandExpandedRegion(.center) { Text(context.state.exerciseName) .font(.caption.weight(.semibold)) .lineLimit(1) + .accessibilityLabel("Exercise \(context.state.exerciseName)") } DynamicIslandExpandedRegion(.trailing) { Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) .font(.system(size: 20, weight: .bold).monospacedDigit()) .contentTransition(.numericText(countsDown: true)) .frame(width: 56, alignment: .trailing) + .accessibilityLabel("Countdown timer") } DynamicIslandExpandedRegion(.bottom) { HStack(spacing: 6) { Image(systemName: "music.note") .font(.caption) .foregroundStyle(.green) + .accessibilityLabel("Music") Text(context.state.trackArtist.isEmpty ? context.state.trackTitle : "\(context.state.trackTitle) — \(context.state.trackArtist)") .font(.caption) .lineLimit(1) .foregroundStyle(.secondary) + .accessibilityLabel(context.state.trackArtist.isEmpty ? context.state.trackTitle : "\(context.state.trackTitle) by \(context.state.trackArtist)") Spacer() if context.state.heartRate > 0 { HStack(spacing: 2) { Image(systemName: "heart.fill") .font(.system(size: 9)) .foregroundStyle(.red) + .accessibilityLabel("Heart rate") Text("\(Int(context.state.heartRate))") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } + .accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute") } if context.state.isPlaying { LiveActivityMusicBars() } } - .padding(.horizontal, 8) .padding(.bottom, 4) } } compactLeading: { Circle() .fill(phaseColor) .frame(width: 10, height: 10) + .accessibilityLabel("\(phaseLabel) phase, workout in progress") } compactTrailing: { Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) .font(.system(size: 12, weight: .medium).monospacedDigit()) .contentTransition(.numericText(countsDown: true)) + .accessibilityLabel("Countdown timer") } minimal: { Circle() .fill(phaseColor) .frame(width: 6, height: 6) + .accessibilityLabel("Workout in progress") } + .keylineTint(phaseColor) + .contentMargins(.leading, 8, for: .expanded) + .contentMargins(.trailing, 8, for: .expanded) + .contentMargins(.bottom, 4, for: .expanded) .widgetURL(URL(string: "tabatago://workout")!) } + .supplementalActivityFamilies([.small, .medium]) } + // MARK: - Lock Screen / Banner + + @ViewBuilder + private func lockScreenView( + context: ActivityViewContext, + phaseColor: Color, + phaseLabel: String, + isFullscreen: Bool, + isLuminanceReduced: Bool + ) -> some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 12) { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(phaseColor) + .frame(width: 4, height: 38) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(context.state.exerciseName) + .font(isFullscreen ? .title2 : .headline) + .foregroundStyle(.primary) + .minimumScaleFactor(0.7) + .accessibilityLabel(context.state.exerciseName) + Text(phaseLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(phaseColor) + .accessibilityLabel("\(phaseLabel) phase") + } + .layoutPriority(1) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) + .font(isFullscreen ? .largeTitle.weight(.bold) : .title2.weight(.bold)) + .fontDesign(.monospaced) + .foregroundStyle(.primary) + .contentTransition(.numericText(countsDown: true)) + .accessibilityLabel("Countdown timer") + Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)") + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityLabel("Round \(context.state.roundCurrent) of \(context.state.roundTotal)") + } + } + .padding(.horizontal, 16) + .padding(.top, isFullscreen ? 24 : 12) + + Divider() + .padding(.horizontal, 16) + .padding(.vertical, 6) + + HStack(spacing: 10) { + Image(systemName: "music.note") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + .accessibilityLabel("Music") + + Text(context.state.trackTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityLabel(context.state.trackTitle) + + Spacer() + + if context.state.heartRate > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10)) + .foregroundStyle(.red) + .accessibilityHidden(true) + Text("\(Int(context.state.heartRate))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .accessibilityLabel("Heart rate \(Int(context.state.heartRate)) beats per minute") + } + + if context.state.isPlaying, !isLuminanceReduced { + LiveActivityMusicBars() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .activityBackgroundTint(.black.opacity(0.9)) + .activitySystemActionForegroundColor(.white) + } + + // MARK: - Small (Apple Watch / CarPlay) + + @ViewBuilder + private func smallLockScreenView( + context: ActivityViewContext, + phaseColor: Color, + phaseLabel: String + ) -> some View { + HStack(spacing: 8) { + Circle() + .fill(phaseColor) + .frame(width: 8, height: 8) + .accessibilityLabel("\(phaseLabel) phase") + + VStack(alignment: .leading, spacing: 1) { + Text(context.state.exerciseName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .accessibilityLabel(context.state.exerciseName) + Text("\(context.state.roundCurrent)/\(context.state.roundTotal)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) + .font(.caption.monospacedDigit()) + .foregroundStyle(.primary) + .contentTransition(.numericText(countsDown: true)) + .accessibilityLabel("Countdown timer") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .activityBackgroundTint(.black.opacity(0.9)) + } + + // MARK: - Phase Color + static func colorForPhase(_ phase: String) -> Color { switch phase { case "prep": return Color(red: 1.0, green: 0.58, blue: 0.0)