Files
Sportstime/SportsTime/Features/Settings/Views/SettingsView.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

997 lines
34 KiB
Swift

//
// SettingsView.swift
// SportsTime
//
import SwiftUI
struct SettingsView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.modelContext) private var modelContext
@State private var viewModel = SettingsViewModel()
@State private var showResetConfirmation = false
@State private var showPaywall = false
@State private var showOnboardingPaywall = false
@State private var showSyncLogs = false
@State private var isSyncActionInProgress = false
@State private var syncActionMessage: String?
#if DEBUG
@State private var selectedSyncStatus: EntitySyncStatus?
@State private var exporter = DebugShareExporter()
@State private var showExportProgress = false
#endif
var body: some View {
List {
// Subscription
subscriptionSection
// Appearance Mode (Light/Dark/System)
appearanceSection
// Theme Selection
themeSection
// Home Screen Animations
animationsSection
// Sports Preferences
sportsSection
// Travel Preferences
travelSection
// Data Sync
syncHealthSection
// Privacy
privacySection
// Icon Generator
iconGeneratorSection
// About
aboutSection
// Reset
resetSection
#if DEBUG
// Debug
debugSection
// Sync Status
syncStatusSection
#endif
}
.scrollContentBackground(.hidden)
.themedBackground()
.alert("Reset Settings", isPresented: $showResetConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Reset", role: .destructive) {
viewModel.resetToDefaults()
}
} message: {
Text("This will reset all settings to their default values.")
}
.sheet(isPresented: $showOnboardingPaywall) {
OnboardingPaywallView(isPresented: $showOnboardingPaywall)
}
.sheet(isPresented: $showSyncLogs) {
SyncLogViewerSheet()
}
.alert("Sync Status", isPresented: Binding(
get: { syncActionMessage != nil },
set: { if !$0 { syncActionMessage = nil } }
)) {
Button("OK", role: .cancel) { syncActionMessage = nil }
} message: {
Text(syncActionMessage ?? "")
}
}
// MARK: - Appearance Section
private var appearanceSection: some View {
Section {
ForEach(AppearanceMode.allCases) { mode in
Button {
Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
AppearanceManager.shared.currentMode = mode
AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName))
}
} label: {
HStack(spacing: 12) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: mode.iconName)
.font(.body)
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName)
.font(.body)
.foregroundStyle(.primary)
Text(mode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if AppearanceManager.shared.currentMode == mode {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.title3)
.accessibilityHidden(true)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(AppearanceManager.shared.currentMode == mode ? .isSelected : [])
}
} header: {
Text("Appearance")
} footer: {
Text("Choose light, dark, or follow your device settings.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Theme Section
private var themeSection: some View {
Section {
ForEach(AppTheme.allCases) { theme in
Button {
Theme.Animation.withMotion(.easeInOut(duration: 0.2)) {
viewModel.selectedTheme = theme
}
} label: {
HStack(spacing: 12) {
// Color preview circles
HStack(spacing: -6) {
ForEach(Array(theme.previewColors.enumerated()), id: \.offset) { _, color in
Circle()
.fill(color)
.frame(width: 24, height: 24)
.overlay(
Circle()
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(theme.displayName)
.font(.body)
.foregroundStyle(.primary)
Text(theme.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if viewModel.selectedTheme == theme {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.title3)
.accessibilityHidden(true)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(viewModel.selectedTheme == theme ? .isSelected : [])
}
} header: {
Text("Theme")
} footer: {
Text("Choose a color scheme for the app.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Animations Section
private var animationsSection: some View {
Section {
Toggle(isOn: Binding(
get: { DesignStyleManager.shared.animationsEnabled },
set: {
DesignStyleManager.shared.animationsEnabled = $0
AnalyticsManager.shared.track(.animationsToggled(enabled: $0))
}
)) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Animated Background")
.font(.body)
.foregroundStyle(.primary)
Text("Show animated sports graphics on home screen")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "sparkles")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
}
} header: {
Text("Home Screen")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Sports Section
private var sportsSection: some View {
Section {
ForEach(Sport.supported) { sport in
Toggle(isOn: Binding(
get: { viewModel.selectedSports.contains(sport) },
set: { _ in viewModel.toggleSport(sport) }
)) {
Label {
Text(sport.displayName)
} icon: {
Image(systemName: sport.iconName)
.foregroundStyle(sportColor(for: sport))
}
}
}
} header: {
Text("Favorite Sports")
} footer: {
Text("Selected sports will be shown by default in schedules and trip planning.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Travel Section
private var travelSection: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Max Driving Per Day")
Spacer()
Text("\(viewModel.maxDrivingHoursPerDay) hours")
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { Double(viewModel.maxDrivingHoursPerDay) },
set: { viewModel.maxDrivingHoursPerDay = Int($0) }
),
in: 2...12,
step: 1
)
}
} header: {
Text("Travel Preferences")
} footer: {
Text("Trips will be optimized to keep daily driving within this limit.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Privacy Section
private var privacySection: some View {
Section {
Toggle(isOn: Binding(
get: { !AnalyticsManager.shared.isOptedOut },
set: { enabled in
if enabled {
AnalyticsManager.shared.optIn()
} else {
AnalyticsManager.shared.optOut()
}
}
)) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Share Analytics")
.font(.body)
.foregroundStyle(.primary)
Text("Help improve SportsTime by sharing anonymous usage data")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "chart.bar.xaxis")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
}
} header: {
Text("Privacy")
} footer: {
Text("No personal data is collected. Analytics are fully anonymous.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - About Section
private var aboutSection: some View {
Section {
HStack {
Text("Version")
Spacer()
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
.foregroundStyle(.secondary)
}
Link(destination: URL(string: "https://sportstime.88oakapps.com/privacy.html")!) {
Label {
Text("Privacy Policy")
} icon: {
Image(systemName: "hand.raised")
.accessibilityHidden(true)
}
}
Link(destination: URL(string: "https://sportstime.88oakapps.com/eula.html")!) {
Label {
Text("EULA")
} icon: {
Image(systemName: "doc.text")
.accessibilityHidden(true)
}
}
Link(destination: URL(string: "mailto:support@88oakapps.com")!) {
Label {
Text("Contact Support")
} icon: {
Image(systemName: "envelope")
.accessibilityHidden(true)
}
}
} header: {
Text("About")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Icon Generator Section
private var iconGeneratorSection: some View {
Section {
NavigationLink {
SportsIconImageGeneratorView()
.navigationTitle("Icon Generator")
.navigationBarTitleDisplayMode(.inline)
} label: {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Sports Icon Generator")
.font(.body)
.foregroundStyle(.primary)
Text("Create shareable icon images")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "photo.badge.plus")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
}
} header: {
Text("Creative Tools")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Reset Section
private var resetSection: some View {
Section {
Button(role: .destructive) {
showResetConfirmation = true
} label: {
Label {
Text("Reset to Defaults")
} icon: {
Image(systemName: "arrow.counterclockwise")
.accessibilityHidden(true)
}
}
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Sync Health Section
private var syncHealthSection: some View {
Section {
let syncState = SyncState.current(in: modelContext)
if syncState.syncInProgress || isSyncActionInProgress {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Sync in progress...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let lastSync = syncState.lastSuccessfulSync {
HStack {
Label("Last Successful Sync", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Spacer()
Text(lastSync.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
HStack {
Label("Last Successful Sync", systemImage: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
Spacer()
Text("Never")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let lastError = syncState.lastSyncError, !lastError.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Label("Last Sync Warning", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.subheadline)
Text(lastError)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !syncState.syncEnabled {
VStack(alignment: .leading, spacing: 4) {
Label("Sync Paused", systemImage: "pause.circle.fill")
.foregroundStyle(.orange)
.font(.subheadline)
if let reason = syncState.syncPausedReason {
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button {
Task {
let syncService = CanonicalSyncService()
await syncService.resumeSync(context: modelContext)
syncActionMessage = "Sync has been re-enabled."
}
} label: {
Label("Re-enable Sync", systemImage: "play.circle")
}
.disabled(isSyncActionInProgress)
}
Button {
triggerManualSync()
} label: {
Label("Sync Now", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(isSyncActionInProgress)
Button {
showSyncLogs = true
} label: {
Label("View Sync Logs", systemImage: "doc.text.magnifyingglass")
}
} header: {
Text("Data Sync")
} footer: {
Text("SportsTime loads bundled data first, then refreshes from CloudKit.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Debug Section
#if DEBUG
private var debugSection: some View {
Section {
Toggle(isOn: Binding(
get: { StoreManager.shared.debugProOverride },
set: { StoreManager.shared.debugProOverride = $0 }
)) {
Label("Override Pro Status", systemImage: "star.fill")
}
Button {
showOnboardingPaywall = true
} label: {
Label("Show Onboarding Flow", systemImage: "play.circle")
}
Button {
UserDefaults.standard.removeObject(forKey: "hasSeenOnboardingPaywall")
} label: {
Label("Reset Onboarding Flag", systemImage: "arrow.counterclockwise")
}
Button {
showExportProgress = true
Task {
await exporter.exportAll(modelContext: modelContext)
}
} label: {
Label("Export All Shareables", systemImage: "square.and.arrow.up.on.square")
}
Button {
showExportProgress = true
Task {
await exporter.exportAchievementSamples()
}
} label: {
Label("Export Achievement Samples", systemImage: "paintbrush")
}
Button {
showExportProgress = true
Task {
await exporter.exportProgressSamples()
}
} label: {
Label("Export Progress Samples", systemImage: "chart.bar.fill")
}
Button {
showExportProgress = true
Task {
await exporter.exportTripSamples()
}
} label: {
Label("Export Trip Samples", systemImage: "car.fill")
}
Button {
Task { await exporter.addAllStadiumVisits(modelContext: modelContext) }
} label: {
Label("Add All Stadium Visits", systemImage: "mappin.and.ellipse")
}
} header: {
Text("Debug")
} footer: {
Text("These options are only visible in debug builds.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
.sheet(isPresented: $showExportProgress) {
DebugExportProgressView(exporter: exporter)
}
}
private var syncStatusSection: some View {
Section {
// Check if sync is disabled
let syncState = SyncState.current(in: modelContext)
if !syncState.syncEnabled {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "pause.circle.fill")
.foregroundStyle(.orange)
Text("Sync Paused")
.font(.headline)
.foregroundStyle(.orange)
}
if let reason = syncState.syncPausedReason {
Text(reason)
.font(.caption)
.foregroundStyle(.secondary)
}
if let lastError = syncState.lastSyncError {
Text("Last error: \(lastError)")
.font(.caption)
.foregroundStyle(.red)
}
Text("Consecutive failures: \(syncState.consecutiveFailures)")
.font(.caption)
.foregroundStyle(.secondary)
}
Button {
Task {
let syncService = CanonicalSyncService()
await syncService.resumeSync(context: modelContext)
print("[SyncDebug] Sync re-enabled by user")
}
} label: {
Label("Re-enable Sync", systemImage: "arrow.clockwise")
}
.tint(.blue)
}
// Overall status header
if SyncStatusMonitor.shared.overallSyncInProgress {
HStack {
ProgressView()
.scaleEffect(0.8)
Text("Sync in progress...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else if let lastSync = SyncStatusMonitor.shared.lastFullSyncTime {
HStack {
Image(systemName: SyncStatusMonitor.shared.allSuccessful ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(SyncStatusMonitor.shared.allSuccessful ? .green : .orange)
Text("Last sync: \(lastSync.formatted(date: .omitted, time: .shortened))")
.font(.subheadline)
.foregroundStyle(.secondary)
if let duration = SyncStatusMonitor.shared.lastFullSyncDuration {
Text("(\(String(format: "%.1fs", duration)))")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
// Per-entity status rows
ForEach(SyncStatusMonitor.shared.orderedStatuses) { status in
SyncStatusRow(status: status) {
selectedSyncStatus = status
}
}
// Manual sync trigger
Button {
Task {
let syncService = CanonicalSyncService()
print("[SyncDebug] Manual sync triggered by user")
do {
let result = try await syncService.syncAll(context: modelContext)
print("[SyncDebug] Manual sync completed: \(result.totalUpdated) records")
} catch {
print("[SyncDebug] Manual sync failed: \(error)")
}
}
} label: {
Label("Trigger Sync Now", systemImage: "arrow.triangle.2.circlepath")
}
// View sync logs
Button {
showSyncLogs = true
} label: {
Label("View Sync Logs", systemImage: "doc.text.magnifyingglass")
}
} header: {
Text("Sync Status")
} footer: {
Text("Shows CloudKit sync status for each record type. Updated on app foreground.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
.sheet(item: $selectedSyncStatus) { status in
SyncStatusDetailSheet(status: status)
}
}
#endif
// MARK: - Subscription Section
private var subscriptionSection: some View {
Section {
if StoreManager.shared.isPro {
// Pro user - show manage option
HStack {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("SportsTime Pro")
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Active subscription")
.font(.caption)
.foregroundStyle(.green)
}
} icon: {
Image(systemName: "star.fill")
.foregroundStyle(Theme.warmOrange)
}
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityHidden(true)
}
Button {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
}
} label: {
Label("Manage Subscription", systemImage: "gear")
}
} else {
// Free user - show upgrade option
Button {
showPaywall = true
} label: {
HStack {
Label {
VStack(alignment: .leading, spacing: 4) {
Text("Upgrade to Pro")
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Unlimited trips, PDF export, progress tracking")
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
} icon: {
Image(systemName: "star.fill")
.foregroundStyle(Theme.warmOrange)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
}
.buttonStyle(.plain)
Button {
Task {
await StoreManager.shared.restorePurchases(source: "settings")
}
} label: {
Label("Restore Purchases", systemImage: "arrow.clockwise")
}
}
} header: {
Text("Subscription")
}
.listRowBackground(Theme.cardBackground(colorScheme))
.sheet(isPresented: $showPaywall) {
PaywallView(source: "settings")
}
}
// MARK: - Helpers
private func triggerManualSync() {
guard !isSyncActionInProgress else { return }
isSyncActionInProgress = true
AccessibilityAnnouncer.announce("Manual sync started.")
Task {
defer { isSyncActionInProgress = false }
do {
let result = try await BackgroundSyncManager.shared.triggerManualSync()
syncActionMessage = "Sync complete. Updated \(result.totalUpdated) records."
AccessibilityAnnouncer.announce(syncActionMessage ?? "")
} catch {
syncActionMessage = "Sync failed: \(error.localizedDescription)"
AccessibilityAnnouncer.announce(syncActionMessage ?? "")
}
}
}
private func sportColor(for sport: Sport) -> Color {
sport.themeColor
}
}
#Preview {
NavigationStack {
SettingsView()
}
}
// MARK: - Debug Sync Status Views
#if DEBUG
/// A row showing sync status for a single entity type
struct SyncStatusRow: View {
let status: EntitySyncStatus
let onInfoTapped: () -> Void
var body: some View {
HStack(spacing: 12) {
// Status indicator
Image(systemName: statusIcon)
.foregroundStyle(statusColor)
.font(.subheadline)
.frame(width: 20)
// Entity icon and name
Image(systemName: status.entityType.iconName)
.foregroundStyle(.secondary)
.font(.subheadline)
.frame(width: 20)
Text(status.entityType.rawValue)
.font(.body)
Spacer()
// Record count (if synced)
if status.lastSyncTime != nil {
Text("\(status.recordCount)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.1))
.clipShape(Capsule())
}
// Info button
Button {
onInfoTapped()
} label: {
Image(systemName: "info.circle")
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
.accessibilityLabel("View sync details")
}
}
private var statusIcon: String {
guard status.lastSyncTime != nil else {
return "circle.dotted"
}
return status.isSuccess ? "checkmark.circle.fill" : "xmark.circle.fill"
}
private var statusColor: Color {
guard status.lastSyncTime != nil else {
return .secondary
}
return status.isSuccess ? .green : .red
}
}
/// Detail sheet showing full sync information for an entity
struct SyncStatusDetailSheet: View {
let status: EntitySyncStatus
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
DetailRow(label: "Entity Type", value: status.entityType.rawValue)
DetailRow(label: "Status", value: status.isSuccess ? "Success" : "Failed", valueColor: status.isSuccess ? .green : .red)
}
Section {
if let syncTime = status.lastSyncTime {
DetailRow(label: "Last Sync", value: syncTime.formatted(date: .abbreviated, time: .standard))
} else {
DetailRow(label: "Last Sync", value: "Never")
}
DetailRow(label: "Records Synced", value: "\(status.recordCount)")
if let duration = status.duration {
DetailRow(label: "Duration", value: String(format: "%.2f seconds", duration))
}
}
if let errorMessage = status.errorMessage {
Section("Error") {
Text(errorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
}
.navigationTitle(status.entityType.rawValue)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium])
}
}
/// Helper view for detail rows
private struct DetailRow: View {
let label: String
let value: String
var valueColor: Color = .primary
var body: some View {
HStack {
Text(label)
.foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(valueColor)
}
}
}
#endif
/// Sheet to view sync logs
struct SyncLogViewerSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var logContent = ""
@State private var autoScroll = true
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ScrollView {
Text(logContent)
.font(.system(.caption, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.id("logBottom")
}
.onChange(of: logContent) {
if autoScroll {
withAnimation {
proxy.scrollTo("logBottom", anchor: .bottom)
}
}
}
}
.navigationTitle("Sync Logs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Clear") {
SyncLogger.shared.clearLog()
logContent = "Log cleared."
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
}
ToolbarItem(placement: .bottomBar) {
HStack {
Button {
logContent = SyncLogger.shared.readLog()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
Spacer()
Toggle("Auto-scroll", isOn: $autoScroll)
.toggleStyle(.switch)
.labelsHidden()
Text("Auto-scroll")
.font(.caption)
}
}
}
.onAppear {
logContent = SyncLogger.shared.readLog()
}
}
}
}