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:
@@ -13,12 +13,18 @@ import TipKit
|
||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||
struct SettingsContentView: View {
|
||||
@EnvironmentObject var authManager: BiometricAuthManager
|
||||
@EnvironmentObject var iapManager: IAPManager
|
||||
|
||||
@State private var showOnboarding = false
|
||||
@State private var showExportView = false
|
||||
@State private var showReminderTimePicker = false
|
||||
@State private var showSubscriptionStore = false
|
||||
@State private var showTrialDatePicker = false
|
||||
@StateObject private var healthService = HealthService.shared
|
||||
|
||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults)
|
||||
private var firstLaunchDate = Date()
|
||||
|
||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
|
||||
@@ -43,6 +49,12 @@ struct SettingsContentView: View {
|
||||
eulaButton
|
||||
privacyButton
|
||||
|
||||
#if DEBUG
|
||||
// Debug section
|
||||
debugSectionHeader
|
||||
trialDateButton
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
.frame(height: 20)
|
||||
|
||||
@@ -107,6 +119,9 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding()
|
||||
})
|
||||
.accessibilityLabel(String(localized: "Reminder Time"))
|
||||
.accessibilityValue(formattedReminderTime)
|
||||
.accessibilityHint(String(localized: "Opens time picker to change reminder time"))
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
@@ -156,6 +171,79 @@ struct SettingsContentView: View {
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// MARK: - Debug Section
|
||||
|
||||
#if DEBUG
|
||||
private var debugSectionHeader: some View {
|
||||
HStack {
|
||||
Text("Debug")
|
||||
.font(.headline)
|
||||
.foregroundColor(.red)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 20)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var trialDateButton: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Trial Start Date")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Change") {
|
||||
showTrialDatePicker = true
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showTrialDatePicker) {
|
||||
NavigationStack {
|
||||
DatePicker(
|
||||
"Trial Start Date",
|
||||
selection: $firstLaunchDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.padding()
|
||||
.navigationTitle("Set Trial Start Date")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
showTrialDatePicker = false
|
||||
// Refresh subscription state
|
||||
Task {
|
||||
await iapManager.checkSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Privacy Lock Toggle
|
||||
|
||||
@ViewBuilder
|
||||
@@ -196,6 +284,8 @@ struct SettingsContentView: View {
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "Privacy Lock"))
|
||||
.accessibilityHint(String(localized: "Require biometric authentication to open app"))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
@@ -228,9 +318,16 @@ struct SettingsContentView: View {
|
||||
Spacer()
|
||||
|
||||
if healthService.isAvailable {
|
||||
// Disable toggle and force off when paywall should show
|
||||
Toggle("", isOn: Binding(
|
||||
get: { healthService.isEnabled },
|
||||
get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled },
|
||||
set: { newValue in
|
||||
// If paywall should show, show subscription store instead
|
||||
if iapManager.shouldShowPaywall {
|
||||
showSubscriptionStore = true
|
||||
return
|
||||
}
|
||||
|
||||
if newValue {
|
||||
Task {
|
||||
// Request all permissions in a single dialog
|
||||
@@ -257,20 +354,40 @@ struct SettingsContentView: View {
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(iapManager.shouldShowPaywall)
|
||||
.accessibilityLabel(String(localized: "Apple Health"))
|
||||
.accessibilityHint(String(localized: "Sync mood data with Apple Health"))
|
||||
} else {
|
||||
Text("Not Available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(String(localized: "Apple Health not available"))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Show premium badge when paywall should show
|
||||
if iapManager.shouldShowPaywall {
|
||||
HStack {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Premium Feature")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "Premium feature, subscription required"))
|
||||
}
|
||||
// Show sync progress or status
|
||||
if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||
else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||
VStack(spacing: 4) {
|
||||
if healthKitManager.isSyncing {
|
||||
ProgressView(value: healthKitManager.syncProgress)
|
||||
.tint(.red)
|
||||
.accessibilityLabel(String(localized: "Syncing health data"))
|
||||
.accessibilityValue("\(Int(healthKitManager.syncProgress * 100)) percent")
|
||||
}
|
||||
Text(healthKitManager.syncStatus)
|
||||
.font(.caption)
|
||||
@@ -283,6 +400,9 @@ struct SettingsContentView: View {
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.healthKitSyncTip()
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
@@ -317,6 +437,8 @@ struct SettingsContentView: View {
|
||||
}
|
||||
.padding()
|
||||
})
|
||||
.accessibilityLabel(String(localized: "Export Data"))
|
||||
.accessibilityHint(String(localized: "Export your mood data as CSV or PDF"))
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
@@ -332,6 +454,7 @@ struct SettingsContentView: View {
|
||||
Text(String(localized: "settings_view_show_onboarding"))
|
||||
.foregroundColor(textColor)
|
||||
})
|
||||
.accessibilityHint(String(localized: "View the app introduction again"))
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -348,6 +471,7 @@ struct SettingsContentView: View {
|
||||
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityHint(String(localized: "Allow deleting mood entries by swiping"))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -367,6 +491,7 @@ struct SettingsContentView: View {
|
||||
Text(String(localized: "settings_view_show_eula"))
|
||||
.foregroundColor(textColor)
|
||||
})
|
||||
.accessibilityHint(String(localized: "Opens End User License Agreement in browser"))
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -385,6 +510,7 @@ struct SettingsContentView: View {
|
||||
Text(String(localized: "settings_view_show_privacy"))
|
||||
.foregroundColor(textColor)
|
||||
})
|
||||
.accessibilityHint(String(localized: "Opens Privacy Policy in browser"))
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -467,6 +593,8 @@ struct SettingsView: View {
|
||||
|
||||
@State private var showSpecialThanks = false
|
||||
@State private var showWhyBGMode = false
|
||||
@State private var showSubscriptionStore = false
|
||||
@State private var showTrialDatePicker = false
|
||||
@StateObject private var healthService = HealthService.shared
|
||||
|
||||
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
|
||||
@@ -712,9 +840,16 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
|
||||
if healthService.isAvailable {
|
||||
// Disable toggle and force off when paywall should show
|
||||
Toggle("", isOn: Binding(
|
||||
get: { healthService.isEnabled },
|
||||
get: { iapManager.shouldShowPaywall ? false : healthService.isEnabled },
|
||||
set: { newValue in
|
||||
// If paywall should show, show subscription store instead
|
||||
if iapManager.shouldShowPaywall {
|
||||
showSubscriptionStore = true
|
||||
return
|
||||
}
|
||||
|
||||
if newValue {
|
||||
Task {
|
||||
// Request all permissions in a single dialog
|
||||
@@ -741,6 +876,7 @@ struct SettingsView: View {
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.disabled(iapManager.shouldShowPaywall)
|
||||
} else {
|
||||
Text("Not Available")
|
||||
.font(.caption)
|
||||
@@ -749,8 +885,20 @@ struct SettingsView: View {
|
||||
}
|
||||
.padding()
|
||||
|
||||
// Show premium badge when paywall should show
|
||||
if iapManager.shouldShowPaywall {
|
||||
HStack {
|
||||
Image(systemName: "crown.fill")
|
||||
.foregroundColor(.yellow)
|
||||
Text("Premium Feature")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
// Show sync progress or status
|
||||
if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||
else if healthKitManager.isSyncing || !healthKitManager.syncStatus.isEmpty {
|
||||
VStack(spacing: 4) {
|
||||
if healthKitManager.isSyncing {
|
||||
ProgressView(value: healthKitManager.syncProgress)
|
||||
@@ -766,6 +914,9 @@ struct SettingsView: View {
|
||||
}
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data Button
|
||||
@@ -870,24 +1021,57 @@ struct SettingsView: View {
|
||||
private var editFirstLaunchDatePast: some View {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
Button(action: {
|
||||
var tmpDate = Date()
|
||||
tmpDate = Calendar.current.date(byAdding: .day, value: -29, to: tmpDate)!
|
||||
tmpDate = Calendar.current.date(byAdding: .hour, value: -23, to: tmpDate)!
|
||||
tmpDate = Calendar.current.date(byAdding: .minute, value: -59, to: tmpDate)!
|
||||
tmpDate = Calendar.current.date(byAdding: .second, value: -45, to: tmpDate)!
|
||||
firstLaunchDate = tmpDate
|
||||
Task {
|
||||
await iapManager.checkSubscriptionStatus()
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Trial Start Date")
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Current: \(firstLaunchDate.formatted(date: .abbreviated, time: .omitted))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}, label: {
|
||||
Text("Set first launch date back 29 days, 23 hrs, 45 seconds")
|
||||
.foregroundColor(textColor)
|
||||
})
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Change") {
|
||||
showTrialDatePicker = true
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
|
||||
.sheet(isPresented: $showTrialDatePicker) {
|
||||
NavigationStack {
|
||||
DatePicker(
|
||||
"Trial Start Date",
|
||||
selection: $firstLaunchDate,
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
.padding()
|
||||
.navigationTitle("Set Trial Start Date")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
showTrialDatePicker = false
|
||||
// Refresh subscription state
|
||||
Task {
|
||||
await iapManager.checkSubscriptionStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
private var resetLaunchDate: some View {
|
||||
|
||||
Reference in New Issue
Block a user