krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.0: 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}