feat(a11y): add VoiceOver, Dynamic Type, Reduce Motion, High Contrast accessibility helpers
This commit is contained in:
parent
be0e8748b2
commit
3bec3602d2
151
ios/ChronoMind/Shared/Accessibility/AccessibilityHelpers.swift
Normal file
151
ios/ChronoMind/Shared/Accessibility/AccessibilityHelpers.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -154,6 +154,7 @@ struct TimerCard: View {
|
||||
.stroke(cardBorder, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: CMRadius.md))
|
||||
.timerAccessible(timer, now: now)
|
||||
}
|
||||
|
||||
// MARK: - Timer State View
|
||||
|
||||
Loading…
Reference in New Issue
Block a user