feat(a11y): add VoiceOver, Dynamic Type, Reduce Motion, High Contrast accessibility helpers

This commit is contained in:
saravanakumardb1 2026-02-27 22:20:22 -08:00
parent be0e8748b2
commit 3bec3602d2
2 changed files with 152 additions and 0 deletions

View File

@ -0,0 +1,151 @@
// Accessibility Helpers
// VoiceOver labels, Dynamic Type support, Reduce Motion, High Contrast
import SwiftUI
// MARK: - Timer Accessibility Descriptions
enum TimerAccessibility {
/// Full VoiceOver description for a timer
static func description(for timer: CMTimer, now: Date) -> String {
let remaining = getRemainingSeconds(timer, now: now)
let timeStr = formatDuration(remaining)
let urgencyLabel = getUrgencyConfig(timer.urgency).label
switch timer.state {
case .active:
return "\(timer.label), \(urgencyLabel) urgency, \(timeStr) remaining"
case .warning:
let firedCount = timer.warnings.filter(\.fired).count
let total = timer.warnings.count
return "\(timer.label), warning, \(firedCount) of \(total) cascade warnings fired, \(timeStr) remaining"
case .firing:
return "\(timer.label), firing now, \(urgencyLabel) urgency, requires attention"
case .paused:
return "\(timer.label), paused, \(timeStr) remaining when resumed"
case .snoozed:
let snoozeStr = timer.snoozedUntil.map { formatRelativeTime($0, now: now) } ?? "soon"
return "\(timer.label), snoozed, will resume \(snoozeStr)"
case .completed:
return "\(timer.label), completed"
case .dismissed:
return "\(timer.label), dismissed"
case .idle:
return "\(timer.label), idle"
}
}
/// Hint for VoiceOver actions
static func hint(for timer: CMTimer) -> String {
switch timer.state {
case .active, .warning:
return "Double tap to view details. Swipe up for pause and snooze options."
case .firing:
return "Double tap to dismiss. Swipe up for snooze options."
case .paused:
return "Double tap to resume."
default:
return "Double tap to view details."
}
}
/// State announcement for VoiceOver
static func stateAnnouncement(_ state: CMTimerState) -> String {
switch state {
case .active: return "Timer active"
case .warning: return "Warning, cascade alert"
case .firing: return "Timer firing, requires attention"
case .paused: return "Timer paused"
case .snoozed: return "Timer snoozed"
case .completed: return "Timer completed"
case .dismissed: return "Timer dismissed"
case .idle: return "Timer idle"
}
}
}
// MARK: - Reduce Motion
struct ReduceMotionModifier: ViewModifier {
@Environment(\.accessibilityReduceMotion) var reduceMotion
let animation: Animation
let reducedAnimation: Animation
func body(content: Content) -> some View {
content.animation(reduceMotion ? reducedAnimation : animation, value: UUID())
}
}
extension View {
/// Apply animation that respects Reduce Motion setting
func motionSafe(_ animation: Animation, reduced: Animation = .none) -> some View {
modifier(ReduceMotionModifier(animation: animation, reducedAnimation: reduced))
}
}
// MARK: - High Contrast Support
struct HighContrastModifier: ViewModifier {
@Environment(\.colorSchemeContrast) var contrast
let normalOpacity: Double
let highContrastOpacity: Double
func body(content: Content) -> some View {
content.opacity(contrast == .increased ? highContrastOpacity : normalOpacity)
}
}
extension View {
/// Adjust opacity based on High Contrast mode
func contrastAdaptive(normal: Double = 1.0, increased: Double = 1.0) -> some View {
modifier(HighContrastModifier(normalOpacity: normal, highContrastOpacity: increased))
}
}
// MARK: - Dynamic Type Scaling
struct ScaledFontModifier: ViewModifier {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
let baseSize: CGFloat
let maxSize: CGFloat
func body(content: Content) -> some View {
content.font(.system(size: scaledSize))
}
private var scaledSize: CGFloat {
let scale: CGFloat
switch dynamicTypeSize {
case .xSmall: scale = 0.8
case .small: scale = 0.9
case .medium: scale = 1.0
case .large: scale = 1.0
case .xLarge: scale = 1.1
case .xxLarge: scale = 1.2
case .xxxLarge: scale = 1.3
case .accessibility1: scale = 1.5
case .accessibility2: scale = 1.7
case .accessibility3: scale = 1.9
case .accessibility4: scale = 2.1
case .accessibility5: scale = 2.3
@unknown default: scale = 1.0
}
return min(baseSize * scale, maxSize)
}
}
// MARK: - Accessibility View Extension
extension View {
/// Add comprehensive timer accessibility
func timerAccessible(_ timer: CMTimer, now: Date) -> some View {
self
.accessibilityElement(children: .combine)
.accessibilityLabel(TimerAccessibility.description(for: timer, now: now))
.accessibilityHint(TimerAccessibility.hint(for: timer))
.accessibilityAddTraits(timer.state == .firing ? .updatesFrequently : .isButton)
}
}

View File

@ -154,6 +154,7 @@ struct TimerCard: View {
.stroke(cardBorder, lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
.timerAccessible(timer, now: now)
}
// MARK: - Timer State View