152 lines
5.1 KiB
Swift
152 lines
5.1 KiB
Swift
// ── 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)
|
|
}
|
|
}
|