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