krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.0: DomainDigWidget/DomainDigPortfolioWidget.swift · raw

  1import SwiftUI
  2import WidgetKit
  3
  4struct DomainDigEntry: TimelineEntry {
  5    let date: Date
  6    let data: DomainDigWidgetData
  7}
  8
  9struct DomainDigProvider: TimelineProvider {
 10    func placeholder(in _: Context) -> DomainDigEntry {
 11        DomainDigEntry(date: Date(), data: .placeholder)
 12    }
 13
 14    func getSnapshot(in context: Context, completion: @escaping (DomainDigEntry) -> Void) {
 15        let data = context.isPreview ? .placeholder : (DomainDigWidgetStore.read() ?? .placeholder)
 16        completion(DomainDigEntry(date: Date(), data: data))
 17    }
 18
 19    func getTimeline(in _: Context, completion: @escaping (Timeline<DomainDigEntry>) -> Void) {
 20        let data = DomainDigWidgetStore.read() ?? .empty
 21        let entry = DomainDigEntry(date: Date(), data: data)
 22        // The app reloads timelines on foreground and on watchlist changes; this
 23        // periodic refresh is a backstop so cert countdowns stay roughly current.
 24        let next = Calendar.current.date(byAdding: .hour, value: 6, to: Date())
 25            ?? Date().addingTimeInterval(6 * 3600)
 26        completion(Timeline(entries: [entry], policy: .after(next)))
 27    }
 28}
 29
 30struct DomainDigPortfolioWidget: Widget {
 31    let kind = "DomainDigPortfolioWidget"
 32
 33    var body: some WidgetConfiguration {
 34        StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in
 35            DomainDigWidgetView(data: entry.data)
 36                .containerBackground(.fill.tertiary, for: .widget)
 37                // Clamped here and ONLY here. A widget canvas is a fixed
 38                // system-defined size and WidgetKit truncates overflow with no
 39                // scroll affordance, so unclamped accessibility sizes produce
 40                // less readable output, not more. In-app there is always a
 41                // scroll view, so nothing there is clamped.
 42                .dynamicTypeSize(...DynamicTypeSize.accessibility1)
 43        }
 44        .configurationDisplayName("Domain Portfolio")
 45        .description("Health and certificate status for your tracked domains.")
 46        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
 47    }
 48}
 49
 50struct DomainDigWidgetView: View {
 51    @Environment(\.widgetFamily) private var family
 52    let data: DomainDigWidgetData
 53
 54    var body: some View {
 55        if data.totalDomains == 0 {
 56            emptyState
 57        } else {
 58            if family == .systemSmall {
 59                smallView
 60            } else {
 61                mediumOrLargeView
 62            }
 63        }
 64    }
 65
 66    private var emptyState: some View {
 67        VStack(spacing: 6) {
 68            Image(systemName: "magnifyingglass")
 69                .font(.title2)
 70                .foregroundStyle(Color(.appTextSecondary))
 71            Text("No tracked domains")
 72                .font(.caption)
 73                .foregroundStyle(Color(.appTextSecondary))
 74                .multilineTextAlignment(.center)
 75        }
 76    }
 77
 78    // MARK: Small
 79
 80    private var smallView: some View {
 81        VStack(alignment: .leading, spacing: 8) {
 82            HStack(spacing: 4) {
 83                Image(systemName: "shield.lefthalf.filled")
 84                Text("DomainDig")
 85                    .fontWeight(.semibold)
 86                Spacer()
 87            }
 88            .font(.caption2)
 89            .foregroundStyle(Color(.appTextSecondary))
 90
 91            Text("\(data.totalDomains)")
 92                .font(.system(.largeTitle, design: .rounded, weight: .bold))
 93            Text("tracked")
 94                .font(.caption2)
 95                .foregroundStyle(Color(.appTextSecondary))
 96
 97            Spacer(minLength: 0)
 98
 99            HStack(spacing: 10) {
100                countPill(data.healthyCount, .healthy, "healthy")
101                countPill(data.warningCount, .warning, "warning")
102                countPill(data.criticalCount, .critical, "critical")
103            }
104        }
105    }
106
107    private func countPill(_ value: Int, _ status: DomainDigWidgetStatus, _ label: String) -> some View {
108        HStack(spacing: 3) {
109            Image(systemName: symbol(for: status))
110                .font(.caption2)
111                .foregroundStyle(color(for: status))
112            Text("\(value)").font(.caption).fontWeight(.medium)
113        }
114        // A coloured dot and a number say nothing on their own.
115        .accessibilityElement(children: .ignore)
116        .accessibilityLabel("\(value) \(label)")
117    }
118
119    // MARK: Medium / Large
120
121    private var mediumOrLargeView: some View {
122        VStack(alignment: .leading, spacing: 10) {
123            HStack {
124                Label("Domain Portfolio", systemImage: "shield.lefthalf.filled")
125                    .font(.caption)
126                    .fontWeight(.semibold)
127                    .foregroundStyle(Color(.appTextSecondary))
128                Spacer()
129                Text("\(data.totalDomains) tracked")
130                    .font(.caption2)
131                    .foregroundStyle(Color(.appTextSecondary))
132            }
133
134            HStack(spacing: 12) {
135                summaryStat(data.healthyCount, "Healthy", Color(.statusPositive))
136                summaryStat(data.warningCount, "Warning", Color(.statusWarning))
137                summaryStat(data.criticalCount, "Critical", Color(.statusCritical))
138                summaryStat(data.expiringSoonCount, "Expiring", Color(.statusWarning))
139            }
140
141            Divider()
142
143            VStack(spacing: 6) {
144                ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in
145                    Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) {
146                        domainRow(domain)
147                    }
148                }
149            }
150            Spacer(minLength: 0)
151        }
152    }
153
154    private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View {
155        VStack(alignment: .leading, spacing: 1) {
156            Text("\(value)")
157                .font(.headline)
158                .foregroundStyle(color)
159            Text(label)
160                .font(.caption2)
161                .foregroundStyle(Color(.appTextSecondary))
162        }
163        .frame(maxWidth: .infinity, alignment: .leading)
164    }
165
166    private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
167        HStack(spacing: 6) {
168            Image(systemName: symbol(for: domain.status))
169                .font(.caption2)
170                .foregroundStyle(color(for: domain.status))
171            if domain.isPinned {
172                Image(systemName: "pin.fill")
173                    .font(.caption2)
174                    .foregroundStyle(Color(.appTextSecondary))
175            }
176            Text(domain.domain)
177                .font(.caption)
178                .lineLimit(1)
179            Spacer(minLength: 4)
180            Text(certLabel(for: domain))
181                .font(.caption2)
182                .foregroundStyle(Color(.appTextSecondary))
183        }
184        // The status is a silent 8pt dot and the cert countdown is bare ("12d"),
185        // both meaningless to VoiceOver. Collapse the row into one spoken phrase.
186        .accessibilityElement(children: .ignore)
187        .accessibilityLabel(rowAccessibilityLabel(domain))
188    }
189
190    private func rowAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
191        var parts = [domain.domain, statusLabel(domain.status)]
192        if domain.isPinned { parts.append("pinned") }
193        parts.append(certAccessibilityLabel(domain))
194        return parts.joined(separator: ", ")
195    }
196
197    private func statusLabel(_ status: DomainDigWidgetStatus) -> String {
198        switch status {
199        case .healthy: return "healthy"
200        case .warning: return "warning"
201        case .critical: return "critical"
202        }
203    }
204
205    private func certAccessibilityLabel(_ domain: DomainDigWidgetDomain) -> String {
206        guard let days = domain.certDaysRemaining else { return "certificate status unknown" }
207        if days < 0 { return "certificate expired" }
208        return "certificate expires in \(days) day\(days == 1 ? "" : "s")"
209    }
210
211    private func certLabel(for domain: DomainDigWidgetDomain) -> String {
212        guard let days = domain.certDaysRemaining else { return "" }
213        if days < 0 { return "expired" }
214        return "\(days)d"
215    }
216
217    private func color(for status: DomainDigWidgetStatus) -> Color {
218        switch status {
219        case .healthy: return Color(.statusPositive)
220        case .warning: return Color(.statusWarning)
221        case .critical: return Color(.statusCritical)
222        }
223    }
224
225    /// Same symbol vocabulary as the in-app badges, so status survives without
226    /// colour (Differentiate Without Color, greyscale, colour-blind viewers) and
227    /// reads consistently across surfaces.
228    private func symbol(for status: DomainDigWidgetStatus) -> String {
229        switch status {
230        case .healthy: return "checkmark.circle.fill"
231        case .warning: return "exclamationmark.triangle.fill"
232        case .critical: return "exclamationmark.octagon.fill"
233        }
234    }
235}