krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.9.0: 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}