krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.1: DomainDig/DashboardView.swift · raw
1import SwiftUI
2
3struct DashboardView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor
6 @Bindable var viewModel: DomainViewModel
7 @State private var collapsedGroups = Set<String>()
8
9 private let summaryColumns = [
10 GridItem(.flexible(), spacing: 10),
11 GridItem(.flexible(), spacing: 10)
12 ]
13
14 var body: some View {
15 List {
16 if viewModel.trackedDomains.isEmpty {
17 Section {
18 EmptyStateCardView(
19 title: "No Portfolio Yet",
20 message: "Track domains to get portfolio health, recent changes, expiry visibility, and an attention queue in one place.",
21 suggestion: "Inspect a domain and use the Track action, or add one directly from Tracked Domains in Settings.",
22 systemImage: "square.stack.3d.up.fill",
23 showsCardBackground: false
24 )
25 }
26 .listRowBackground(Color(.appSurface))
27 } else {
28 Section {
29 LazyVGrid(columns: summaryColumns, spacing: 10) {
30 summaryCard(title: "Total Domains", value: viewModel.portfolioDashboardData.snapshot.totalDomains, filter: .all, tint: Color(.statusInfo))
31 summaryCard(title: "Healthy", value: viewModel.portfolioDashboardData.snapshot.healthyCount, filter: .healthy, tint: Color(.statusPositive))
32 summaryCard(title: "Warning", value: viewModel.portfolioDashboardData.snapshot.warningCount, filter: .warning, tint: Color(.statusWarning))
33 summaryCard(title: "Critical", value: viewModel.portfolioDashboardData.snapshot.criticalCount, filter: .critical, tint: Color(.statusCritical))
34 summaryCard(title: "Changes (24h)", value: viewModel.portfolioDashboardData.snapshot.changedLast24h, filter: .changed, tint: Color(.statusWarning))
35 summaryCard(title: "Unreachable", value: viewModel.portfolioDashboardData.snapshot.unreachableCount, filter: .unreachable, tint: Color(.statusCritical))
36 }
37 .padding(.vertical, 4)
38 }
39 .listRowBackground(Color(.appSurface))
40
41 Section("Quick Filters") {
42 ScrollView(.horizontal, showsIndicators: false) {
43 HStack(spacing: 8) {
44 ForEach(PortfolioFilterOption.allCases) { filter in
45 Button {
46 viewModel.dashboardFilter = filter
47 } label: {
48 let isSelected = viewModel.dashboardFilter == filter
49 HStack(spacing: 4) {
50 // Selection is a fill-colour change; under
51 // Differentiate Without Color add a
52 // checkmark + border so it does not depend
53 // on hue alone.
54 if isSelected, differentiateWithoutColor {
55 Image(systemName: "checkmark")
56 .font(.caption2.weight(.bold))
57 }
58 Text(filter.title)
59 .font(appDensity.font(.caption, weight: .semibold))
60 }
61 .foregroundStyle(isSelected ? Color(.appOnAccent) : Color.primary)
62 .padding(.horizontal, 12)
63 .padding(.vertical, 8)
64 .background(isSelected ? Color(.statusInfo) : Color(.appSurfaceElevated))
65 .overlay(
66 Capsule().strokeBorder(
67 Color.primary,
68 lineWidth: isSelected && differentiateWithoutColor ? 1.5 : 0
69 )
70 )
71 .clipShape(Capsule())
72 }
73 .buttonStyle(.plain)
74 .accessibilityAddTraits(viewModel.dashboardFilter == filter ? .isSelected : [])
75 }
76 }
77 .padding(.vertical, 4)
78 }
79 }
80 .listRowBackground(Color(.appSurface))
81
82 Section("Recent Activity") {
83 if viewModel.filteredPortfolioRecentActivity.isEmpty {
84 dashboardEmptyRow("No recent portfolio changes")
85 } else {
86 ForEach(viewModel.filteredPortfolioRecentActivity.prefix(8)) { item in
87 if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
88 NavigationLink {
89 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
90 } label: {
91 PortfolioActivityRow(item: item)
92 }
93 .buttonStyle(.plain)
94 }
95 }
96 }
97 }
98 .listRowBackground(Color(.appSurface))
99
100 Section("Attention Required") {
101 if viewModel.filteredPortfolioAttentionRequired.isEmpty {
102 dashboardEmptyRow("Nothing urgent right now")
103 } else {
104 ForEach(viewModel.filteredPortfolioAttentionRequired.prefix(8)) { item in
105 if let trackedDomain = viewModel.trackedDomain(withID: item.trackedDomainID) {
106 NavigationLink {
107 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
108 } label: {
109 PortfolioAttentionRow(item: item)
110 }
111 .buttonStyle(.plain)
112 }
113 }
114 }
115 }
116 .listRowBackground(Color(.appSurface))
117
118 Section("Expiring Soon") {
119 if viewModel.filteredPortfolioExpiringSoon.isEmpty {
120 dashboardEmptyRow("No certificates expiring within 30 days")
121 } else {
122 ForEach(viewModel.filteredPortfolioExpiringSoon.prefix(8)) { state in
123 NavigationLink {
124 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
125 } label: {
126 PortfolioExpiryRow(state: state)
127 }
128 .buttonStyle(.plain)
129 }
130 }
131 }
132 .listRowBackground(Color(.appSurface))
133
134 Section("Portfolio List") {
135 if viewModel.filteredPortfolioGroups.isEmpty {
136 dashboardEmptyRow("No domains match the current filter")
137 } else {
138 ForEach(viewModel.filteredPortfolioGroups) { group in
139 DisclosureGroup(
140 isExpanded: disclosureBinding(for: group.apexDomain),
141 content: {
142 ForEach(group.domains) { state in
143 NavigationLink {
144 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: state.trackedDomain)
145 } label: {
146 WatchlistRowView(
147 trackedDomain: state.trackedDomain,
148 isRefreshing: viewModel.refreshingTrackedDomainID == state.trackedDomain.id
149 )
150 }
151 .buttonStyle(.plain)
152 }
153 },
154 label: {
155 HStack {
156 VStack(alignment: .leading, spacing: 4) {
157 Text(group.apexDomain)
158 .font(appDensity.font(.headline, design: .default, weight: .semibold))
159 Text("\(group.domains.count) domain\(group.domains.count == 1 ? "" : "s")")
160 .font(appDensity.font(.caption))
161 .foregroundStyle(Color(.appTextSecondary))
162 }
163 Spacer()
164 groupBadge(for: group.domains)
165 }
166 .padding(.vertical, 4)
167 }
168 )
169 }
170 }
171 }
172 .listRowBackground(Color(.appSurface))
173 }
174 }
175 .scrollContentBackground(.hidden)
176 .background(Color(.appBackground))
177 .navigationTitle("Dashboard")
178 .searchable(text: $viewModel.dashboardSearchText, prompt: "Search portfolio")
179 .toolbar {
180 if !viewModel.trackedDomains.isEmpty {
181 ToolbarItem(placement: .topBarTrailing) {
182 Button {
183 AppHaptics.refresh()
184 viewModel.refreshAllTrackedDomains()
185 } label: {
186 Image(systemName: "arrow.clockwise")
187 }
188 .accessibilityLabel("Refresh all tracked domains")
189 .disabled(viewModel.batchLookupRunning)
190 }
191 }
192 }
193 }
194
195 private func disclosureBinding(for apexDomain: String) -> Binding<Bool> {
196 Binding(
197 get: { !collapsedGroups.contains(apexDomain) },
198 set: { isExpanded in
199 if isExpanded {
200 collapsedGroups.remove(apexDomain)
201 } else {
202 collapsedGroups.insert(apexDomain)
203 }
204 }
205 )
206 }
207
208 private func summaryCard(title: String, value: Int, filter: PortfolioFilterOption, tint: Color) -> some View {
209 Button {
210 viewModel.dashboardFilter = filter
211 } label: {
212 VStack(alignment: .leading, spacing: 8) {
213 Text(title)
214 .font(appDensity.font(.caption, design: .default, weight: .semibold))
215 .foregroundStyle(Color(.appTextSecondary))
216 Text("\(value)")
217 // Was a fixed 28pt, which ignored Dynamic Type entirely.
218 .font(.system(.title, design: .rounded, weight: .bold))
219 .foregroundStyle(.primary)
220 HStack(spacing: 5) {
221 // A symbol under Differentiate Without Color (where a bare
222 // colour dot conveys nothing), a plain dot otherwise.
223 if differentiateWithoutColor {
224 Image(systemName: symbol(for: filter))
225 .font(.caption2)
226 .foregroundStyle(tint)
227 } else {
228 Circle()
229 .fill(tint)
230 .frame(width: 8, height: 8)
231 }
232 Text(filter.title)
233 .font(appDensity.font(.caption2, design: .default, weight: .semibold))
234 .foregroundStyle(tint)
235 }
236 }
237 .frame(maxWidth: .infinity, alignment: .leading)
238 .padding(appDensity.metrics.cardPadding)
239 .background(cardBackground(for: filter))
240 .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius))
241 }
242 .buttonStyle(.plain)
243 }
244
245 private func symbol(for filter: PortfolioFilterOption) -> String {
246 switch filter {
247 case .all: return "square.grid.2x2.fill"
248 case .healthy: return "checkmark.circle.fill"
249 case .warning: return "exclamationmark.triangle.fill"
250 case .critical: return "exclamationmark.octagon.fill"
251 case .changed: return "arrow.triangle.2.circlepath"
252 case .expiring: return "clock.badge.exclamationmark.fill"
253 case .unreachable: return "wifi.slash"
254 }
255 }
256
257 private func cardBackground(for filter: PortfolioFilterOption) -> some ShapeStyle {
258 if viewModel.dashboardFilter == filter {
259 // Uses the authored info surface rather than a translucent wash of
260 // the accent. `statusInfo` at 28% over a light background renders
261 // lavender, not blue — an artefact of carrying a dark-only opacity
262 // trick into light mode.
263 return AnyShapeStyle(
264 LinearGradient(
265 colors: [Color(.statusInfoSurface), Color(.appSurface)],
266 startPoint: .topLeading,
267 endPoint: .bottomTrailing
268 )
269 )
270 }
271 return AnyShapeStyle(Color(.appSurfaceElevated))
272 }
273
274 private func dashboardEmptyRow(_ message: String) -> some View {
275 Text(message)
276 .font(appDensity.font(.caption))
277 .foregroundStyle(Color(.appTextSecondary))
278 .padding(.vertical, 4)
279 }
280
281 private func groupBadge(for states: [PortfolioDomainStatus]) -> some View {
282 let criticalCount = states.filter { $0.health == .critical }.count
283 let warningCount = states.filter { $0.health == .warning }.count
284 let title: String
285 let tone: AppStatusTone
286 let systemImage: String
287
288 if criticalCount > 0 {
289 title = "\(criticalCount) critical"
290 tone = .critical
291 systemImage = "exclamationmark.octagon.fill"
292 } else if warningCount > 0 {
293 title = "\(warningCount) warning"
294 tone = .warning
295 systemImage = "exclamationmark.triangle.fill"
296 } else {
297 title = "Healthy"
298 tone = .positive
299 systemImage = "checkmark.circle.fill"
300 }
301
302 return AppStatusBadgeView(
303 model: .init(
304 title: title,
305 systemImage: systemImage,
306 foregroundColor: tone.foreground,
307 backgroundColor: tone.surface
308 )
309 )
310 }
311}
312
313private struct PortfolioActivityRow: View {
314 @Environment(\.appDensity) private var appDensity
315 let item: PortfolioActivityItem
316
317 var body: some View {
318 HStack(alignment: .top, spacing: 10) {
319 Image(systemName: item.systemImage)
320 .font(.caption.weight(.semibold))
321 .foregroundStyle(iconColor)
322 .frame(width: 22, height: 22)
323 .background(iconColor.opacity(0.14))
324 .clipShape(RoundedRectangle(cornerRadius: 7))
325
326 VStack(alignment: .leading, spacing: 4) {
327 Text(item.message)
328 .font(appDensity.font(.callout, design: .default))
329 .foregroundStyle(.primary)
330 HStack(spacing: 8) {
331 Text(item.domain)
332 Text(relativeTimestamp(item.timestamp))
333 }
334 .font(appDensity.font(.caption2))
335 .foregroundStyle(Color(.appTextSecondary))
336 }
337 }
338 .padding(.vertical, 4)
339 }
340
341 private var iconColor: Color {
342 switch item.health {
343 case .healthy:
344 return Color(.statusInfo)
345 case .warning:
346 return Color(.statusWarning)
347 case .critical:
348 return Color(.statusCritical)
349 }
350 }
351}
352
353private struct PortfolioAttentionRow: View {
354 @Environment(\.appDensity) private var appDensity
355 let item: PortfolioAttentionItem
356
357 var body: some View {
358 HStack(alignment: .top, spacing: 10) {
359 AppStatusBadgeView(model: badgeModel)
360
361 VStack(alignment: .leading, spacing: 4) {
362 Text(item.domain)
363 .font(appDensity.font(.callout))
364 .foregroundStyle(.primary)
365 Text(item.reason)
366 .font(appDensity.font(.caption, design: .default))
367 .foregroundStyle(Color(.appTextSecondary))
368 Text(relativeTimestamp(item.timestamp))
369 .font(appDensity.font(.caption2))
370 .foregroundStyle(Color(.appTextSecondary))
371 }
372 }
373 .padding(.vertical, 4)
374 }
375
376 private var badgeModel: AppStatusBadgeModel {
377 switch item.health {
378 case .healthy:
379 return .init(title: "Healthy", systemImage: "checkmark.circle.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
380 case .warning:
381 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
382 case .critical:
383 return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
384 }
385 }
386}
387
388private struct PortfolioExpiryRow: View {
389 @Environment(\.appDensity) private var appDensity
390 let state: PortfolioDomainStatus
391
392 var body: some View {
393 // Wide while it fits; stacked at accessibility sizes so the badge does
394 // not letter-wrap beside a long domain.
395 ViewThatFits(in: .horizontal) {
396 HStack {
397 expiryText
398 Spacer()
399 AppStatusBadgeView(model: badgeModel)
400 }
401 VStack(alignment: .leading, spacing: 6) {
402 expiryText
403 AppStatusBadgeView(model: badgeModel)
404 }
405 }
406 .padding(.vertical, 4)
407 }
408
409 private var expiryText: some View {
410 VStack(alignment: .leading, spacing: 4) {
411 Text(state.trackedDomain.domain)
412 .font(appDensity.font(.callout))
413 .foregroundStyle(.primary)
414 Text(expirySubtitle)
415 .font(appDensity.font(.caption))
416 .foregroundStyle(Color(.appTextSecondary))
417 }
418 }
419
420 private var expirySubtitle: String {
421 if let days = state.certificateDaysRemaining {
422 return "Expires in \(days) day\(days == 1 ? "" : "s")"
423 }
424 return "Certificate needs review"
425 }
426
427 private var badgeModel: AppStatusBadgeModel {
428 switch state.certificateExpiryState {
429 case .none:
430 return .init(title: "Healthy", systemImage: "lock.fill", foregroundColor: Color(.statusPositive), backgroundColor: Color(.statusPositiveSurface))
431 case .warning:
432 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: Color(.statusWarning), backgroundColor: Color(.statusWarningSurface))
433 case .critical:
434 return .init(title: "Critical", systemImage: "xmark.octagon.fill", foregroundColor: Color(.statusCritical), backgroundColor: Color(.statusCriticalSurface))
435 }
436 }
437}
438
439private func relativeTimestamp(_ date: Date) -> String {
440 let formatter = RelativeDateTimeFormatter()
441 formatter.unitsStyle = .short
442 return formatter.localizedString(for: date, relativeTo: Date())
443}