krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.2: 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}