Replace all system colors (.secondary, Color(.secondarySystemBackground), etc.) with Theme.textPrimary/textSecondary/textMuted/cardBackground/ surfaceGlow across 13 views. Remove PostHog debug logging. Add debug settings for sample trips and hardcoded group poll preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1025 lines
35 KiB
Swift
1025 lines
35 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 : [])
|
|
}
|
|
} 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")
|
|
}
|
|
|
|
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)
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|