Files
Sportstime/SportsTime/Features/Settings/Views/SettingsView.swift
Trey t dc142bd14b feat: expand XCUITest coverage to 54 QA scenarios with accessibility IDs and fix test failures
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>
2026-02-16 19:44:22 -06:00

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()
}
}
}
}