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}