diff options
| author | Christian Cleberg <[email protected]> | 2026-07-25 00:15:15 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-25 00:16:00 -0500 |
| commit | 48999737a37ef16cad0d84e71fa1b229bb04903f (patch) | |
| tree | 2d1c1da063aa8af50b4f2a94c600deeffb8832d3 /DomainDig/ContentView.swift | |
| parent | 321a10f85435fdcbfb54f109ee53df895a550a42 (diff) | |
| download | domain-dig-48999737a37ef16cad0d84e71fa1b229bb04903f.tar.gz domain-dig-48999737a37ef16cad0d84e71fa1b229bb04903f.tar.bz2 domain-dig-48999737a37ef16cad0d84e71fa1b229bb04903f.zip | |
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).
Diffstat (limited to 'DomainDig/ContentView.swift')
| -rw-r--r-- | DomainDig/ContentView.swift | 1143 |
1 files changed, 0 insertions, 1143 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 894fc88..3b7e759 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2703,1149 +2703,6 @@ private extension String { } } -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 <token>` 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() - } - } - } - } - } - } -} - extension ChangeImpactClassification { var tone: AppStatusTone { switch self { |
