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:
@@ -158,28 +158,26 @@ struct FeelsVoteWidgetEntryView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entry.hasSubscription {
|
||||
if entry.hasVotedToday {
|
||||
// Show stats after voting
|
||||
VotedStatsView(entry: entry)
|
||||
} else {
|
||||
// Show voting buttons
|
||||
VotingView(family: family, promptText: entry.promptText)
|
||||
}
|
||||
if entry.hasVotedToday {
|
||||
// Already voted today - show stats (regardless of subscription status)
|
||||
VotedStatsView(entry: entry)
|
||||
} else {
|
||||
// Non-subscriber view - tap to open app
|
||||
NonSubscriberView()
|
||||
// Not voted yet - show voting buttons
|
||||
// If subscribed/in trial: buttons record votes
|
||||
// If trial expired: buttons open app
|
||||
VotingView(family: family, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Voting View (for subscribers who haven't voted)
|
||||
// MARK: - Voting View
|
||||
|
||||
struct VotingView: View {
|
||||
let family: WidgetFamily
|
||||
let promptText: String
|
||||
let hasSubscription: Bool
|
||||
|
||||
private var moodTint: MoodTintable.Type {
|
||||
UserDefaultsStore.moodTintable()
|
||||
@@ -200,7 +198,7 @@ struct VotingView: View {
|
||||
// MARK: - Small Widget: 3 over 2 grid
|
||||
private var smallLayout: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(promptText)
|
||||
Text(hasSubscription ? promptText : "Tap to open app")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -230,7 +228,7 @@ struct VotingView: View {
|
||||
// MARK: - Medium Widget: Single row
|
||||
private var mediumLayout: some View {
|
||||
VStack {
|
||||
Text(promptText)
|
||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -247,15 +245,37 @@ struct VotingView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
|
||||
Button(intent: VoteMoodIntent(mood: mood)) {
|
||||
moodImages.icon(forMood: mood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(moodTint.color(forMood: mood))
|
||||
// Ensure minimum 44x44 touch target for accessibility
|
||||
let touchSize = max(size, 44)
|
||||
|
||||
if hasSubscription {
|
||||
// Active subscription: vote normally
|
||||
Button(intent: VoteMoodIntent(mood: mood)) {
|
||||
moodIcon(for: mood, size: size)
|
||||
.frame(minWidth: touchSize, minHeight: touchSize)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(mood.strValue)
|
||||
.accessibilityHint(String(localized: "Log this mood"))
|
||||
} else {
|
||||
// Trial expired: open app to subscribe
|
||||
Link(destination: URL(string: "feels://subscribe")!) {
|
||||
moodIcon(for: mood, size: size)
|
||||
.frame(minWidth: touchSize, minHeight: touchSize)
|
||||
}
|
||||
.accessibilityLabel(mood.strValue)
|
||||
.accessibilityHint(String(localized: "Open app to subscribe"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
|
||||
moodImages.icon(forMood: mood)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(moodTint.color(forMood: mood))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +331,8 @@ struct VotedStatsView: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "Mood logged: \(entry.todaysMood?.strValue ?? "")"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(12)
|
||||
@@ -407,6 +429,8 @@ struct NonSubscriberView: View {
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Track Your Mood"))
|
||||
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user