summaryrefslogtreecommitdiff
path: root/DomainDig/SettingsViews.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/SettingsViews.swift')
-rw-r--r--DomainDig/SettingsViews.swift1149
1 files changed, 1149 insertions, 0 deletions
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 <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()
+ }
+ }
+ }
+ }
+ }
+ }
+}