krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v2.0.0: DomainDig/WatchlistView.swift · raw
1import SwiftUI
2
3struct WatchlistView: View {
4 @Bindable var viewModel: DomainViewModel
5 @Environment(\.dismiss) private var dismiss
6
7 var body: some View {
8 List {
9 if viewModel.sortedTrackedDomains.isEmpty {
10 Section {
11 VStack(alignment: .leading, spacing: 8) {
12 Text("No tracked domains yet")
13 .font(.system(.callout, design: .monospaced))
14 .foregroundStyle(.primary)
15 Text("Tracked domains appear here. Tracking is local and manual for now.")
16 .font(.system(.caption, design: .monospaced))
17 .foregroundStyle(.secondary)
18 }
19 .padding(.vertical, 8)
20 }
21 .listRowBackground(Color(.systemGray6).opacity(0.5))
22 } else {
23 if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
24 Section {
25 Text(limitMessage)
26 .font(.system(.caption, design: .monospaced))
27 .foregroundStyle(.secondary)
28 }
29 .listRowBackground(Color(.systemGray6).opacity(0.5))
30 }
31
32 Section {
33 ForEach(viewModel.sortedTrackedDomains) { trackedDomain in
34 NavigationLink {
35 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
36 } label: {
37 WatchlistRowView(
38 trackedDomain: trackedDomain,
39 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
40 )
41 }
42 .buttonStyle(.plain)
43 .swipeActions(edge: .leading, allowsFullSwipe: false) {
44 Button {
45 viewModel.togglePinned(for: trackedDomain)
46 } label: {
47 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
48 }
49 .tint(.yellow)
50 }
51 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
52 Button(role: .destructive) {
53 viewModel.deleteTrackedDomain(trackedDomain)
54 } label: {
55 Label("Delete", systemImage: "trash")
56 }
57 }
58 .contextMenu {
59 Button {
60 viewModel.refreshTrackedDomain(trackedDomain)
61 } label: {
62 Label("Refresh", systemImage: "arrow.clockwise")
63 }
64
65 Button {
66 dismiss()
67 viewModel.rerunInspection(for: trackedDomain)
68 } label: {
69 Label("Open Inspection", systemImage: "magnifyingglass")
70 }
71
72 Button {
73 viewModel.togglePinned(for: trackedDomain)
74 } label: {
75 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
76 }
77
78 Button(role: .destructive) {
79 viewModel.deleteTrackedDomain(trackedDomain)
80 } label: {
81 Label("Delete", systemImage: "trash")
82 }
83 }
84 .listRowBackground(Color(.systemGray6).opacity(0.5))
85 }
86 .onDelete(perform: viewModel.deleteTrackedDomains)
87 } header: {
88 Text("Tracked Domains")
89 }
90 }
91 }
92 .scrollContentBackground(.hidden)
93 .background(Color.black)
94 .navigationTitle("Watchlist")
95 .toolbar {
96 if !viewModel.sortedTrackedDomains.isEmpty {
97 EditButton()
98 }
99 }
100 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
101 dismiss()
102 }
103 .preferredColorScheme(.dark)
104 }
105}
106
107struct WatchlistRowView: View {
108 let trackedDomain: TrackedDomain
109 let isRefreshing: Bool
110
111 var body: some View {
112 VStack(alignment: .leading, spacing: 6) {
113 HStack(alignment: .firstTextBaseline, spacing: 8) {
114 if trackedDomain.isPinned {
115 Image(systemName: "pin.fill")
116 .font(.caption2)
117 .foregroundStyle(.yellow)
118 }
119 Text(trackedDomain.domain)
120 .font(.system(.callout, design: .monospaced))
121 .foregroundStyle(.primary)
122 .lineLimit(2)
123 .multilineTextAlignment(.leading)
124 Spacer(minLength: 8)
125 statusBadge
126 }
127
128 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
129 .font(.system(.caption2, design: .monospaced))
130 .foregroundStyle(.secondary)
131
132 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
133 Text(note)
134 .font(.system(.caption, design: .monospaced))
135 .foregroundStyle(.secondary)
136 .lineLimit(2)
137 } else if let summary = trackedDomain.lastChangeSummary {
138 Text(summary.changedSections.isEmpty ? "No meaningful changes detected." : summary.changedSections.joined(separator: " • "))
139 .font(.system(.caption, design: .monospaced))
140 .foregroundStyle(.secondary)
141 .lineLimit(2)
142 }
143 }
144 .frame(maxWidth: .infinity, alignment: .leading)
145 .padding(.vertical, 4)
146 }
147
148 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
149 switch status {
150 case .available:
151 return "Available"
152 case .registered:
153 return "Registered"
154 case .unknown, .none:
155 return "Unknown"
156 }
157 }
158
159 @ViewBuilder
160 private var statusBadge: some View {
161 if isRefreshing {
162 HStack(spacing: 6) {
163 ProgressView()
164 .controlSize(.small)
165 Text("Refreshing")
166 .font(.system(.caption2, design: .monospaced))
167 .foregroundStyle(.secondary)
168 }
169 .padding(.horizontal, 8)
170 .padding(.vertical, 4)
171 .background(Color(.systemGray5).opacity(0.6))
172 .clipShape(Capsule())
173 } else {
174 Text(availabilityLabel(trackedDomain.lastKnownAvailability))
175 .font(.system(.caption2, design: .monospaced))
176 .foregroundStyle(badgeColor)
177 .padding(.horizontal, 8)
178 .padding(.vertical, 4)
179 .background(badgeBackground)
180 .clipShape(Capsule())
181 }
182 }
183
184 private var badgeColor: Color {
185 switch trackedDomain.lastKnownAvailability {
186 case .available:
187 return .green
188 case .registered:
189 return .yellow
190 case .unknown, .none:
191 return .secondary
192 }
193 }
194
195 private var badgeBackground: Color {
196 switch trackedDomain.lastKnownAvailability {
197 case .available:
198 return .green.opacity(0.16)
199 case .registered:
200 return .yellow.opacity(0.16)
201 case .unknown, .none:
202 return Color(.systemGray5).opacity(0.6)
203 }
204 }
205}
206
207struct TrackedDomainDetailView: View {
208 @Bindable var viewModel: DomainViewModel
209 let trackedDomain: TrackedDomain
210 @Environment(\.dismiss) private var dismiss
211
212 @State private var noteDraft = ""
213 @State private var isEditingNote = false
214
215 private var liveTrackedDomain: TrackedDomain {
216 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
217 }
218
219 private var latestSnapshots: [HistoryEntry] {
220 viewModel.recentSnapshots(for: liveTrackedDomain)
221 }
222
223 private var latestDiffSections: [DomainDiffSection] {
224 viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
225 }
226
227 var body: some View {
228 List {
229 Section {
230 WatchlistRowView(
231 trackedDomain: liveTrackedDomain,
232 isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
233 )
234 }
235 .listRowBackground(Color(.systemGray6).opacity(0.5))
236
237 Section {
238 Button {
239 viewModel.refreshTrackedDomain(liveTrackedDomain)
240 } label: {
241 Label("Manual Refresh", systemImage: "arrow.clockwise")
242 }
243
244 Button {
245 viewModel.rerunInspection(for: liveTrackedDomain)
246 } label: {
247 Label("Re-run Inspection", systemImage: "magnifyingglass")
248 }
249
250 Button {
251 viewModel.togglePinned(for: liveTrackedDomain)
252 } label: {
253 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
254 }
255
256 Button {
257 noteDraft = liveTrackedDomain.note ?? ""
258 isEditingNote = true
259 } label: {
260 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
261 }
262 }
263 .listRowBackground(Color(.systemGray6).opacity(0.5))
264
265 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
266 Section("Latest Change Summary") {
267 DomainChangeSummaryView(summary: summary)
268 }
269 .listRowBackground(Color.clear)
270 }
271
272 if !latestDiffSections.isEmpty {
273 Section("Latest Diff") {
274 DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false)
275 }
276 .listRowBackground(Color.clear)
277 }
278
279 Section("Recent Snapshots") {
280 if latestSnapshots.isEmpty {
281 Text("No snapshots yet")
282 .font(.system(.caption, design: .monospaced))
283 .foregroundStyle(.secondary)
284 } else {
285 ForEach(latestSnapshots) { entry in
286 NavigationLink {
287 HistoryDetailView(viewModel: viewModel, entry: entry)
288 } label: {
289 VStack(alignment: .leading, spacing: 4) {
290 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
291 .font(.system(.caption, design: .monospaced))
292 .foregroundStyle(.primary)
293 Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
294 .font(.system(.caption2, design: .monospaced))
295 .foregroundStyle(.secondary)
296 }
297 }
298 }
299 }
300 }
301 .listRowBackground(Color(.systemGray6).opacity(0.5))
302 }
303 .scrollContentBackground(.hidden)
304 .background(Color.black)
305 .navigationTitle(liveTrackedDomain.domain)
306 .preferredColorScheme(.dark)
307 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
308 dismiss()
309 }
310 .sheet(isPresented: $isEditingNote) {
311 NavigationStack {
312 Form {
313 Section("Tracking Note") {
314 TextField("Optional note", text: $noteDraft, axis: .vertical)
315 .lineLimit(3...6)
316 }
317 }
318 .navigationTitle("Edit Note")
319 .toolbar {
320 ToolbarItem(placement: .cancellationAction) {
321 Button("Cancel") {
322 isEditingNote = false
323 }
324 }
325 ToolbarItem(placement: .confirmationAction) {
326 Button("Save") {
327 viewModel.updateNote(noteDraft, for: liveTrackedDomain)
328 isEditingNote = false
329 }
330 }
331 }
332 }
333 }
334 }
335}