- Add VoiceOver labels and hints to all voting layouts, settings, widgets, onboarding screens, and entry cells - Add Reduce Motion support to button animations throughout the app - Ensure 44x44pt minimum touch targets on widget mood buttons - Enhance AccessibilityHelpers with Dynamic Type support, ScaledValue wrapper, and VoiceOver detection utilities - Gate premium features (Insights, Month/Year views) behind subscription - Update widgets to show subscription prompts for non-subscribers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
193 lines
6.0 KiB
Swift
193 lines
6.0 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 }
|
|
}
|
|
}
|
|
|
|
/// View modifier that respects reduce motion preference
|
|
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 {
|
|
/// Applies animation only when reduce motion is disabled
|
|
func accessibleAnimation(_ animation: Animation? = .default, reduced: Animation? = nil) -> some View {
|
|
modifier(ReduceMotionModifier(animation: animation, reducedAnimation: reduced))
|
|
}
|
|
|
|
/// 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 formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
|
|
return self
|
|
.accessibilityLabel("\(mood.strValue) on \(formatter.string(from: date))")
|
|
.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)
|
|
}
|
|
|
|
/// Returns a custom-sized font that scales with Dynamic Type
|
|
static func scaledSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default, relativeTo style: Font.TextStyle = .body) -> Font {
|
|
Font.system(size: size, weight: weight, design: design)
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|