Add 22 new UI tests across 8 test files covering Home, Schedule, Progress, Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility identifiers to 11 view files for test element discovery. Fix sport chip assertion logic (all sports start selected, tap deselects), scroll container issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow. Update QA coverage map from 32 to 54 automated test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1040 lines
36 KiB
Swift
1040 lines
36 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
|
|
@State private var showSamplePoll = false
|
|
@State private var sampleTripsMessage: String?
|
|
#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 : [])
|
|
.accessibilityIdentifier("settings.appearance.\(mode.rawValue)")
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("settings.animationsToggle")
|
|
} 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)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("settings.analyticsToggle")
|
|
} 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)
|
|
.accessibilityIdentifier("settings.versionLabel")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
.accessibilityIdentifier("settings.resetButton")
|
|
}
|
|
.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)
|
|
.accessibilityIdentifier("settings.syncNowButton")
|
|
|
|
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")
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { UserDefaults.standard.bool(forKey: "marketingVideoMode") },
|
|
set: { UserDefaults.standard.set($0, forKey: "marketingVideoMode") }
|
|
)) {
|
|
Label("Marketing Video Mode", systemImage: "video.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")
|
|
}
|
|
|
|
Button {
|
|
exporter.saveSampleTrips(modelContext: modelContext)
|
|
sampleTripsMessage = "Saved 4 sample trips!"
|
|
} label: {
|
|
Label("Save 4 Sample Trips", systemImage: "suitcase.fill")
|
|
}
|
|
|
|
Button {
|
|
showSamplePoll = true
|
|
} label: {
|
|
Label("View Sample Poll", systemImage: "chart.bar.doc.horizontal.fill")
|
|
}
|
|
} header: {
|
|
Text("Debug")
|
|
} footer: {
|
|
Text("These options are only visible in debug builds.")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
.sheet(isPresented: $showExportProgress) {
|
|
DebugExportProgressView(exporter: exporter)
|
|
}
|
|
.sheet(isPresented: $showSamplePoll) {
|
|
NavigationStack {
|
|
DebugPollPreviewView()
|
|
}
|
|
}
|
|
.alert("Sample Trips", isPresented: Binding(
|
|
get: { sampleTripsMessage != nil },
|
|
set: { if !$0 { sampleTripsMessage = nil } }
|
|
)) {
|
|
Button("OK", role: .cancel) { sampleTripsMessage = nil }
|
|
} message: {
|
|
Text(sampleTripsMessage ?? "")
|
|
}
|
|
}
|
|
|
|
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)
|
|
.accessibilityIdentifier("settings.upgradeProButton")
|
|
|
|
Button {
|
|
Task {
|
|
await StoreManager.shared.restorePurchases(source: "settings")
|
|
}
|
|
} label: {
|
|
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
|
}
|
|
.accessibilityIdentifier("settings.restorePurchasesButton")
|
|
}
|
|
} 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()
|
|
}
|
|
}
|
|
}
|
|
}
|