krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.1: DomainDig/WatchlistView.swift · raw

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