krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.7.0: DomainDig/WatchlistView.swift · raw

  1import SwiftUI
  2
  3struct WatchlistView: View {
  4    @Environment(\.appDensity) private var appDensity
  5    @Bindable var viewModel: DomainViewModel
  6    @Environment(\.dismiss) private var dismiss
  7    @State private var purchaseService = PurchaseService.shared
  8    @State private var showWorkflowAddSheet = false
  9    @State private var showAddDomainSheet = false
 10    @State private var newTrackedDomain = ""
 11    @State private var addDomainError: String?
 12    @FocusState private var isAddDomainFieldFocused: Bool
 13    @State private var showSavedViewsSheet = false
 14    @State private var showSaveViewPrompt = false
 15    @State private var newSavedViewName = ""
 16
 17    private var pinnedDomains: [TrackedDomain] {
 18        viewModel.filteredTrackedDomains.filter(\.isPinned)
 19    }
 20
 21    private var otherDomains: [TrackedDomain] {
 22        viewModel.filteredTrackedDomains.filter { !$0.isPinned }
 23    }
 24
 25    var body: some View {
 26        let _ = purchaseService.currentTier
 27
 28        List {
 29            if !viewModel.allWatchlistTags.isEmpty {
 30                Section {
 31                    TagFilterChipRowView(tags: viewModel.allWatchlistTags, selection: $viewModel.watchlistTagFilter)
 32                }
 33                .listRowBackground(Color.clear)
 34                .listRowInsets(EdgeInsets())
 35            }
 36
 37            if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
 38                Section("Refresh Progress") {
 39                    VStack(alignment: .leading, spacing: 8) {
 40                        ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
 41                            .tint(.cyan)
 42                        HStack {
 43                            Text(viewModel.batchProgressLabel)
 44                                .font(appDensity.font(.caption))
 45                                .foregroundStyle(.secondary)
 46                            Spacer()
 47                            if viewModel.batchLookupRunning {
 48                                Button("Cancel") {
 49                                    viewModel.cancelBatchLookup()
 50                                }
 51                                .buttonStyle(.bordered)
 52                                .font(appDensity.font(.caption2))
 53                            }
 54                        }
 55
 56                        ForEach(viewModel.batchResults.prefix(5)) { result in
 57                            BatchResultRowView(result: result)
 58                        }
 59                    }
 60                    .padding(.vertical, 4)
 61                }
 62                .listRowBackground(Color(.systemGray6).opacity(0.5))
 63            }
 64
 65            if viewModel.filteredTrackedDomains.isEmpty {
 66                Section {
 67                    EmptyStateCardView(
 68                        title: "No Tracked Domains",
 69                        message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
 70                        suggestion: "Run an inspection and use the Track action on a domain you care about.",
 71                        systemImage: "eye",
 72                        showsCardBackground: false
 73                    )
 74                }
 75                .listRowBackground(Color(.systemGray6).opacity(0.5))
 76            } else {
 77                if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
 78                    Section {
 79                        Text(limitMessage)
 80                            .font(appDensity.font(.caption))
 81                            .foregroundStyle(.secondary)
 82                    }
 83                    .listRowBackground(Color(.systemGray6).opacity(0.5))
 84                }
 85
 86                if !pinnedDomains.isEmpty {
 87                    trackedSection(title: "Pinned", domains: pinnedDomains)
 88                }
 89
 90                if !otherDomains.isEmpty {
 91                    trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
 92                }
 93            }
 94        }
 95        .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
 96        .scrollContentBackground(.hidden)
 97        .background(Color.black)
 98        .navigationTitle("Watchlist")
 99        .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
100        .toolbar {
101            ToolbarItemGroup(placement: .topBarTrailing) {
102                Button {
103                    addDomainError = nil
104                    newTrackedDomain = ""
105                    showAddDomainSheet = true
106                } label: {
107                    Image(systemName: "plus")
108                }
109
110                if !viewModel.filteredTrackedDomains.isEmpty {
111                    Menu {
112                        Picker("Filter", selection: $viewModel.watchlistFilter) {
113                            ForEach(WatchlistFilterOption.allCases) { option in
114                                Text(option.title).tag(option)
115                            }
116                        }
117
118                        Picker("Sort", selection: $viewModel.watchlistSortOption) {
119                            ForEach(WatchlistSortOption.allCases) { option in
120                                Text(option.title).tag(option)
121                            }
122                        }
123
124                        Button("Save Current View…") {
125                            newSavedViewName = ""
126                            showSaveViewPrompt = true
127                        }
128
129                        if !viewModel.watchlistSavedViews.isEmpty {
130                            Button("Saved Views") {
131                                showSavedViewsSheet = true
132                            }
133                        }
134
135                        Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
136                            AppHaptics.refresh()
137                            viewModel.refreshAllTrackedDomains()
138                        }
139                        .disabled(viewModel.batchLookupRunning)
140
141                        Button("Add to Workflow") {
142                            showWorkflowAddSheet = true
143                        }
144
145                        if viewModel.trackedDomains.count >= 2 {
146                            NavigationLink("Compare Domains") {
147                                DomainCompareView(viewModel: viewModel)
148                            }
149                        }
150
151                        Button("Export TXT") {
152                            shareTrackedDomains(format: .text)
153                        }
154
155                        if FeatureAccessService.hasAccess(to: .advancedExports) {
156                            Button("Export CSV") {
157                                shareTrackedDomains(format: .csv)
158                            }
159
160                            Button("Export JSON") {
161                                shareTrackedDomains(format: .json)
162                            }
163                        } else {
164                            Button("CSV Export • Available in Pro") {}
165                                .disabled(true)
166                            Button("JSON Export • Available in Pro") {}
167                                .disabled(true)
168                        }
169                    } label: {
170                        Image(systemName: "line.3.horizontal.decrease.circle")
171                    }
172
173                    EditButton()
174                }
175            }
176        }
177        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
178            dismiss()
179        }
180        .sheet(item: batchSummaryBinding) { summary in
181            BatchSweepSummaryView(viewModel: viewModel, summary: summary)
182        }
183        .sheet(isPresented: $showAddDomainSheet) {
184            NavigationStack {
185                Form {
186                    Section("Domain") {
187                        TextField("example.com", text: $newTrackedDomain)
188                            .textInputAutocapitalization(.never)
189                            .autocorrectionDisabled()
190                            .keyboardType(.URL)
191                            .textContentType(.URL)
192                            .focused($isAddDomainFieldFocused)
193                            .onSubmit(addTrackedDomain)
194                    }
195
196                    Section {
197                        Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.")
198                            .font(appDensity.font(.caption))
199                            .foregroundStyle(.secondary)
200                    }
201
202                    if let addDomainError {
203                        Section {
204                            Text(addDomainError)
205                                .font(appDensity.font(.caption))
206                                .foregroundStyle(.red)
207                        }
208                    }
209                }
210                .navigationTitle("Add Domain")
211                .toolbar {
212                    ToolbarItem(placement: .cancellationAction) {
213                        Button("Cancel") {
214                            showAddDomainSheet = false
215                        }
216                    }
217
218                    ToolbarItem(placement: .confirmationAction) {
219                        Button("Add", action: addTrackedDomain)
220                    }
221                }
222                .onAppear {
223                    DispatchQueue.main.async {
224                        isAddDomainFieldFocused = true
225                    }
226                }
227            }
228        }
229        .sheet(isPresented: $showWorkflowAddSheet) {
230            WorkflowBulkAddSheet(
231                viewModel: viewModel,
232                title: "Add Watchlist Domains",
233                availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
234            )
235        }
236        .alert("Save Current View", isPresented: $showSaveViewPrompt) {
237            TextField("View name", text: $newSavedViewName)
238            Button("Save") {
239                viewModel.saveCurrentWatchlistView(name: newSavedViewName)
240            }
241            Button("Cancel", role: .cancel) {}
242        } message: {
243            Text("Saves the current tag, filter, and sort as a reusable preset.")
244        }
245        .sheet(isPresented: $showSavedViewsSheet) {
246            NavigationStack {
247                List {
248                    ForEach(viewModel.watchlistSavedViews) { view in
249                        Button {
250                            viewModel.applyWatchlistSavedView(view)
251                            showSavedViewsSheet = false
252                        } label: {
253                            VStack(alignment: .leading, spacing: 2) {
254                                Text(view.name)
255                                    .foregroundStyle(.primary)
256                                Text([view.tag, view.filter.title, view.sort.title].compactMap { $0 }.joined(separator: ""))
257                                    .font(.caption)
258                                    .foregroundStyle(.secondary)
259                            }
260                        }
261                    }
262                    .onDelete { offsets in
263                        viewModel.deleteWatchlistSavedViews(at: offsets)
264                    }
265                }
266                .navigationTitle("Saved Views")
267                .toolbar {
268                    ToolbarItem(placement: .cancellationAction) {
269                        Button("Done") {
270                            showSavedViewsSheet = false
271                        }
272                    }
273                    ToolbarItem(placement: .topBarTrailing) {
274                        EditButton()
275                    }
276                }
277            }
278        }
279        .preferredColorScheme(.dark)
280    }
281
282    @ViewBuilder
283    private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
284        Section(title) {
285            ForEach(domains) { trackedDomain in
286                trackedDomainRow(trackedDomain)
287            }
288        }
289    }
290
291    private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
292        NavigationLink {
293            TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
294        } label: {
295            WatchlistRowView(
296                trackedDomain: trackedDomain,
297                isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
298            )
299        }
300        .buttonStyle(.plain)
301        .swipeActions(edge: .leading, allowsFullSwipe: false) {
302            Button {
303                AppHaptics.refresh()
304                viewModel.refreshTrackedDomain(trackedDomain)
305            } label: {
306                Label("Refresh", systemImage: "arrow.clockwise")
307            }
308            .tint(.cyan)
309
310            Button {
311                viewModel.togglePinned(for: trackedDomain)
312            } label: {
313                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
314            }
315            .tint(.yellow)
316        }
317        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
318            Button {
319                AppHaptics.refresh()
320                viewModel.refreshTrackedDomain(trackedDomain)
321            } label: {
322                Label("Refresh", systemImage: "arrow.clockwise")
323            }
324            .tint(.cyan)
325
326            if viewModel.canDelete(trackedDomain) {
327                Button(role: .destructive) {
328                    viewModel.deleteTrackedDomain(trackedDomain)
329                } label: {
330                    Label("Delete", systemImage: "trash")
331                }
332            }
333        }
334        .contextMenu {
335            Button {
336                AppHaptics.refresh()
337                viewModel.refreshTrackedDomain(trackedDomain)
338            } label: {
339                Label("Refresh", systemImage: "arrow.clockwise")
340            }
341
342            Button {
343                dismiss()
344                viewModel.rerunInspection(for: trackedDomain)
345            } label: {
346                Label("Open Inspection", systemImage: "magnifyingglass")
347            }
348
349            Button {
350                viewModel.togglePinned(for: trackedDomain)
351            } label: {
352                Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
353            }
354            .disabled(!viewModel.canEdit(trackedDomain))
355
356            Button {
357                // The system sharing UI manages participants and permissions.
358            } label: {
359                Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
360            }
361            .disabled(true)
362
363            if viewModel.canDelete(trackedDomain) {
364                Button(role: .destructive) {
365                    viewModel.deleteTrackedDomain(trackedDomain)
366                } label: {
367                    Label("Delete", systemImage: "trash")
368                }
369            }
370        }
371        .listRowBackground(Color(.systemGray6).opacity(0.5))
372    }
373
374    private var batchSummaryBinding: Binding<BatchSweepSummary?> {
375        Binding(
376            get: { viewModel.latestBatchSweepSummary },
377            set: { viewModel.latestBatchSweepSummary = $0 }
378        )
379    }
380
381    private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
382        let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
383        domains.forEach(viewModel.deleteTrackedDomain)
384    }
385
386    private func shareTrackedDomains(format: DomainExportFormat) {
387        let formatter = DateFormatter()
388        formatter.dateFormat = "yyyyMMdd_HHmmss"
389        let timestamp = formatter.string(from: Date())
390        let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
391        let data: Data
392
393        switch format {
394        case .text:
395            data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
396        case .csv:
397            data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
398        case .json:
399            data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
400        }
401
402        ExportPresenter.share(filename: filename, data: data)
403    }
404
405    private func addTrackedDomain() {
406        let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines)
407        guard !draft.isEmpty else {
408            addDomainError = "Enter a domain to add."
409            return
410        }
411
412        if viewModel.trackDomain(domain: draft, availabilityStatus: nil) {
413            AppHaptics.track()
414            isAddDomainFieldFocused = false
415            addDomainError = nil
416            newTrackedDomain = ""
417            showAddDomainSheet = false
418        } else if viewModel.upgradePrompt == nil {
419            addDomainError = "Enter a valid domain like example.com."
420        }
421    }
422}
423
424struct WatchlistRowView: View {
425    @Environment(\.appDensity) private var appDensity
426    let trackedDomain: TrackedDomain
427    let isRefreshing: Bool
428
429    var body: some View {
430        VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
431            HStack(alignment: .firstTextBaseline, spacing: 8) {
432                if trackedDomain.isPinned {
433                    Image(systemName: "pin.fill")
434                        .font(.caption2)
435                        .foregroundStyle(.yellow)
436                }
437                Text(trackedDomain.domain)
438                    .font(appDensity.font(.callout))
439                    .foregroundStyle(.primary)
440                    .lineLimit(2)
441                    .multilineTextAlignment(.leading)
442                Spacer(minLength: 8)
443                statusBadge
444            }
445
446            Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
447                .font(appDensity.font(.caption2))
448                .foregroundStyle(.secondary)
449
450            if let collaboration = trackedDomain.collaboration, collaboration.isShared {
451                Text("\(collaboration.ownership.title)\(collaboration.permission.title)")
452                    .font(appDensity.font(.caption2))
453                    .foregroundStyle(.secondary)
454            }
455
456            HStack(spacing: 8) {
457                Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
458                if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
459                    Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
460                }
461                if let lastAlertAt = trackedDomain.lastAlertAt {
462                    Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
463                }
464            }
465            .font(appDensity.font(.caption2))
466            .foregroundStyle(.secondary)
467
468            indicatorRow
469
470            if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
471                Text(note)
472                    .font(appDensity.font(.caption))
473                    .foregroundStyle(.secondary)
474                    .lineLimit(2)
475            } else if let summary = trackedDomain.lastChangeSummary {
476                Text(summary.message)
477                    .font(appDensity.font(.caption))
478                    .foregroundStyle(.secondary)
479                    .lineLimit(2)
480            }
481        }
482        .frame(maxWidth: .infinity, alignment: .leading)
483        .padding(.vertical, 4)
484    }
485
486    private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
487        switch status {
488        case .available:
489            return "Available"
490        case .registered:
491            return "Registered"
492        case .unknown, .none:
493            return "Unknown"
494        }
495    }
496
497    @ViewBuilder
498    private var statusBadge: some View {
499        if isRefreshing {
500            AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
501        } else {
502            AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
503        }
504    }
505
506    @ViewBuilder
507    private var indicatorRow: some View {
508        HStack(spacing: 8) {
509            if trackedDomain.collaboration?.isShared == true {
510                AppStatusBadgeView(
511                    model: .init(
512                        title: "Shared",
513                        systemImage: "person.2.fill",
514                        foregroundColor: .cyan,
515                        backgroundColor: .cyan.opacity(0.16)
516                    )
517                )
518            }
519
520            AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
521
522            if trackedDomain.certificateWarningLevel != .none {
523                AppStatusBadgeView(model: certificateBadge)
524            }
525        }
526    }
527
528    private var certificateBadge: AppStatusBadgeModel {
529        let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
530        switch trackedDomain.certificateWarningLevel {
531        case .critical:
532            return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
533        case .warning:
534            return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
535        case .none:
536            return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
537        }
538    }
539}
540
541struct TrackedDomainDetailView: View {
542    @Bindable var viewModel: DomainViewModel
543    let trackedDomain: TrackedDomain
544    @Environment(\.dismiss) private var dismiss
545
546    @State private var noteDraft = ""
547    @State private var isEditingNote = false
548    @State private var tagsDraft = ""
549    @State private var isEditingTags = false
550    @State private var showRerunOptions = false
551    @State private var shareEntity: ShareableEntity?
552    @State private var showingAuditTimeline = false
553    @State private var auditStartInFlight = false
554
555    private var liveTrackedDomain: TrackedDomain {
556        viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
557    }
558
559    private var latestSnapshots: [HistoryEntry] {
560        viewModel.recentSnapshots(for: liveTrackedDomain)
561    }
562
563    private var latestDiffSections: [DomainDiffSection] {
564        viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
565    }
566
567    var body: some View {
568        List {
569            Section {
570                WatchlistRowView(
571                    trackedDomain: liveTrackedDomain,
572                    isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
573                )
574            }
575            .listRowBackground(Color(.systemGray6).opacity(0.5))
576
577            Section {
578                Button {
579                    viewModel.refreshTrackedDomain(liveTrackedDomain)
580                } label: {
581                    Label("Manual Refresh", systemImage: "arrow.clockwise")
582                }
583
584                Button {
585                    showRerunOptions = true
586                } label: {
587                    Label("Re-run Inspection", systemImage: "magnifyingglass")
588                }
589
590                Button {
591                    Task {
592                        auditStartInFlight = true
593                        if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
594                            showingAuditTimeline = true
595                        }
596                        auditStartInFlight = false
597                    }
598                } label: {
599                    Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
600                }
601                .disabled(auditStartInFlight)
602
603                if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
604                    Button {
605                        showingAuditTimeline = true
606                    } label: {
607                        Label("View Audits", systemImage: "clock.badge.checkmark")
608                    }
609                }
610
611                Button {
612                    viewModel.togglePinned(for: liveTrackedDomain)
613                } label: {
614                    Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
615                }
616                .disabled(!viewModel.canEdit(liveTrackedDomain))
617
618                Button {
619                    viewModel.toggleMonitoring(for: liveTrackedDomain)
620                } label: {
621                    Label(
622                        liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
623                        systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
624                    )
625                }
626                .disabled(!viewModel.canEdit(liveTrackedDomain))
627
628                Button {
629                    noteDraft = liveTrackedDomain.note ?? ""
630                    isEditingNote = true
631                } label: {
632                    Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
633                }
634                .disabled(!viewModel.canEdit(liveTrackedDomain))
635
636                Button {
637                    tagsDraft = liveTrackedDomain.tags.joined(separator: ", ")
638                    isEditingTags = true
639                } label: {
640                    Label(liveTrackedDomain.tags.isEmpty ? "Add Tags" : "Edit Tags", systemImage: "tag")
641                }
642                .disabled(!viewModel.canEdit(liveTrackedDomain))
643
644                Button {
645                    shareEntity = .trackedDomain(liveTrackedDomain.domain)
646                } label: {
647                    Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
648                }
649            }
650            .listRowBackground(Color(.systemGray6).opacity(0.5))
651
652            if !liveTrackedDomain.tags.isEmpty {
653                Section("Tags") {
654                    TagChipRowView(tags: liveTrackedDomain.tags)
655                }
656                .listRowBackground(Color(.systemGray6).opacity(0.5))
657            }
658
659            Section("Monitoring Status") {
660                LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
661                LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
662                LabeledContent(
663                    "Last Change",
664                    value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None"
665                )
666                if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty {
667                    LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)")
668                }
669            }
670            .listRowBackground(Color(.systemGray6).opacity(0.5))
671
672            if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
673                Section("Latest Change Summary") {
674                    DomainChangeSummaryView(summary: summary)
675                }
676                .listRowBackground(Color.clear)
677            }
678
679            if !latestDiffSections.isEmpty {
680                Section("Latest Diff") {
681                    DomainDiffView(
682                        title: "Latest Snapshot vs Previous",
683                        sections: latestDiffSections,
684                        contextNote: latestSnapshots.count >= 2
685                            ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
686                            : nil,
687                        showsUnchanged: false,
688                        highlightedSectionID: nil
689                    )
690                }
691                .listRowBackground(Color.clear)
692            }
693
694            Section("Recent Snapshots") {
695                if latestSnapshots.isEmpty {
696                    Text("No snapshots yet")
697                        .font(.system(.caption, design: .monospaced))
698                        .foregroundStyle(.secondary)
699                } else {
700                    ForEach(latestSnapshots) { entry in
701                        NavigationLink {
702                            HistoryDetailView(viewModel: viewModel, entry: entry)
703                        } label: {
704                            VStack(alignment: .leading, spacing: 4) {
705                                Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
706                                    .font(.system(.caption, design: .monospaced))
707                                    .foregroundStyle(.primary)
708                                Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
709                                    .font(.system(.caption2, design: .monospaced))
710                                    .foregroundStyle(.secondary)
711                            }
712                        }
713                    }
714                }
715            }
716            .listRowBackground(Color(.systemGray6).opacity(0.5))
717        }
718        .scrollContentBackground(.hidden)
719        .background(Color.black)
720        .navigationTitle(liveTrackedDomain.domain)
721        .preferredColorScheme(.dark)
722        .onChange(of: viewModel.rerunNavigationToken) { _, _ in
723            dismiss()
724        }
725        .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) {
726            Button("Run with Current Settings") {
727                viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false)
728            }
729            if latestSnapshots.first != nil {
730                Button("Run with Snapshot Resolver") {
731                    viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true)
732                }
733            }
734            Button("Cancel", role: .cancel) {}
735        } message: {
736            Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.")
737        }
738        .sheet(isPresented: $isEditingNote) {
739            NavigationStack {
740                Form {
741                    Section("Tracking Note") {
742                        TextField("Optional note", text: $noteDraft, axis: .vertical)
743                            .lineLimit(3...6)
744                    }
745                }
746                .navigationTitle("Edit Note")
747                .toolbar {
748                    ToolbarItem(placement: .cancellationAction) {
749                        Button("Cancel") {
750                            isEditingNote = false
751                        }
752                    }
753                    ToolbarItem(placement: .confirmationAction) {
754                        Button("Save") {
755                            viewModel.updateNote(noteDraft, for: liveTrackedDomain)
756                            isEditingNote = false
757                        }
758                    }
759                }
760            }
761        }
762        .sheet(isPresented: $isEditingTags) {
763            NavigationStack {
764                Form {
765                    Section("Tags") {
766                        TextField("comma, separated, tags", text: $tagsDraft)
767                            .textInputAutocapitalization(.never)
768                    }
769                }
770                .navigationTitle("Edit Tags")
771                .toolbar {
772                    ToolbarItem(placement: .cancellationAction) {
773                        Button("Cancel") {
774                            isEditingTags = false
775                        }
776                    }
777                    ToolbarItem(placement: .confirmationAction) {
778                        Button("Save") {
779                            let tags = tagsDraft.components(separatedBy: ",")
780                            viewModel.updateTags(tags, for: liveTrackedDomain)
781                            isEditingTags = false
782                        }
783                    }
784                }
785            }
786        }
787        .sheet(item: $shareEntity) { entity in
788            CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
789        }
790        .sheet(isPresented: $showingAuditTimeline) {
791            NavigationStack {
792                AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
793            }
794        }
795    }
796}