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/ContentView.swift | 1143 ---------------------------------------- DomainDig/SettingsViews.swift | 1149 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1149 insertions(+), 1143 deletions(-) create mode 100644 DomainDig/SettingsViews.swift (limited to 'DomainDig') 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 ` 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 { 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