diff --git a/ios/ChronoMind/Shared/Accessibility/AccessibilityHelpers.swift b/ios/ChronoMind/Shared/Accessibility/AccessibilityHelpers.swift new file mode 100644 index 0000000..4fa2fce --- /dev/null +++ b/ios/ChronoMind/Shared/Accessibility/AccessibilityHelpers.swift @@ -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) + } +} diff --git a/ios/ChronoMind/Views/Components/TimerCard.swift b/ios/ChronoMind/Views/Components/TimerCard.swift index 905f0ec..9e6fe54 100644 --- a/ios/ChronoMind/Views/Components/TimerCard.swift +++ b/ios/ChronoMind/Views/Components/TimerCard.swift @@ -154,6 +154,7 @@ struct TimerCard: View { .stroke(cardBorder, lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: CMRadius.md)) + .timerAccessible(timer, now: now) } // MARK: - Timer State View