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}