Add comprehensive WCAG 2.1 AA accessibility support

- 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>
This commit is contained in:
Trey t
2025-12-23 23:26:21 -06:00
parent a6a6912183
commit 086f8b8807
24 changed files with 741 additions and 283 deletions

View File

@@ -308,7 +308,7 @@ struct FeelsWidgetEntryView : View {
var entry: Provider.Entry
private var showVotingForToday: Bool {
entry.hasSubscription && !entry.hasVotedToday
!entry.hasVotedToday
}
@ViewBuilder
@@ -338,7 +338,7 @@ struct SmallWidgetView: View {
var todayView: WatchTimelineView?
private var showVotingForToday: Bool {
entry.hasSubscription && !entry.hasVotedToday
!entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
@@ -365,8 +365,8 @@ struct SmallWidgetView: View {
var body: some View {
if showVotingForToday {
// Show interactive voting buttons
VotingView(family: .systemSmall, promptText: entry.promptText)
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else if let today = todayView {
VStack(spacing: 0) {
Spacer()
@@ -405,7 +405,7 @@ struct MediumWidgetView: View {
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
entry.hasSubscription && !entry.hasVotedToday
!entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
@@ -439,8 +439,8 @@ struct MediumWidgetView: View {
var body: some View {
if showVotingForToday {
// Show interactive voting buttons
VotingView(family: .systemMedium, promptText: entry.promptText)
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemMedium, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
@@ -524,7 +524,7 @@ struct LargeWidgetView: View {
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
entry.hasSubscription && !entry.hasVotedToday
!entry.hasVotedToday
}
init(entry: Provider.Entry) {
@@ -551,8 +551,8 @@ struct LargeWidgetView: View {
var body: some View {
if showVotingForToday {
// Show interactive voting buttons for large widget
LargeVotingView(promptText: entry.promptText)
// Show interactive voting buttons for large widget (or open app links if expired)
LargeVotingView(promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows
@@ -668,6 +668,7 @@ struct DayCell: View {
struct LargeVotingView: View {
let promptText: String
let hasSubscription: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
@@ -681,7 +682,7 @@ struct LargeVotingView: View {
VStack(spacing: 24) {
Spacer()
Text(promptText)
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -691,26 +692,7 @@ struct LargeVotingView: View {
// Large mood buttons in a row
HStack(spacing: 20) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
Button(intent: VoteMoodIntent(mood: mood)) {
VStack(spacing: 8) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.strValue)
.font(.caption.weight(.medium))
.foregroundColor(moodTint.color(forMood: mood))
}
.padding(.vertical, 12)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
}
.buttonStyle(.plain)
moodButton(for: mood)
}
}
@@ -718,6 +700,40 @@ struct LargeVotingView: View {
}
.padding()
}
@ViewBuilder
private func moodButton(for mood: Mood) -> some View {
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodButtonContent(for: mood)
}
.buttonStyle(.plain)
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodButtonContent(for: mood)
}
}
}
private func moodButtonContent(for mood: Mood) -> some View {
VStack(spacing: 8) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.strValue)
.font(.caption.weight(.medium))
.foregroundColor(moodTint.color(forMood: mood))
}
.padding(.vertical, 12)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
}
}
/**********************************************************/
@@ -823,11 +839,12 @@ struct TimeBodyView: View {
let group: [WatchTimelineView]
var showVotingForToday: Bool = false
var promptText: String = ""
var hasSubscription: Bool = false
var body: some View {
if showVotingForToday {
// Show voting view without extra background container
InlineVotingView(promptText: promptText)
InlineVotingView(promptText: promptText, hasSubscription: hasSubscription)
.padding()
} else {
ZStack {
@@ -847,6 +864,7 @@ struct TimeBodyView: View {
struct InlineVotingView: View {
let promptText: String
let hasSubscription: Bool
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
private var moodTint: MoodTintable.Type {
@@ -859,7 +877,7 @@ struct InlineVotingView: View {
var body: some View {
VStack(spacing: 8) {
Text(promptText)
Text(hasSubscription ? promptText : "Tap to open app")
.font(.subheadline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
@@ -868,18 +886,33 @@ struct InlineVotingView: View {
HStack(spacing: 8) {
ForEach(moods, id: \.rawValue) { mood in
Button(intent: VoteMoodIntent(mood: mood)) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
.foregroundColor(moodTint.color(forMood: mood))
}
.buttonStyle(.plain)
moodButton(for: mood)
}
}
}
}
@ViewBuilder
private func moodButton(for mood: Mood) -> some View {
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodIcon(for: mood)
}
.buttonStyle(.plain)
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodIcon(for: mood)
}
}
}
private func moodIcon(for mood: Mood) -> some View {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
.foregroundColor(moodTint.color(forMood: mood))
}
}
struct EntryCard: View {