From 48999737a37ef16cad0d84e71fa1b229bb04903f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 25 Jul 2026 00:15:15 -0500 Subject: refactor: extract Settings screens into SettingsViews.swift (v5 step 4, 6/n) Pivots the god-file decomposition to ContentView.swift (the other ~3.9k-line file). Unlike DomainViewModel, this is a set of independent SwiftUI View structs, so it splits with no shared-state entanglement. - Moves the Settings surface into SettingsViews.swift: SettingsView (the tab root, presented from RootTabView) plus its nine file-private section screens (Display, History & Network, iCloud Sync, Local API, Monitoring, Import & Export, Data Management, About) and the DataImportPreviewSheet. Pure move. - The private section screens stay file-private together in the new file, reached only through SettingsView's navigation links. Zero visibility changes were needed: the block references nothing file-private to ContentView.swift, so the ContentView diff is pure deletion. ContentView.swift: 3881 -> 2739 lines. App builds clean; unit suite 58/58. No project.pbxproj change (DomainDig/ is a synchronized group). --- DomainDig/SettingsViews.swift | 1149 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1149 insertions(+) create mode 100644 DomainDig/SettingsViews.swift (limited to 'DomainDig/SettingsViews.swift') diff --git a/DomainDig/SettingsViews.swift b/DomainDig/SettingsViews.swift new file mode 100644 index 0000000..a39639d --- /dev/null +++ b/DomainDig/SettingsViews.swift @@ -0,0 +1,1149 @@ +import SwiftUI +import UniformTypeIdentifiers + +// Settings screens extracted from ContentView.swift. `SettingsView` is the +// Settings tab root (presented from RootTabView); the per-section screens are +// file-private, reached only through its navigation links. + +struct SettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + @State private var purchaseService = PurchaseService.shared + + var body: some View { + let _ = purchaseService.currentTier + + List { + Section("Tier") { + LabeledContent("Status", value: purchaseService.currentTier.title) + + if purchaseService.currentTier == .free { + Button("Upgrade") { + viewModel.isPaywallPresented = true + } + } else { + Button("Manage Subscription") { + Task { + await purchaseService.manageSubscription() + } + } + } + + Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { + Task { + await purchaseService.restorePurchases() + } + } + .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) + + if let statusMessage = purchaseService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + + if let errorMessage = purchaseService.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.statusCritical)) + } + } + + Section("Preferences") { + NavigationLink("Tracked Domains") { + WatchlistView(viewModel: viewModel) + } + + NavigationLink("Workflows") { + WorkflowsView(viewModel: viewModel) + } + + NavigationLink("Display") { + DisplaySettingsView() + } + + NavigationLink("History & Network") { + HistoryNetworkSettingsView(viewModel: viewModel) + } + } + + Section("Services") { + NavigationLink("Monitoring Activity") { + MonitoringView(viewModel: viewModel) + } + + NavigationLink("Integrations") { + IntegrationsSettingsView() + } + + NavigationLink("Local API") { + LocalAPISettingsView() + } + + NavigationLink("iCloud Sync") { + CloudSyncSettingsView() + } + + NavigationLink("Monitoring") { + MonitoringSettingsView(viewModel: viewModel) + } + + NavigationLink("Scheduled Reports") { + ScheduledReportsView() + } + } + + Section("Data") { + NavigationLink("Import & Export") { + DataPortabilitySettingsView(viewModel: viewModel) + } + + NavigationLink("Data Management") { + DataManagementSettingsView(viewModel: viewModel) + } + } + + Section("About") { + NavigationLink("App Info") { + AboutSettingsView() + } + } + } + .navigationTitle("Settings") + } +} + +private struct DisplaySettingsView: View { + @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue + @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue + + var body: some View { + Form { + Section("Display") { + Picker("Appearance", selection: $storedAppearance) { + ForEach(AppAppearance.allCases) { appearance in + Text(appearance.title).tag(appearance.rawValue) + } + } + + Picker("Density", selection: $storedDensity) { + ForEach(AppDensity.allCases) { density in + Text(density.title).tag(density.rawValue) + } + } + } + } + .navigationTitle("Display") + } +} + +private struct HistoryNetworkSettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString + @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue + + @State private var resolverOption: DNSResolverOption = .cloudflare + @State private var customResolverURL = DNSResolverOption.defaultURLString + + private var customResolverError: String? { + guard resolverOption == .custom else { return nil } + return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" + } + + var body: some View { + Form { + Section("History") { + Picker( + "Auto-prune", + selection: Binding( + get: { viewModel.historyAutoPruneOption }, + set: { viewModel.setHistoryAutoPruneOption($0) } + ) + ) { + ForEach(HistoryAutoPruneOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + + Section("Network") { + Picker("Resolver", selection: $resolverOption) { + ForEach(DNSResolverOption.allCases) { option in + Text(option.title).tag(option) + } + } + + if resolverOption == .custom { + TextField("https://resolver.example/dns-query", text: $customResolverURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + + if let customResolverError { + Text(customResolverError) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.statusCritical)) + } + } + } + } + .navigationTitle("History & Network") + .onAppear { + let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) + resolverOption = DNSResolverOption.option(for: currentResolverURL) + customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString + } + .onChange(of: resolverOption) { _, newValue in + guard let presetURL = newValue.urlString else { + storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + return + } + storedResolverURL = presetURL + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + } + .onChange(of: customResolverURL) { _, newValue in + guard resolverOption == .custom else { return } + storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: storedDensity + ) + } + .onChange(of: storedDensity) { _, newValue in + viewModel.persistCurrentAppSettings( + resolverURLString: storedResolverURL, + appDensityRawValue: newValue + ) + } + } +} + +private struct CloudSyncSettingsView: View { + @Environment(\.appDensity) private var appDensity + @State private var cloudSyncService = CloudSyncService.shared + + var body: some View { + Form { + Section("iCloud Sync") { + Toggle( + "Enable iCloud Sync", + isOn: Binding( + get: { cloudSyncService.isEnabled }, + set: { cloudSyncService.setSyncEnabled($0) } + ) + ) + + LabeledContent("Status", value: cloudSyncService.status.title) + LabeledContent( + "Last Sync", + value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced" + ) + + Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") { + Task { + await cloudSyncService.syncNow(trigger: .manual) + } + } + .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing) + + Text("iCloud Sync stores DomainDig data in your private iCloud account. DomainDig does not operate a sync server. Disabling sync keeps local data on this device.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + + Text(cloudSyncService.detailMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + + if let lastErrorMessage = cloudSyncService.lastErrorMessage { + Text(lastErrorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.statusCritical)) + } + } + } + .navigationTitle("iCloud Sync") + .task { + await cloudSyncService.refreshAvailability() + } + } +} + +private struct LocalAPISettingsView: View { + @Environment(\.appDensity) private var appDensity + @State private var localAPIService = LocalAPIService.shared + @State private var portText = "" + + private var statusText: String { + if localAPIService.isRunning { return "Running" } + return localAPIService.config.isEnabled ? "Stopped" : "Disabled" + } + + var body: some View { + Form { + Section("Local API") { + Toggle( + "Enable Local API", + isOn: Binding( + get: { localAPIService.config.isEnabled }, + set: { localAPIService.setEnabled($0) } + ) + ) + + TextField( + "Port", + text: Binding( + get: { portText }, + set: { newValue in + portText = newValue + if let port = Int(newValue) { + localAPIService.setPort(port) + } + } + ) + ) + .keyboardType(.numberPad) + + LabeledContent("Address", value: localAPIService.address) + LabeledContent("Status", value: statusText) + LabeledContent("Token", value: localAPIService.maskedToken) + + if let statusMessage = localAPIService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + + Section("Authentication") { + Button("Copy Token") { + localAPIService.copyToken() + } + + Button("Copy cURL Command") { + localAPIService.copyCurlCommand() + } + + Button("Rotate Token") { + localAPIService.rotateToken() + } + + Text("Every request requires either `Authorization: Bearer ` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + + Section("Request Logging") { + Toggle( + "Log Requests", + isOn: Binding( + get: { localAPIService.config.requestLoggingEnabled }, + set: { localAPIService.setRequestLoggingEnabled($0) } + ) + ) + + if localAPIService.requestLogs.isEmpty { + Text("No local API requests logged yet.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } else { + ForEach(localAPIService.requestLogs.prefix(25)) { log in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("\(log.method) \(log.path)") + .font(appDensity.font(.callout, design: .monospaced)) + Spacer() + Text("\(log.statusCode)") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary) + } + + Text(log.timestamp.formatted(date: .abbreviated, time: .standard)) + .font(appDensity.font(.caption2, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + + Text("\(Int(log.duration * 1000)) ms") + .font(appDensity.font(.caption2, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + + Button("Clear Logs", role: .destructive) { + localAPIService.clearRequestLogs() + } + } + + Section("Control") { + Button("Restart Server") { + localAPIService.setEnabled(false) + localAPIService.setEnabled(true) + } + .disabled(!localAPIService.config.isEnabled) + + Button("Stop Server") { + localAPIService.stopServer() + } + .disabled(!localAPIService.isRunning) + } + } + .navigationTitle("Local API") + .onAppear { + portText = String(localAPIService.config.port) + localAPIService.refresh() + } + } +} + +private struct MonitoringSettingsView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + private var notificationAuthorizationLabel: String { + switch viewModel.monitoringNotificationStatus { + case .authorized, .provisional, .ephemeral: + return "Allowed" + case .denied: + return "Denied" + case .notDetermined: + return "Not Requested" + @unknown default: + return "Unknown" + } + } + + var body: some View { + Form { + Section("Monitoring") { + Toggle( + "Enable Background Monitoring", + isOn: Binding( + get: { viewModel.monitoringSettings.isEnabled }, + set: { viewModel.setMonitoringEnabled($0) } + ) + ) + + Picker( + "Base Interval", + selection: Binding( + get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) }, + set: { viewModel.setMonitoringBaseInterval($0) } + ) + ) { + ForEach(MonitoringBaseInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + + Toggle( + "Adaptive Monitoring", + isOn: Binding( + get: { viewModel.monitoringSettings.adaptiveEnabled }, + set: { viewModel.setMonitoringAdaptiveEnabled($0) } + ) + ) + + Picker( + "Sensitivity", + selection: Binding( + get: { viewModel.monitoringSettings.sensitivity }, + set: { viewModel.setMonitoringSensitivity($0) } + ) + ) { + ForEach(MonitoringSensitivity.allCases) { sensitivity in + Text(sensitivity.title).tag(sensitivity) + } + } + + let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22 + let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7 + Toggle( + "Quiet Hours", + isOn: Binding( + get: { viewModel.monitoringSettings.quietHours != nil }, + set: { isEnabled in + viewModel.setMonitoringQuietHours( + startHour: quietHoursStart, + endHour: quietHoursEnd, + isEnabled: isEnabled + ) + } + ) + ) + + if viewModel.monitoringSettings.quietHours != nil { + Picker( + "Quiet Starts", + selection: Binding( + get: { quietHoursStart }, + set: { startHour in + viewModel.setMonitoringQuietHours( + startHour: startHour, + endHour: quietHoursEnd, + isEnabled: true + ) + } + ) + ) { + ForEach(0..<24, id: \.self) { hour in + Text(Self.monitoringHourLabel(for: hour)).tag(hour) + } + } + + Picker( + "Quiet Ends", + selection: Binding( + get: { quietHoursEnd }, + set: { endHour in + viewModel.setMonitoringQuietHours( + startHour: quietHoursStart, + endHour: endHour, + isEnabled: true + ) + } + ) + ) { + ForEach(0..<24, id: \.self) { hour in + Text(Self.monitoringHourLabel(for: hour)).tag(hour) + } + } + } + + Picker( + "Domains", + selection: Binding( + get: { viewModel.monitoringSettings.scope }, + set: { viewModel.setMonitoringScope($0) } + ) + ) { + ForEach(MonitoringScope.allCases) { scope in + Text(scope.title).tag(scope) + } + } + + if viewModel.monitoringSettings.scope == .selectedOnly { + ForEach(viewModel.trackedDomains) { trackedDomain in + Toggle( + trackedDomain.domain, + isOn: Binding( + get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) }, + set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) } + ) + ) + } + } + + Toggle( + "Local Alerts", + isOn: Binding( + get: { viewModel.monitoringSettings.alertsEnabled }, + set: { isEnabled in + if isEnabled { + Task { + await viewModel.requestMonitoringNotificationAuthorization() + } + } else { + viewModel.setMonitoringAlertsEnabled(false) + } + } + ) + ) + + Picker( + "Notify For", + selection: Binding( + get: { viewModel.monitoringSettings.alertFilter }, + set: { viewModel.setMonitoringAlertFilter($0) } + ) + ) { + ForEach(MonitoringAlertFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + + LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription()) + LabeledContent("Notification Access", value: notificationAuthorizationLabel) + + if let monitoringStatusMessage = viewModel.monitoringStatusMessage { + Text(monitoringStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + + if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { + Text("Background monitoring and alerts are available in Pro.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + .navigationTitle("Monitoring") + .onAppear { + viewModel.refreshMonitoringState() + Task { + await viewModel.refreshMonitoringAuthorizationStatus() + } + } + } + + private static func monitoringHourLabel(for hour: Int) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h a" + let components = DateComponents(calendar: .current, hour: hour) + return components.date.map(formatter.string(from:)) ?? "\(hour):00" + } +} + +private struct DataPortabilitySettingsView: View { + private enum ImportTarget { + case backup + case trackedDomains + case workflows + + var expectedKind: DataPortabilityImportKind { + switch self { + case .backup: + return .backup + case .trackedDomains: + return .trackedDomains + case .workflows: + return .workflows + } + } + + var allowedContentTypes: [UTType] { + switch self { + case .backup: + return [UTType.json] + case .trackedDomains, .workflows: + return [UTType.json, UTType.commaSeparatedText] + } + } + } + + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + + @State private var importMode: DataPortabilityImportMode = .merge + @State private var activeImportTarget: ImportTarget? + @State private var pendingImportTarget: ImportTarget? + @State private var importDebugStatus: String? + @State private var pendingImportPreview: DataImportPreview? + @State private var pendingImportError: String? + @State private var showReplaceImportConfirmation = false + + var body: some View { + Form { + Section("Import & Export") { + Picker("Import Mode", selection: $importMode) { + ForEach(DataPortabilityImportMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + + Text(importMode.explanation) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + + Button("Export Full Backup") { + exportFullBackup() + } + + Button("Import Backup") { + recordImportDebugStatus("Tapped Import Backup") + pendingImportTarget = .backup + activeImportTarget = .backup + } + + Menu("Export Tracked Domains") { + Button("JSON") { + exportPortableTrackedDomainsJSON() + } + Button("CSV") { + exportPortableTrackedDomainsCSV() + } + } + + Button("Import Tracked Domains") { + recordImportDebugStatus("Tapped Import Tracked Domains") + pendingImportTarget = .trackedDomains + activeImportTarget = .trackedDomains + } + + Menu("Export Workflows") { + Button("JSON") { + exportPortableWorkflowsJSON() + } + Button("CSV") { + exportPortableWorkflowsCSV() + } + } + + Button("Import Workflows") { + recordImportDebugStatus("Tapped Import Workflows") + pendingImportTarget = .workflows + activeImportTarget = .workflows + } + + Button("Export History") { + exportPortableHistoryJSON() + } + } + + Section("Local Data") { + LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") + LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)") + LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") + LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") + + Text("Data stays on this device unless you export it. Backup files can include domain history, monitoring settings, and notes. Imported files are processed on-device.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + + if let portabilityStatusMessage = viewModel.portabilityStatusMessage { + Text(portabilityStatusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + } + } + + #if DEBUG + if let importDebugStatus { + Section("Import Debug") { + Text(importDebugStatus) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(Color(.appTextSecondary)) + .textSelection(.enabled) + } + } + #endif + } + .navigationTitle("Import & Export") + .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) { + Button("Replace", role: .destructive) { + applyPendingImport() + } + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.") + } + .alert("Import Error", isPresented: Binding( + get: { pendingImportError != nil }, + set: { if !$0 { pendingImportError = nil } } + )) { + Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text(pendingImportError ?? "The import could not be completed.") + } + .sheet(isPresented: Binding( + get: { pendingImportPreview != nil }, + set: { if !$0 { pendingImportPreview = nil } } + )) { + if let pendingImportPreview { + DataImportPreviewSheet( + preview: pendingImportPreview, + mode: importMode, + onCancel: { + self.pendingImportPreview = nil + }, + onApply: { + if importMode == .replace { + showReplaceImportConfirmation = true + } else { + applyPendingImport() + } + } + ) + } + } + .fileImporter( + isPresented: Binding( + get: { activeImportTarget != nil }, + set: { if !$0 { activeImportTarget = nil } } + ), + allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json], + allowsMultipleSelection: false + ) { result in + guard let pendingImportTarget else { + recordImportDebugStatus("fileImporter returned with no active target") + return + } + recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)") + handleImportResult(result, expectedKind: pendingImportTarget.expectedKind) + self.pendingImportTarget = nil + self.activeImportTarget = nil + } + .onAppear { + viewModel.refreshDataLifecycleSummary() + } + } + + private func exportFullBackup() { + guard let data = viewModel.exportFullBackupData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsJSON() { + guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data) + } + + private func exportPortableTrackedDomainsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"), + contents: viewModel.exportPortableTrackedDomainsCSV() + ) + } + + private func exportPortableWorkflowsJSON() { + guard let data = viewModel.exportPortableWorkflowsJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data) + } + + private func exportPortableWorkflowsCSV() { + ExportPresenter.share( + filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"), + contents: viewModel.exportPortableWorkflowsCSV() + ) + } + + private func exportPortableHistoryJSON() { + guard let data = viewModel.exportPortableHistoryJSONData() else { return } + ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data) + } + + private func handleImportResult( + _ result: Result<[URL], Error>, + expectedKind: DataPortabilityImportKind + ) { + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)") + recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)") + do { + let urls = try result.get() + guard let url = urls.first else { + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned") + recordImportDebugStatus("No URL returned from picker") + return + } + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)") + recordImportDebugStatus("Selected \(url.lastPathComponent)") + let shouldStopAccessing = url.startAccessingSecurityScopedResource() + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)") + recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)") + defer { + if shouldStopAccessing { + url.stopAccessingSecurityScopedResource() + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased") + } + } + + let data = try Data(contentsOf: url) + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)") + recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)") + let preview = try viewModel.prepareDataImport( + data: data, + fileName: url.lastPathComponent, + mode: importMode + ) + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)") + recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)") + + guard preview.kind == expectedKind else { + let message = preview.kind == .backup + ? "That file is a full backup. Use Import Backup." + : "That file type does not match this import action." + DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)") + recordImportDebugStatus("Kind mismatch: \(message)") + presentImportError(message) + return + } + + DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)") + recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)") + presentImportPreview(preview) + } catch { + DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)") + recordImportDebugStatus("Import failed: \(error.localizedDescription)") + presentImportError(error.localizedDescription) + } + } + + private func applyPendingImport() { + guard let pendingImportPreview else { return } + do { + _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode) + self.pendingImportPreview = nil + } catch { + pendingImportError = error.localizedDescription + } + } + + private func portabilityFilename(suffix: String, fileExtension: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)" + } + + private func presentImportPreview(_ preview: DataImportPreview) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)") + recordImportDebugStatus("Preview presented for \(preview.fileName)") + pendingImportPreview = preview + } + } + + private func presentImportError(_ message: String) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(300)) + DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)") + recordImportDebugStatus("Error presented: \(message)") + pendingImportError = message + } + } + + private func recordImportDebugStatus(_ message: String) { + #if DEBUG + let status = "[Import Debug] \(message)" + importDebugStatus = status + print(status) + #endif + } +} + +private struct DataManagementSettingsView: View { + @Bindable var viewModel: DomainViewModel + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + @State private var showClearHistoryConfirmation = false + @State private var showClearCacheConfirmation = false + @State private var showClearWorkflowsConfirmation = false + @State private var showClearTrackedDomainsConfirmation = false + @State private var showDeleteAllConfirmation = false + @State private var deleteAllErrorMessage: String? + @State private var deleteAllSuccessMessage: String? + @State private var isDeletingAllData = false + + var body: some View { + Form { + Section("Data") { + Button("Clear History", role: .destructive) { + showClearHistoryConfirmation = true + } + + Button("Clear Cache", role: .destructive) { + showClearCacheConfirmation = true + } + + Button("Clear Workflows", role: .destructive) { + showClearWorkflowsConfirmation = true + } + + Button("Clear Tracked Domains", role: .destructive) { + showClearTrackedDomainsConfirmation = true + } + } + + Section { + Button(role: .destructive) { + showDeleteAllConfirmation = true + } label: { + HStack { + Text("Delete All Data") + Spacer() + if isDeletingAllData { + ProgressView() + .controlSize(.small) + } + } + } + .disabled(isDeletingAllData) + } header: { + Text("Danger Zone") + } footer: { + Text("Permanently removes all local DomainDig data from this device.") + } + } + .disabled(isDeletingAllData) + .navigationTitle("Data Management") + .alert("Clear history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearHistory() + } + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text("This removes saved lookup snapshots and clears monitoring run history on this device.") + } + .alert("Clear cache?", isPresented: $showClearCacheConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearLookupCache() + } + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") + } + .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearWorkflows() + } + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") + } + .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearTrackedDomains() + } + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.") + } + .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) { + Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + Button("Delete All Data", role: .destructive) { + deleteAllData() + } + } message: { + Text("This will permanently remove all saved DomainDig data from this device. This includes tracked domains, monitoring history, snapshots, exports, cached reports, and local settings. This action cannot be undone.") + } + .alert("Delete Failed", isPresented: Binding( + get: { deleteAllErrorMessage != nil }, + set: { if !$0 { deleteAllErrorMessage = nil } } + )) { + Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ } + } message: { + Text(deleteAllErrorMessage ?? "The local data reset could not be completed.") + } + .safeAreaInset(edge: .bottom) { + if let deleteAllSuccessMessage { + Text(deleteAllSuccessMessage) + .font(.footnote.weight(.medium)) + .foregroundStyle(Color(.appTextSecondary)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + // Reduce Transparency swaps the blur for an opaque surface. + // On iOS 26+ the system also composites its own translucency + // that the app cannot declare — verify there too (Phase 6). + .background( + Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial)) + ) + .padding(.bottom, 8) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + + private func deleteAllData() { + guard !isDeletingAllData else { return } + + isDeletingAllData = true + deleteAllErrorMessage = nil + deleteAllSuccessMessage = nil + + Task { + do { + try await DataResetService.wipeAllLocalData(viewModel: viewModel) + deleteAllSuccessMessage = "All local data removed." + try? await Task.sleep(for: .seconds(2)) + if deleteAllSuccessMessage == "All local data removed." { + deleteAllSuccessMessage = nil + } + } catch { + deleteAllErrorMessage = error.localizedDescription + } + + isDeletingAllData = false + } + } +} + +private struct AboutSettingsView: View { + @State private var cloudSyncService = CloudSyncService.shared + + private var appVersion: String { + AppVersion.current + } + + var body: some View { + Form { + Section("About") { + LabeledContent("Version", value: appVersion) + LabeledContent("Storage", value: cloudSyncService.isEnabled ? "Local-first + iCloud" : "Local-only") + LabeledContent("Backup Schema", value: "v\(DomainDigBackup.currentSchemaVersion)") + } + } + .navigationTitle("App Info") + .task { + await cloudSyncService.refreshAvailability() + } + } +} + +private struct DataImportPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + + let preview: DataImportPreview + let mode: DataPortabilityImportMode + let onCancel: () -> Void + let onApply: () -> Void + + var body: some View { + NavigationStack { + List { + Section("Summary") { + ForEach(preview.summaryLines, id: \.self) { line in + Text(line) + } + } + + Section("Projected Counts") { + LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") + LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)") + LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") + LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") + LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") + } + + if !preview.warnings.isEmpty { + Section("Warnings") { + ForEach(preview.warnings, id: \.self) { warning in + Text(warning) + .foregroundStyle(Color(.appTextSecondary)) + } + } + } + } + .navigationTitle("Import Preview") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + onCancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(mode == .replace ? "Replace" : "Import") { + onApply() + if mode == .merge { + dismiss() + } + } + } + } + } + } +} -- cgit v1.2.3