Files
Reflect/Shared/Utilities/AccessibilityHelpers.swift
Trey t c22d246865 Fix 25 audit issues: memory leaks, concurrency, performance, accessibility
Address findings from comprehensive audit across 5 workstreams:

- Memory: Token-based DataController listeners (prevent closure leaks),
  static DateFormatters, ImageCache observer cleanup, MotionManager
  reference counting, FoundationModels dedup guard
- Concurrency: Replace Task.detached with Task in FeelsApp (preserve
  MainActor isolation), wrap WatchConnectivity handler in MainActor
- Performance: Cache sortedGroupedData in DayViewViewModel, cache demo
  data in MonthView/YearView, remove broken ReduceMotionModifier
- Accessibility: VoiceOver support for LockScreen, DemoHeatmapCell
  labels, MonthCard button labels, InsightsView header traits,
  Smart Invert protection on neon headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:11:48 -06:00

169 lines
5.1 KiB
Swift

//
// AccessibilityHelpers.swift
// Feels
//
// Accessibility utilities for supporting VoiceOver, Dynamic Type, and Reduce Motion.
//
import SwiftUI
// MARK: - Reduce Motion Support
/// Environment key for accessing reduce motion preference
struct ReduceMotionKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var reduceMotion: Bool {
get { self[ReduceMotionKey.self] }
set { self[ReduceMotionKey.self] = newValue }
}
}
extension View {
/// Wraps content in withAnimation respecting reduce motion
func withAccessibleAnimation<V: Equatable>(_ animation: Animation? = .default, value: V, action: @escaping () -> Void) -> some View {
self.onChange(of: value) { _, _ in
if UIAccessibility.isReduceMotionEnabled {
action()
} else {
withAnimation(animation) {
action()
}
}
}
}
}
// MARK: - Accessibility Helpers
extension View {
/// Adds accessibility label with optional hint
func accessibleMoodCell(mood: Mood, date: Date) -> some View {
let dateString = DateFormattingCache.shared.string(for: date, format: .dateMedium)
return self
.accessibilityLabel("\(mood.strValue) on \(dateString)")
.accessibilityHint("Double tap to edit mood")
}
/// Makes a button accessible with custom label
func accessibleButton(label: String, hint: String? = nil) -> some View {
self
.accessibilityLabel(label)
.accessibilityHint(hint ?? "")
.accessibilityAddTraits(.isButton)
}
/// Groups related elements for VoiceOver
func accessibilityGrouped(label: String) -> some View {
self
.accessibilityElement(children: .combine)
.accessibilityLabel(label)
}
}
// MARK: - Dynamic Type Support
extension Font {
/// Returns a scalable font that respects Dynamic Type
static func scalable(_ style: Font.TextStyle, weight: Font.Weight = .regular) -> Font {
Font.system(style, design: .rounded).weight(weight)
}
}
/// Property wrapper for scaled metrics that respect Dynamic Type
@propertyWrapper
struct ScaledValue: DynamicProperty {
@ScaledMetric private var scaledValue: CGFloat
var wrappedValue: CGFloat { scaledValue }
init(wrappedValue: CGFloat, relativeTo textStyle: Font.TextStyle = .body) {
_scaledValue = ScaledMetric(wrappedValue: wrappedValue, relativeTo: textStyle)
}
}
extension View {
/// Ensures minimum touch target size for accessibility (44x44 points)
func accessibleTouchTarget() -> some View {
self.frame(minWidth: 44, minHeight: 44)
}
}
// MARK: - Accessibility Announcements
struct AccessibilityAnnouncement {
/// Announces a message to VoiceOver users
static func announce(_ message: String) {
UIAccessibility.post(notification: .announcement, argument: message)
}
/// Notifies VoiceOver that screen content has changed
static func screenChanged() {
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
/// Notifies VoiceOver that layout has changed
static func layoutChanged(focusElement: Any? = nil) {
UIAccessibility.post(notification: .layoutChanged, argument: focusElement)
}
}
// MARK: - Reduce Motion View Modifier
/// View modifier that provides alternative content when Reduce Motion is enabled
struct ReduceMotionContent<Reduced: View>: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let reducedContent: () -> Reduced
func body(content: Content) -> some View {
if reduceMotion {
reducedContent()
} else {
content
}
}
}
extension View {
/// Provides alternative content when Reduce Motion is enabled
func reduceMotionAlternative<V: View>(@ViewBuilder _ reduced: @escaping () -> V) -> some View {
modifier(ReduceMotionContent(reducedContent: reduced))
}
/// Conditionally applies animation based on Reduce Motion setting
func animationIfAllowed<V: Equatable>(_ animation: Animation?, value: V) -> some View {
modifier(ConditionalAnimationModifier(animation: animation, value: value))
}
}
struct ConditionalAnimationModifier<V: Equatable>: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let animation: Animation?
let value: V
func body(content: Content) -> some View {
if reduceMotion {
content
} else {
content.animation(animation, value: value)
}
}
}
// MARK: - VoiceOver Detection
extension View {
/// Runs an action when VoiceOver is running
func onVoiceOverChange(perform action: @escaping (Bool) -> Void) -> some View {
self.onReceive(NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)) { _ in
action(UIAccessibility.isVoiceOverRunning)
}
.onAppear {
action(UIAccessibility.isVoiceOverRunning)
}
}
}