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

@@ -93,8 +93,12 @@ struct HorizontalVotingView: View {
}
.buttonStyle(MoodButtonStyle())
.frame(maxWidth: .infinity)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -136,8 +140,12 @@ struct CardVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -177,10 +185,14 @@ struct RadialVotingView: View {
}
.buttonStyle(MoodButtonStyle())
.position(position)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
}
.frame(height: 180)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
private func angleForIndex(_ index: Int, total: Int) -> Double {
@@ -233,8 +245,12 @@ struct StackedVotingView: View {
)
}
.buttonStyle(CardButtonStyle())
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
@@ -323,36 +339,43 @@ struct AuraVotingView: View {
}
}
.buttonStyle(AuraButtonStyle(color: color))
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
// Custom button style for aura with glow effect on press
struct AuraButtonStyle: ButtonStyle {
let color: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.92 : 1.0)
.brightness(configuration.isPressed ? 0.1 : 0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Button Styles
struct MoodButtonStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}
struct CardButtonStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1.0)
.opacity(configuration.isPressed ? 0.8 : 1.0)
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: configuration.isPressed)
}
}

View File

@@ -29,17 +29,24 @@ struct DayFilterPickerView: View {
ForEach(weekdays.indices, id: \.self) { dayIdx in
let day = String(weekdays[dayIdx].0)
let value = weekdays[dayIdx].1
Button(day.capitalized, action: {
if filteredDays.currentFilters.contains(value) {
let isSelected = filteredDays.currentFilters.contains(value)
Button(action: {
if isSelected {
filteredDays.removeFilter(filter: value)
} else {
filteredDays.addFilter(newFilter: value)
}
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
})
.frame(maxWidth: .infinity)
.foregroundColor(filteredDays.currentFilters.contains(value) ? .green : .red)
}) {
Text(day.capitalized)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(uiColor: .tertiarySystemBackground))
.foregroundColor(isSelected ? .green : .red)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
Text(String(localized: "day_picker_view_text"))

View File

@@ -174,6 +174,7 @@ extension DayView {
Image(systemName: "calendar")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(textColor.opacity(0.6))
.accessibilityHidden(true)
Text("\(Random.monthName(fromMonthInt: month)) \(String(year))")
.font(.system(size: 20, weight: .bold, design: .rounded))
@@ -184,6 +185,9 @@ extension DayView {
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.ultraThinMaterial)
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(Random.monthName(fromMonthInt: month)) \(String(year))"))
.accessibilityAddTraits(.isHeader)
}
private func auraSectionHeader(month: Int, year: Int) -> some View {

View File

@@ -67,11 +67,12 @@ class DayViewViewModel: ObservableObject {
return
}
// Sync to HealthKit for past day updates
// Sync to HealthKit for past day updates (only if user has full access)
guard mood != .missing && mood != .placeholder else { return }
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
if healthKitEnabled {
let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: entry.forDate)
}

View File

@@ -26,45 +26,63 @@ struct EntryListView: View {
}
var body: some View {
switch dayViewStyle {
case .classic:
classicStyle
case .minimal:
minimalStyle
case .compact:
compactStyle
case .bubble:
bubbleStyle
case .grid:
gridStyle
case .aura:
auraStyle
case .chronicle:
chronicleStyle
case .neon:
neonStyle
case .ink:
inkStyle
case .prism:
prismStyle
case .tape:
tapeStyle
case .morph:
morphStyle
case .stack:
stackStyle
case .wave:
waveStyle
case .pattern:
patternStyle
case .leather:
leatherStyle
case .glass:
glassStyle
case .motion:
motionStyle
case .micro:
microStyle
Group {
switch dayViewStyle {
case .classic:
classicStyle
case .minimal:
minimalStyle
case .compact:
compactStyle
case .bubble:
bubbleStyle
case .grid:
gridStyle
case .aura:
auraStyle
case .chronicle:
chronicleStyle
case .neon:
neonStyle
case .ink:
inkStyle
case .prism:
prismStyle
case .tape:
tapeStyle
case .morph:
morphStyle
case .stack:
stackStyle
case .wave:
waveStyle
case .pattern:
patternStyle
case .leather:
leatherStyle
case .glass:
glassStyle
case .motion:
motionStyle
case .micro:
microStyle
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(isMissing ? String(localized: "Tap to log mood for this day") : String(localized: "Tap to view or edit"))
.accessibilityAddTraits(.isButton)
}
private var accessibilityDescription: String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .full
let dateString = dateFormatter.string(from: entry.forDate)
if isMissing {
return String(localized: "\(dateString), no mood logged")
} else {
return "\(dateString), \(entry.mood.strValue)"
}
}

View File

@@ -52,8 +52,8 @@ struct FeelsSubscriptionStoreView: View {
VStack(alignment: .leading, spacing: 12) {
FeatureHighlight(icon: "calendar", text: "Month & Year Views")
FeatureHighlight(icon: "lightbulb.fill", text: "AI-Powered Insights")
FeatureHighlight(icon: "photo.fill", text: "Photos & Journal Notes")
FeatureHighlight(icon: "heart.fill", text: "Health Data Correlation")
FeatureHighlight(icon: "heart.text.square.fill", text: "Health Data Correlation")
FeatureHighlight(icon: "square.grid.2x2.fill", text: "Interactive Widgets")
}
.padding(.top, 8)
}

View File

@@ -101,26 +101,73 @@ struct InsightsView: View {
.disabled(iapManager.shouldShowPaywall)
if iapManager.shouldShowPaywall {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
showSubscriptionStore = true
// Premium insights prompt
VStack(spacing: 24) {
Spacer()
// Icon
ZStack {
Circle()
.fill(
LinearGradient(
colors: [.purple.opacity(0.2), .blue.opacity(0.2)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 100, height: 100)
Image(systemName: "sparkles")
.font(.system(size: 44))
.foregroundStyle(
LinearGradient(
colors: [.purple, .blue],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
VStack {
Spacer()
// Text
VStack(spacing: 12) {
Text("Unlock AI-Powered Insights")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(textColor)
.multilineTextAlignment(.center)
Text("Discover patterns in your mood, get personalized recommendations, and understand what affects how you feel.")
.font(.system(size: 16))
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Subscribe button
Button {
showSubscriptionStore = true
} label: {
Text(String(localized: "subscription_required_button"))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
HStack {
Image(systemName: "sparkles")
Text("Get Personal Insights")
}
.font(.system(size: 18, weight: .bold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(
LinearGradient(
colors: [.purple, .blue],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding()
.padding(.horizontal, 24)
Spacer()
}
.background(theme.currentTheme.bg)
}
}
.sheet(isPresented: $showSubscriptionStore) {

View File

@@ -43,6 +43,24 @@ struct MonthView: View {
@State private var trialWarningHidden = false
@State private var showSubscriptionStore = false
/// Filters month data to only current month when subscription/trial expired
private var filteredMonthData: [Int: [Int: [MoodEntryModel]]] {
guard iapManager.shouldShowPaywall else {
return viewModel.grouped
}
// Only show current month when paywall should show
let currentMonth = Calendar.current.component(.month, from: Date())
let currentYear = Calendar.current.component(.year, from: Date())
var filtered: [Int: [Int: [MoodEntryModel]]] = [:]
if let yearData = viewModel.grouped[currentYear],
let monthData = yearData[currentMonth] {
filtered[currentYear] = [currentMonth: monthData]
}
return filtered
}
var body: some View {
ZStack {
if viewModel.hasNoData {
@@ -51,7 +69,7 @@ struct MonthView: View {
} else {
ScrollView {
VStack(spacing: 16) {
ForEach(viewModel.grouped.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in
ForEach(filteredMonthData.sorted(by: { $0.key > $1.key }), id: \.key) { year, months in
// for each month
ForEach(months.sorted(by: { $0.key > $1.key }), id: \.key) { month, entries in
MonthCard(
@@ -90,7 +108,7 @@ struct MonthView: View {
}
)
}
.disabled(iapManager.shouldShowPaywall)
.scrollDisabled(iapManager.shouldShowPaywall)
}
// Hidden text to trigger updates when custom tint changes

View File

@@ -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 {

View File

@@ -64,27 +64,42 @@ struct YearView: View {
}
)
}
.disabled(iapManager.shouldShowPaywall)
.scrollDisabled(iapManager.shouldShowPaywall)
.mask(
// Fade effect when paywall should show: 100% at top, 0% at bottom
iapManager.shouldShowPaywall ?
AnyView(
LinearGradient(
gradient: Gradient(stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.3),
.init(color: .clear, location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
) : AnyView(Color.black)
)
}
if iapManager.shouldShowPaywall {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture {
showSubscriptionStore = true
}
VStack {
Spacer()
Button {
showSubscriptionStore = true
} label: {
Text(String(localized: "subscription_required_button"))
VStack(spacing: 16) {
Text("Subscribe to see your full year")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
.foregroundColor(textColor)
Button {
showSubscriptionStore = true
} label: {
Text(String(localized: "subscription_required_button"))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 10).fill(Color.pink))
}
}
.padding()
}