krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.1: DomainDig/TimelineView.swift · raw
1import SwiftUI
2
3struct TimelineView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 let domain: String
7
8 @State private var presentedDiff: DomainDiff?
9 @State private var focusedSectionID: String?
10
11 private var timelineSections: [TimelineSection] {
12 viewModel.timelineSections(for: domain)
13 }
14
15 private var compareButtonDisabled: Bool {
16 viewModel.selectedSnapshots.count != 2 && viewModel.historyEntries(for: domain).count < 2
17 }
18
19 var body: some View {
20 List {
21 ForEach(timelineSections) { section in
22 Section(section.title) {
23 ForEach(section.entries) { summary in
24 if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
25 NavigationLink {
26 HistoryDetailView(viewModel: viewModel, entry: entry)
27 } label: {
28 TimelineRow(summary: summary, entry: entry)
29 }
30 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
31 Button {
32 viewModel.toggleSnapshotSelection(entry)
33 } label: {
34 Label(
35 viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
36 systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
37 )
38 }
39
40 Button(role: .destructive) {
41 viewModel.removeHistoryEntries(withIDs: [entry.id])
42 } label: {
43 Label("Delete", systemImage: "trash")
44 }
45 }
46 }
47 }
48 .listRowBackground(Color(.appSurface))
49 }
50 }
51 }
52 .scrollContentBackground(.hidden)
53 .background(Color(.appBackground))
54 .navigationTitle(domain)
55 .toolbar {
56 ToolbarItemGroup(placement: .topBarTrailing) {
57 Menu {
58 Picker("Grouping", selection: $viewModel.timelineGrouping) {
59 ForEach(TimelineGroupingOption.allCases) { option in
60 Text(option.title).tag(option)
61 }
62 }
63 } label: {
64 Image(systemName: "line.3.horizontal.decrease.circle")
65 }
66 .accessibilityLabel("Group timeline")
67
68 Button("Compare") {
69 if viewModel.selectedSnapshots.count == 2 {
70 presentedDiff = viewModel.generateDiffForSelectedSnapshots()
71 } else {
72 let entries = viewModel.historyEntries(for: domain)
73 guard entries.count >= 2 else { return }
74 presentedDiff = viewModel.generateDiff(from: entries[1], to: entries[0])
75 }
76 focusedSectionID = viewModel.currentDiffTargetSectionID
77 }
78 .disabled(compareButtonDisabled)
79
80 Menu("Export") {
81 Button("Export TXT") {
82 ExportPresenter.share(
83 filename: "\(domain)-timeline.txt",
84 contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
85 )
86 }
87
88 Button("Export JSON") {
89 guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
90 ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
91 }
92 }
93 }
94 }
95 .sheet(item: $presentedDiff) { diff in
96 NavigationStack {
97 TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
98 }
99 }
100 }
101}
102
103private struct TimelineRow: View {
104 @Environment(\.appDensity) private var appDensity
105 let summary: SnapshotSummary
106 let entry: HistoryEntry
107
108 var body: some View {
109 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
110 HStack(alignment: .center, spacing: 8) {
111 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
112 .font(appDensity.font(.callout))
113 .foregroundStyle(.primary)
114 Spacer()
115 if let severity = summary.severitySummary {
116 AppStatusBadgeView(
117 model: .init(
118 title: severity.title,
119 systemImage: "arrow.triangle.2.circlepath",
120 foregroundColor: severity == .high ? Color(.statusCritical) : Color(.statusWarning),
121 backgroundColor: (severity == .high ? AppStatusTone.critical : .warning).surface
122 )
123 )
124 }
125 }
126
127 Text(summary.changeSummaryMessage ?? "No change summary")
128 .font(appDensity.font(.caption))
129 .foregroundStyle(Color(.appTextSecondary))
130 .lineLimit(2)
131
132 HStack(spacing: 8) {
133 AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
134 if let riskScore = summary.riskScore {
135 Text("Risk \(riskScore)")
136 .lineLimit(1)
137 .minimumScaleFactor(0.85)
138 }
139 }
140 .font(appDensity.font(.caption2))
141 .foregroundStyle(Color(.appTextSecondary))
142
143 if !entry.intelligenceTimeline.isEmpty {
144 VStack(alignment: .leading, spacing: 4) {
145 ForEach(Array(entry.intelligenceTimeline.prefix(2))) { event in
146 Text("\(event.title): \(event.detail)")
147 .font(appDensity.font(.caption2))
148 .foregroundStyle(Color(.appTextSecondary))
149 .lineLimit(1)
150 }
151 }
152 }
153
154 HStack(spacing: 8) {
155 if let primaryIP = summary.primaryIP {
156 Text(primaryIP)
157 .lineLimit(1)
158 .truncationMode(.middle)
159 }
160 Spacer(minLength: 8)
161 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
162 .lineLimit(1)
163 }
164 .font(appDensity.font(.caption2))
165 .foregroundStyle(Color(.appTextSecondary))
166 }
167 }
168}
169
170struct TimelineDiffView: View {
171 @Environment(\.accessibilityReduceMotion) private var reduceMotion
172 @Bindable var viewModel: DomainViewModel
173 let diff: DomainDiff
174 @Binding var focusedSectionID: String?
175
176 var body: some View {
177 ScrollViewReader { proxy in
178 ScrollView {
179 VStack(alignment: .leading, spacing: 12) {
180 HStack {
181 Button("Previous Change") {
182 viewModel.moveToPreviousDiffChange()
183 focusedSectionID = viewModel.currentDiffTargetSectionID
184 scroll(proxy: proxy)
185 }
186 .disabled(viewModel.activeDiffChangeIndex == 0)
187
188 Button("Next Change") {
189 viewModel.moveToNextDiffChange()
190 focusedSectionID = viewModel.currentDiffTargetSectionID
191 scroll(proxy: proxy)
192 }
193 .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
194
195 Spacer()
196 }
197
198 DomainDiffView(
199 title: "Snapshot Diff",
200 sections: diff.sections,
201 contextNote: diff.contextNote,
202 showsUnchanged: false,
203 highlightedSectionID: focusedSectionID
204 )
205 }
206 .padding()
207 }
208 .background(Color(.appBackground))
209 .navigationTitle("Compare Snapshots")
210 .navigationBarTitleDisplayMode(.inline)
211 .onAppear {
212 scroll(proxy: proxy)
213 }
214 .onChange(of: focusedSectionID) { _, _ in
215 scroll(proxy: proxy)
216 }
217 }
218 }
219
220 private func scroll(proxy: ScrollViewProxy) {
221 guard let focusedSectionID else { return }
222 withAnimation(reduceMotion ? nil : .default) {
223 proxy.scrollTo(focusedSectionID, anchor: .top)
224 }
225 }
226}