krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.2: DomainDig/SettingsViews.swift · raw
1import SwiftUI
2import UniformTypeIdentifiers
3
4// Settings screens extracted from ContentView.swift. `SettingsView` is the
5// Settings tab root (presented from RootTabView); the per-section screens are
6// file-private, reached only through its navigation links.
7
8struct SettingsView: View {
9 @Environment(\.appDensity) private var appDensity
10 @Bindable var viewModel: DomainViewModel
11 @State private var purchaseService = PurchaseService.shared
12
13 var body: some View {
14 let _ = purchaseService.currentTier
15
16 List {
17 Section("Tier") {
18 LabeledContent("Status", value: purchaseService.currentTier.title)
19
20 if purchaseService.currentTier == .free {
21 Button("Upgrade") {
22 viewModel.isPaywallPresented = true
23 }
24 } else {
25 Button("Manage Subscription") {
26 Task {
27 await purchaseService.manageSubscription()
28 }
29 }
30 }
31
32 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
33 Task {
34 await purchaseService.restorePurchases()
35 }
36 }
37 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
38
39 if let statusMessage = purchaseService.statusMessage {
40 Text(statusMessage)
41 .font(appDensity.font(.caption, design: .default))
42 .foregroundStyle(Color(.appTextSecondary))
43 }
44
45 if let errorMessage = purchaseService.errorMessage {
46 Text(errorMessage)
47 .font(appDensity.font(.caption, design: .default))
48 .foregroundStyle(Color(.statusCritical))
49 }
50 }
51
52 Section("Preferences") {
53 NavigationLink("Tracked Domains") {
54 WatchlistView(viewModel: viewModel)
55 }
56
57 NavigationLink("Workflows") {
58 WorkflowsView(viewModel: viewModel)
59 }
60
61 NavigationLink("Display") {
62 DisplaySettingsView()
63 }
64
65 NavigationLink("History & Network") {
66 HistoryNetworkSettingsView(viewModel: viewModel)
67 }
68 }
69
70 Section("Services") {
71 NavigationLink("Monitoring Activity") {
72 MonitoringView(viewModel: viewModel)
73 }
74
75 NavigationLink("Integrations") {
76 IntegrationsSettingsView()
77 }
78
79 NavigationLink("Local API") {
80 LocalAPISettingsView()
81 }
82
83 NavigationLink("iCloud Sync") {
84 CloudSyncSettingsView()
85 }
86
87 NavigationLink("Monitoring") {
88 MonitoringSettingsView(viewModel: viewModel)
89 }
90
91 NavigationLink("Scheduled Reports") {
92 ScheduledReportsView()
93 }
94 }
95
96 Section("Data") {
97 NavigationLink("Import & Export") {
98 DataPortabilitySettingsView(viewModel: viewModel)
99 }
100
101 NavigationLink("Data Management") {
102 DataManagementSettingsView(viewModel: viewModel)
103 }
104 }
105
106 Section("About") {
107 NavigationLink("App Info") {
108 AppInfoView()
109 }
110 }
111 }
112 .navigationTitle("Settings")
113 }
114}
115
116private struct DisplaySettingsView: View {
117 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
118 @AppStorage(AppAppearance.userDefaultsKey) private var storedAppearance = AppAppearance.system.rawValue
119
120 var body: some View {
121 Form {
122 Section("Display") {
123 Picker("Appearance", selection: $storedAppearance) {
124 ForEach(AppAppearance.allCases) { appearance in
125 Text(appearance.title).tag(appearance.rawValue)
126 }
127 }
128
129 Picker("Density", selection: $storedDensity) {
130 ForEach(AppDensity.allCases) { density in
131 Text(density.title).tag(density.rawValue)
132 }
133 }
134 }
135 }
136 .navigationTitle("Display")
137 }
138}
139
140private struct HistoryNetworkSettingsView: View {
141 @Environment(\.appDensity) private var appDensity
142 @Bindable var viewModel: DomainViewModel
143 @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString
144 @AppStorage(AppDensity.userDefaultsKey) private var storedDensity = AppDensity.compact.rawValue
145
146 @State private var resolverOption: DNSResolverOption = .cloudflare
147 @State private var customResolverURL = DNSResolverOption.defaultURLString
148
149 private var customResolverError: String? {
150 guard resolverOption == .custom else { return nil }
151 return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://"
152 }
153
154 var body: some View {
155 Form {
156 Section("History") {
157 Picker(
158 "Auto-prune",
159 selection: Binding(
160 get: { viewModel.historyAutoPruneOption },
161 set: { viewModel.setHistoryAutoPruneOption($0) }
162 )
163 ) {
164 ForEach(HistoryAutoPruneOption.allCases) { option in
165 Text(option.title).tag(option)
166 }
167 }
168
169 Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.")
170 .font(appDensity.font(.caption, design: .default))
171 .foregroundStyle(Color(.appTextSecondary))
172 }
173
174 Section("Network") {
175 Picker("Resolver", selection: $resolverOption) {
176 ForEach(DNSResolverOption.allCases) { option in
177 Text(option.title).tag(option)
178 }
179 }
180
181 if resolverOption == .custom {
182 TextField("https://resolver.example/dns-query", text: $customResolverURL)
183 .textInputAutocapitalization(.never)
184 .autocorrectionDisabled()
185 .keyboardType(.URL)
186
187 if let customResolverError {
188 Text(customResolverError)
189 .font(appDensity.font(.caption, design: .default))
190 .foregroundStyle(Color(.statusCritical))
191 }
192 }
193 }
194 }
195 .navigationTitle("History & Network")
196 .onAppear {
197 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
198 resolverOption = DNSResolverOption.option(for: currentResolverURL)
199 customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString
200 }
201 .onChange(of: resolverOption) { _, newValue in
202 guard let presetURL = newValue.urlString else {
203 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
204 viewModel.persistCurrentAppSettings(
205 resolverURLString: storedResolverURL,
206 appDensityRawValue: storedDensity
207 )
208 return
209 }
210 storedResolverURL = presetURL
211 viewModel.persistCurrentAppSettings(
212 resolverURLString: storedResolverURL,
213 appDensityRawValue: storedDensity
214 )
215 }
216 .onChange(of: customResolverURL) { _, newValue in
217 guard resolverOption == .custom else { return }
218 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
219 viewModel.persistCurrentAppSettings(
220 resolverURLString: storedResolverURL,
221 appDensityRawValue: storedDensity
222 )
223 }
224 .onChange(of: storedDensity) { _, newValue in
225 viewModel.persistCurrentAppSettings(
226 resolverURLString: storedResolverURL,
227 appDensityRawValue: newValue
228 )
229 }
230 }
231}
232
233private struct CloudSyncSettingsView: View {
234 @Environment(\.appDensity) private var appDensity
235 @State private var cloudSyncService = CloudSyncService.shared
236
237 var body: some View {
238 Form {
239 Section("iCloud Sync") {
240 Toggle(
241 "Enable iCloud Sync",
242 isOn: Binding(
243 get: { cloudSyncService.isEnabled },
244 set: { cloudSyncService.setSyncEnabled($0) }
245 )
246 )
247
248 LabeledContent("Status", value: cloudSyncService.status.title)
249 LabeledContent(
250 "Last Sync",
251 value: cloudSyncService.lastSyncDate?.formatted(date: .abbreviated, time: .shortened) ?? "Not yet synced"
252 )
253
254 Button(cloudSyncService.status == .syncing ? "Syncing…" : "Sync Now") {
255 Task {
256 await cloudSyncService.syncNow(trigger: .manual)
257 }
258 }
259 .disabled(!cloudSyncService.isEnabled || cloudSyncService.status == .syncing)
260
261 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.")
262 .font(appDensity.font(.caption, design: .default))
263 .foregroundStyle(Color(.appTextSecondary))
264
265 Text(cloudSyncService.detailMessage)
266 .font(appDensity.font(.caption, design: .default))
267 .foregroundStyle(Color(.appTextSecondary))
268
269 if let lastErrorMessage = cloudSyncService.lastErrorMessage {
270 Text(lastErrorMessage)
271 .font(appDensity.font(.caption, design: .default))
272 .foregroundStyle(Color(.statusCritical))
273 }
274 }
275 }
276 .navigationTitle("iCloud Sync")
277 .task {
278 await cloudSyncService.refreshAvailability()
279 }
280 }
281}
282
283private struct LocalAPISettingsView: View {
284 @Environment(\.appDensity) private var appDensity
285 @State private var localAPIService = LocalAPIService.shared
286 @State private var portText = ""
287
288 private var statusText: String {
289 if localAPIService.isRunning { return "Running" }
290 return localAPIService.config.isEnabled ? "Stopped" : "Disabled"
291 }
292
293 var body: some View {
294 Form {
295 Section("Local API") {
296 Toggle(
297 "Enable Local API",
298 isOn: Binding(
299 get: { localAPIService.config.isEnabled },
300 set: { localAPIService.setEnabled($0) }
301 )
302 )
303
304 TextField(
305 "Port",
306 text: Binding(
307 get: { portText },
308 set: { newValue in
309 portText = newValue
310 if let port = Int(newValue) {
311 localAPIService.setPort(port)
312 }
313 }
314 )
315 )
316 .keyboardType(.numberPad)
317
318 LabeledContent("Address", value: localAPIService.address)
319 LabeledContent("Status", value: statusText)
320 LabeledContent("Token", value: localAPIService.maskedToken)
321
322 if let statusMessage = localAPIService.statusMessage {
323 Text(statusMessage)
324 .font(appDensity.font(.caption, design: .default))
325 .foregroundStyle(Color(.appTextSecondary))
326 }
327 }
328
329 Section("Authentication") {
330 Button("Copy Token") {
331 localAPIService.copyToken()
332 }
333
334 Button("Copy cURL Command") {
335 localAPIService.copyCurlCommand()
336 }
337
338 Button("Rotate Token") {
339 localAPIService.rotateToken()
340 }
341
342 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.")
343 .font(appDensity.font(.caption, design: .default))
344 .foregroundStyle(Color(.appTextSecondary))
345 }
346
347 Section("Request Logging") {
348 Toggle(
349 "Log Requests",
350 isOn: Binding(
351 get: { localAPIService.config.requestLoggingEnabled },
352 set: { localAPIService.setRequestLoggingEnabled($0) }
353 )
354 )
355
356 if localAPIService.requestLogs.isEmpty {
357 Text("No local API requests logged yet.")
358 .font(appDensity.font(.caption, design: .default))
359 .foregroundStyle(Color(.appTextSecondary))
360 } else {
361 ForEach(localAPIService.requestLogs.prefix(25)) { log in
362 VStack(alignment: .leading, spacing: 4) {
363 HStack {
364 Text("\(log.method) \(log.path)")
365 .font(appDensity.font(.callout, design: .monospaced))
366 Spacer()
367 Text("\(log.statusCode)")
368 .font(appDensity.font(.caption, design: .default))
369 .foregroundStyle(log.statusCode >= 400 ? Color(.statusCritical) : .secondary)
370 }
371
372 Text(log.timestamp.formatted(date: .abbreviated, time: .standard))
373 .font(appDensity.font(.caption2, design: .default))
374 .foregroundStyle(Color(.appTextSecondary))
375
376 Text("\(Int(log.duration * 1000)) ms")
377 .font(appDensity.font(.caption2, design: .default))
378 .foregroundStyle(Color(.appTextSecondary))
379 }
380 }
381 }
382
383 Button("Clear Logs", role: .destructive) {
384 localAPIService.clearRequestLogs()
385 }
386 }
387
388 Section("Control") {
389 Button("Restart Server") {
390 localAPIService.setEnabled(false)
391 localAPIService.setEnabled(true)
392 }
393 .disabled(!localAPIService.config.isEnabled)
394
395 Button("Stop Server") {
396 localAPIService.stopServer()
397 }
398 .disabled(!localAPIService.isRunning)
399 }
400 }
401 .navigationTitle("Local API")
402 .onAppear {
403 portText = String(localAPIService.config.port)
404 localAPIService.refresh()
405 }
406 }
407}
408
409private struct MonitoringSettingsView: View {
410 @Environment(\.appDensity) private var appDensity
411 @Bindable var viewModel: DomainViewModel
412
413 private var notificationAuthorizationLabel: String {
414 switch viewModel.monitoringNotificationStatus {
415 case .authorized, .provisional, .ephemeral:
416 return "Allowed"
417 case .denied:
418 return "Denied"
419 case .notDetermined:
420 return "Not Requested"
421 @unknown default:
422 return "Unknown"
423 }
424 }
425
426 var body: some View {
427 Form {
428 Section("Monitoring") {
429 Toggle(
430 "Enable Background Monitoring",
431 isOn: Binding(
432 get: { viewModel.monitoringSettings.isEnabled },
433 set: { viewModel.setMonitoringEnabled($0) }
434 )
435 )
436
437 Picker(
438 "Base Interval",
439 selection: Binding(
440 get: { MonitoringBaseInterval.nearest(to: viewModel.monitoringSettings.baseInterval) },
441 set: { viewModel.setMonitoringBaseInterval($0) }
442 )
443 ) {
444 ForEach(MonitoringBaseInterval.allCases) { interval in
445 Text(interval.title).tag(interval)
446 }
447 }
448
449 Toggle(
450 "Adaptive Monitoring",
451 isOn: Binding(
452 get: { viewModel.monitoringSettings.adaptiveEnabled },
453 set: { viewModel.setMonitoringAdaptiveEnabled($0) }
454 )
455 )
456
457 Picker(
458 "Sensitivity",
459 selection: Binding(
460 get: { viewModel.monitoringSettings.sensitivity },
461 set: { viewModel.setMonitoringSensitivity($0) }
462 )
463 ) {
464 ForEach(MonitoringSensitivity.allCases) { sensitivity in
465 Text(sensitivity.title).tag(sensitivity)
466 }
467 }
468
469 let quietHoursStart = viewModel.monitoringSettings.quietHours?.startHour ?? 22
470 let quietHoursEnd = viewModel.monitoringSettings.quietHours?.endHour ?? 7
471 Toggle(
472 "Quiet Hours",
473 isOn: Binding(
474 get: { viewModel.monitoringSettings.quietHours != nil },
475 set: { isEnabled in
476 viewModel.setMonitoringQuietHours(
477 startHour: quietHoursStart,
478 endHour: quietHoursEnd,
479 isEnabled: isEnabled
480 )
481 }
482 )
483 )
484
485 if viewModel.monitoringSettings.quietHours != nil {
486 Picker(
487 "Quiet Starts",
488 selection: Binding(
489 get: { quietHoursStart },
490 set: { startHour in
491 viewModel.setMonitoringQuietHours(
492 startHour: startHour,
493 endHour: quietHoursEnd,
494 isEnabled: true
495 )
496 }
497 )
498 ) {
499 ForEach(0..<24, id: \.self) { hour in
500 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
501 }
502 }
503
504 Picker(
505 "Quiet Ends",
506 selection: Binding(
507 get: { quietHoursEnd },
508 set: { endHour in
509 viewModel.setMonitoringQuietHours(
510 startHour: quietHoursStart,
511 endHour: endHour,
512 isEnabled: true
513 )
514 }
515 )
516 ) {
517 ForEach(0..<24, id: \.self) { hour in
518 Text(Self.monitoringHourLabel(for: hour)).tag(hour)
519 }
520 }
521 }
522
523 Picker(
524 "Domains",
525 selection: Binding(
526 get: { viewModel.monitoringSettings.scope },
527 set: { viewModel.setMonitoringScope($0) }
528 )
529 ) {
530 ForEach(MonitoringScope.allCases) { scope in
531 Text(scope.title).tag(scope)
532 }
533 }
534
535 if viewModel.monitoringSettings.scope == .selectedOnly {
536 ForEach(viewModel.trackedDomains) { trackedDomain in
537 Toggle(
538 trackedDomain.domain,
539 isOn: Binding(
540 get: { viewModel.monitoringSettings.selectedDomainIDs.contains(trackedDomain.id) },
541 set: { viewModel.setMonitoringSelection(for: trackedDomain, isSelected: $0) }
542 )
543 )
544 }
545 }
546
547 Toggle(
548 "Local Alerts",
549 isOn: Binding(
550 get: { viewModel.monitoringSettings.alertsEnabled },
551 set: { isEnabled in
552 if isEnabled {
553 Task {
554 await viewModel.requestMonitoringNotificationAuthorization()
555 }
556 } else {
557 viewModel.setMonitoringAlertsEnabled(false)
558 }
559 }
560 )
561 )
562
563 Picker(
564 "Notify For",
565 selection: Binding(
566 get: { viewModel.monitoringSettings.alertFilter },
567 set: { viewModel.setMonitoringAlertFilter($0) }
568 )
569 ) {
570 ForEach(MonitoringAlertFilter.allCases) { filter in
571 Text(filter.title).tag(filter)
572 }
573 }
574
575 LabeledContent("Background Refresh", value: DomainMonitoringScheduler.shared.backgroundRefreshStatusDescription())
576 LabeledContent("Notification Access", value: notificationAuthorizationLabel)
577
578 if let monitoringStatusMessage = viewModel.monitoringStatusMessage {
579 Text(monitoringStatusMessage)
580 .font(appDensity.font(.caption, design: .default))
581 .foregroundStyle(Color(.appTextSecondary))
582 }
583
584 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
585 Text("Background monitoring and alerts are available in Pro.")
586 .font(appDensity.font(.caption, design: .default))
587 .foregroundStyle(Color(.appTextSecondary))
588 }
589 }
590 }
591 .navigationTitle("Monitoring")
592 .onAppear {
593 viewModel.refreshMonitoringState()
594 Task {
595 await viewModel.refreshMonitoringAuthorizationStatus()
596 }
597 }
598 }
599
600 private static func monitoringHourLabel(for hour: Int) -> String {
601 let formatter = DateFormatter()
602 formatter.dateFormat = "h a"
603 let components = DateComponents(calendar: .current, hour: hour)
604 return components.date.map(formatter.string(from:)) ?? "\(hour):00"
605 }
606}
607
608private struct DataPortabilitySettingsView: View {
609 private enum ImportTarget {
610 case backup
611 case trackedDomains
612 case workflows
613
614 var expectedKind: DataPortabilityImportKind {
615 switch self {
616 case .backup:
617 return .backup
618 case .trackedDomains:
619 return .trackedDomains
620 case .workflows:
621 return .workflows
622 }
623 }
624
625 var allowedContentTypes: [UTType] {
626 switch self {
627 case .backup:
628 return [UTType.json]
629 case .trackedDomains, .workflows:
630 return [UTType.json, UTType.commaSeparatedText]
631 }
632 }
633 }
634
635 @Environment(\.appDensity) private var appDensity
636 @Bindable var viewModel: DomainViewModel
637
638 @State private var importMode: DataPortabilityImportMode = .merge
639 @State private var activeImportTarget: ImportTarget?
640 @State private var pendingImportTarget: ImportTarget?
641 @State private var importDebugStatus: String?
642 @State private var pendingImportPreview: DataImportPreview?
643 @State private var pendingImportError: String?
644 @State private var showReplaceImportConfirmation = false
645
646 var body: some View {
647 Form {
648 Section("Import & Export") {
649 Picker("Import Mode", selection: $importMode) {
650 ForEach(DataPortabilityImportMode.allCases) { mode in
651 Text(mode.title).tag(mode)
652 }
653 }
654
655 Text(importMode.explanation)
656 .font(appDensity.font(.caption, design: .default))
657 .foregroundStyle(Color(.appTextSecondary))
658
659 Button("Export Full Backup") {
660 exportFullBackup()
661 }
662
663 Button("Import Backup") {
664 recordImportDebugStatus("Tapped Import Backup")
665 pendingImportTarget = .backup
666 activeImportTarget = .backup
667 }
668
669 Menu("Export Tracked Domains") {
670 Button("JSON") {
671 exportPortableTrackedDomainsJSON()
672 }
673 Button("CSV") {
674 exportPortableTrackedDomainsCSV()
675 }
676 }
677
678 Button("Import Tracked Domains") {
679 recordImportDebugStatus("Tapped Import Tracked Domains")
680 pendingImportTarget = .trackedDomains
681 activeImportTarget = .trackedDomains
682 }
683
684 Menu("Export Workflows") {
685 Button("JSON") {
686 exportPortableWorkflowsJSON()
687 }
688 Button("CSV") {
689 exportPortableWorkflowsCSV()
690 }
691 }
692
693 Button("Import Workflows") {
694 recordImportDebugStatus("Tapped Import Workflows")
695 pendingImportTarget = .workflows
696 activeImportTarget = .workflows
697 }
698
699 Button("Export History") {
700 exportPortableHistoryJSON()
701 }
702 }
703
704 Section("Local Data") {
705 LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)")
706 LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)")
707 LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)")
708 LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)")
709 LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)")
710 LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)")
711
712 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.")
713 .font(appDensity.font(.caption, design: .default))
714 .foregroundStyle(Color(.appTextSecondary))
715
716 if let portabilityStatusMessage = viewModel.portabilityStatusMessage {
717 Text(portabilityStatusMessage)
718 .font(appDensity.font(.caption, design: .default))
719 .foregroundStyle(Color(.appTextSecondary))
720 }
721 }
722
723 #if DEBUG
724 if let importDebugStatus {
725 Section("Import Debug") {
726 Text(importDebugStatus)
727 .font(appDensity.font(.caption, design: .default))
728 .foregroundStyle(Color(.appTextSecondary))
729 .textSelection(.enabled)
730 }
731 }
732 #endif
733 }
734 .navigationTitle("Import & Export")
735 .alert("Replace local data?", isPresented: $showReplaceImportConfirmation) {
736 Button("Replace", role: .destructive) {
737 applyPendingImport()
738 }
739 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
740 } message: {
741 Text("Replace mode overwrites local data covered by the imported file and may remove items that are only on this device.")
742 }
743 .alert("Import Error", isPresented: Binding(
744 get: { pendingImportError != nil },
745 set: { if !$0 { pendingImportError = nil } }
746 )) {
747 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
748 } message: {
749 Text(pendingImportError ?? "The import could not be completed.")
750 }
751 .sheet(isPresented: Binding(
752 get: { pendingImportPreview != nil },
753 set: { if !$0 { pendingImportPreview = nil } }
754 )) {
755 if let pendingImportPreview {
756 DataImportPreviewSheet(
757 preview: pendingImportPreview,
758 mode: importMode,
759 onCancel: {
760 self.pendingImportPreview = nil
761 },
762 onApply: {
763 if importMode == .replace {
764 showReplaceImportConfirmation = true
765 } else {
766 applyPendingImport()
767 }
768 }
769 )
770 }
771 }
772 .fileImporter(
773 isPresented: Binding(
774 get: { activeImportTarget != nil },
775 set: { if !$0 { activeImportTarget = nil } }
776 ),
777 allowedContentTypes: activeImportTarget?.allowedContentTypes ?? [UTType.json],
778 allowsMultipleSelection: false
779 ) { result in
780 guard let pendingImportTarget else {
781 recordImportDebugStatus("fileImporter returned with no active target")
782 return
783 }
784 recordImportDebugStatus("fileImporter returned for \(pendingImportTarget.expectedKind.rawValue)")
785 handleImportResult(result, expectedKind: pendingImportTarget.expectedKind)
786 self.pendingImportTarget = nil
787 self.activeImportTarget = nil
788 }
789 .onAppear {
790 viewModel.refreshDataLifecycleSummary()
791 }
792 }
793
794 private func exportFullBackup() {
795 guard let data = viewModel.exportFullBackupData() else { return }
796 ExportPresenter.share(filename: portabilityFilename(suffix: "backup", fileExtension: "json"), data: data)
797 }
798
799 private func exportPortableTrackedDomainsJSON() {
800 guard let data = viewModel.exportPortableTrackedDomainsJSONData() else { return }
801 ExportPresenter.share(filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "json"), data: data)
802 }
803
804 private func exportPortableTrackedDomainsCSV() {
805 ExportPresenter.share(
806 filename: portabilityFilename(suffix: "tracked_domains", fileExtension: "csv"),
807 contents: viewModel.exportPortableTrackedDomainsCSV()
808 )
809 }
810
811 private func exportPortableWorkflowsJSON() {
812 guard let data = viewModel.exportPortableWorkflowsJSONData() else { return }
813 ExportPresenter.share(filename: portabilityFilename(suffix: "workflows", fileExtension: "json"), data: data)
814 }
815
816 private func exportPortableWorkflowsCSV() {
817 ExportPresenter.share(
818 filename: portabilityFilename(suffix: "workflows", fileExtension: "csv"),
819 contents: viewModel.exportPortableWorkflowsCSV()
820 )
821 }
822
823 private func exportPortableHistoryJSON() {
824 guard let data = viewModel.exportPortableHistoryJSONData() else { return }
825 ExportPresenter.share(filename: portabilityFilename(suffix: "history", fileExtension: "json"), data: data)
826 }
827
828 private func handleImportResult(
829 _ result: Result<[URL], Error>,
830 expectedKind: DataPortabilityImportKind
831 ) {
832 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult expectedKind=\(expectedKind.rawValue)")
833 recordImportDebugStatus("handleImportResult started for \(expectedKind.rawValue)")
834 do {
835 let urls = try result.get()
836 guard let url = urls.first else {
837 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult noURLReturned")
838 recordImportDebugStatus("No URL returned from picker")
839 return
840 }
841 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult selectedURL=\(url.absoluteString)")
842 recordImportDebugStatus("Selected \(url.lastPathComponent)")
843 let shouldStopAccessing = url.startAccessingSecurityScopedResource()
844 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeGranted=\(shouldStopAccessing)")
845 recordImportDebugStatus("Security scope granted: \(shouldStopAccessing)")
846 defer {
847 if shouldStopAccessing {
848 url.stopAccessingSecurityScopedResource()
849 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult securityScopeReleased")
850 }
851 }
852
853 let data = try Data(contentsOf: url)
854 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult dataRead bytes=\(data.count) fileName=\(url.lastPathComponent)")
855 recordImportDebugStatus("Read \(data.count) bytes from \(url.lastPathComponent)")
856 let preview = try viewModel.prepareDataImport(
857 data: data,
858 fileName: url.lastPathComponent,
859 mode: importMode
860 )
861 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult previewReady previewKind=\(preview.kind.rawValue) expectedKind=\(expectedKind.rawValue)")
862 recordImportDebugStatus("Preview ready: \(preview.kind.rawValue)")
863
864 guard preview.kind == expectedKind else {
865 let message = preview.kind == .backup
866 ? "That file is a full backup. Use Import Backup."
867 : "That file type does not match this import action."
868 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult kindMismatch message=\(message)")
869 recordImportDebugStatus("Kind mismatch: \(message)")
870 presentImportError(message)
871 return
872 }
873
874 DomainDebugLog.debug("DataPortabilitySettingsView.handleImportResult presentingPreview kind=\(preview.kind.rawValue)")
875 recordImportDebugStatus("Presenting preview for \(preview.kind.rawValue)")
876 presentImportPreview(preview)
877 } catch {
878 DomainDebugLog.error("DataPortabilitySettingsView.handleImportResult failed error=\(error.localizedDescription)")
879 recordImportDebugStatus("Import failed: \(error.localizedDescription)")
880 presentImportError(error.localizedDescription)
881 }
882 }
883
884 private func applyPendingImport() {
885 guard let pendingImportPreview else { return }
886 do {
887 _ = try viewModel.applyDataImport(pendingImportPreview, mode: importMode)
888 self.pendingImportPreview = nil
889 } catch {
890 pendingImportError = error.localizedDescription
891 }
892 }
893
894 private func portabilityFilename(suffix: String, fileExtension: String) -> String {
895 let formatter = DateFormatter()
896 formatter.dateFormat = "yyyyMMdd_HHmmss"
897 return "\(formatter.string(from: Date()))_domaindig_\(suffix).\(fileExtension)"
898 }
899
900 private func presentImportPreview(_ preview: DataImportPreview) {
901 Task { @MainActor in
902 try? await Task.sleep(for: .milliseconds(300))
903 DomainDebugLog.debug("DataPortabilitySettingsView.presentImportPreview kind=\(preview.kind.rawValue) fileName=\(preview.fileName)")
904 recordImportDebugStatus("Preview presented for \(preview.fileName)")
905 pendingImportPreview = preview
906 }
907 }
908
909 private func presentImportError(_ message: String) {
910 Task { @MainActor in
911 try? await Task.sleep(for: .milliseconds(300))
912 DomainDebugLog.error("DataPortabilitySettingsView.presentImportError message=\(message)")
913 recordImportDebugStatus("Error presented: \(message)")
914 pendingImportError = message
915 }
916 }
917
918 private func recordImportDebugStatus(_ message: String) {
919 #if DEBUG
920 let status = "[Import Debug] \(message)"
921 importDebugStatus = status
922 print(status)
923 #endif
924 }
925}
926
927private struct DataManagementSettingsView: View {
928 @Bindable var viewModel: DomainViewModel
929 @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
930
931 @State private var showClearHistoryConfirmation = false
932 @State private var showClearCacheConfirmation = false
933 @State private var showClearWorkflowsConfirmation = false
934 @State private var showClearTrackedDomainsConfirmation = false
935 @State private var showDeleteAllConfirmation = false
936 @State private var deleteAllErrorMessage: String?
937 @State private var deleteAllSuccessMessage: String?
938 @State private var isDeletingAllData = false
939
940 var body: some View {
941 Form {
942 Section("Data") {
943 Button("Clear History", role: .destructive) {
944 showClearHistoryConfirmation = true
945 }
946
947 Button("Clear Cache", role: .destructive) {
948 showClearCacheConfirmation = true
949 }
950
951 Button("Clear Workflows", role: .destructive) {
952 showClearWorkflowsConfirmation = true
953 }
954
955 Button("Clear Tracked Domains", role: .destructive) {
956 showClearTrackedDomainsConfirmation = true
957 }
958 }
959
960 Section {
961 Button(role: .destructive) {
962 showDeleteAllConfirmation = true
963 } label: {
964 HStack {
965 Text("Delete All Data")
966 Spacer()
967 if isDeletingAllData {
968 ProgressView()
969 .controlSize(.small)
970 }
971 }
972 }
973 .disabled(isDeletingAllData)
974 } header: {
975 Text("Danger Zone")
976 } footer: {
977 Text("Permanently removes all local DomainDig data from this device.")
978 }
979 }
980 .disabled(isDeletingAllData)
981 .navigationTitle("Data Management")
982 .alert("Clear history?", isPresented: $showClearHistoryConfirmation) {
983 Button("Clear", role: .destructive) {
984 viewModel.clearHistory()
985 }
986 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
987 } message: {
988 Text("This removes saved lookup snapshots and clears monitoring run history on this device.")
989 }
990 .alert("Clear cache?", isPresented: $showClearCacheConfirmation) {
991 Button("Clear", role: .destructive) {
992 viewModel.clearLookupCache()
993 }
994 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
995 } message: {
996 Text("This clears the in-memory lookup cache and cancels any cached in-flight work.")
997 }
998 .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) {
999 Button("Clear", role: .destructive) {
1000 viewModel.clearWorkflows()
1001 }
1002 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1003 } message: {
1004 Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.")
1005 }
1006 .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) {
1007 Button("Clear", role: .destructive) {
1008 viewModel.clearTrackedDomains()
1009 }
1010 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1011 } message: {
1012 Text("This removes the watchlist and clears monitoring run history. History and workflows stay intact.")
1013 }
1014 .alert("Delete All Data?", isPresented: $showDeleteAllConfirmation) {
1015 Button("Cancel", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1016 Button("Delete All Data", role: .destructive) {
1017 deleteAllData()
1018 }
1019 } message: {
1020 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.")
1021 }
1022 .alert("Delete Failed", isPresented: Binding(
1023 get: { deleteAllErrorMessage != nil },
1024 set: { if !$0 { deleteAllErrorMessage = nil } }
1025 )) {
1026 Button("OK", role: .cancel) { /* Dismiss only; SwiftUI closes the alert. */ }
1027 } message: {
1028 Text(deleteAllErrorMessage ?? "The local data reset could not be completed.")
1029 }
1030 .safeAreaInset(edge: .bottom) {
1031 if let deleteAllSuccessMessage {
1032 Text(deleteAllSuccessMessage)
1033 .font(.footnote.weight(.medium))
1034 .foregroundStyle(Color(.appTextSecondary))
1035 .padding(.horizontal, 14)
1036 .padding(.vertical, 10)
1037 // Reduce Transparency swaps the blur for an opaque surface.
1038 // On iOS 26+ the system also composites its own translucency
1039 // that the app cannot declare — verify there too (Phase 6).
1040 .background(
1041 Capsule().fill(reduceTransparency ? AnyShapeStyle(Color(.appSurfaceElevated)) : AnyShapeStyle(.thinMaterial))
1042 )
1043 .padding(.bottom, 8)
1044 .transition(.move(edge: .bottom).combined(with: .opacity))
1045 }
1046 }
1047 }
1048
1049 private func deleteAllData() {
1050 guard !isDeletingAllData else { return }
1051
1052 isDeletingAllData = true
1053 deleteAllErrorMessage = nil
1054 deleteAllSuccessMessage = nil
1055
1056 Task {
1057 do {
1058 try await DataResetService.wipeAllLocalData(viewModel: viewModel)
1059 deleteAllSuccessMessage = "All local data removed."
1060 try? await Task.sleep(for: .seconds(2))
1061 if deleteAllSuccessMessage == "All local data removed." {
1062 deleteAllSuccessMessage = nil
1063 }
1064 } catch {
1065 deleteAllErrorMessage = error.localizedDescription
1066 }
1067
1068 isDeletingAllData = false
1069 }
1070 }
1071}
1072
1073private struct DataImportPreviewSheet: View {
1074 @Environment(\.dismiss) private var dismiss
1075
1076 let preview: DataImportPreview
1077 let mode: DataPortabilityImportMode
1078 let onCancel: () -> Void
1079 let onApply: () -> Void
1080
1081 var body: some View {
1082 NavigationStack {
1083 List {
1084 Section("Summary") {
1085 ForEach(preview.summaryLines, id: \.self) { line in
1086 Text(line)
1087 }
1088 }
1089
1090 Section("Projected Counts") {
1091 LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)")
1092 LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)")
1093 LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)")
1094 LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)")
1095 LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)")
1096 LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)")
1097 }
1098
1099 if !preview.warnings.isEmpty {
1100 Section("Warnings") {
1101 ForEach(preview.warnings, id: \.self) { warning in
1102 Text(warning)
1103 .foregroundStyle(Color(.appTextSecondary))
1104 }
1105 }
1106 }
1107 }
1108 .navigationTitle("Import Preview")
1109 .toolbar {
1110 ToolbarItem(placement: .cancellationAction) {
1111 Button("Cancel") {
1112 onCancel()
1113 dismiss()
1114 }
1115 }
1116 ToolbarItem(placement: .confirmationAction) {
1117 Button(mode == .replace ? "Replace" : "Import") {
1118 onApply()
1119 if mode == .merge {
1120 dismiss()
1121 }
1122 }
1123 }
1124 }
1125 }
1126 }
1127}