krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.1: 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}