krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.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: 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: 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 }
38 .configurationDisplayName("Domain Portfolio")
39 .description("Health and certificate status for your tracked domains.")
40 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
41 }
42}
43
44struct DomainDigWidgetView: View {
45 @Environment(\.widgetFamily) private var family
46 let data: DomainDigWidgetData
47
48 var body: some View {
49 if data.totalDomains == 0 {
50 emptyState
51 } else {
52 switch family {
53 case .systemSmall:
54 smallView
55 default:
56 mediumOrLargeView
57 }
58 }
59 }
60
61 private var emptyState: some View {
62 VStack(spacing: 6) {
63 Image(systemName: "magnifyingglass")
64 .font(.title2)
65 .foregroundStyle(.secondary)
66 Text("No tracked domains")
67 .font(.caption)
68 .foregroundStyle(.secondary)
69 .multilineTextAlignment(.center)
70 }
71 }
72
73 // MARK: Small
74
75 private var smallView: some View {
76 VStack(alignment: .leading, spacing: 8) {
77 HStack(spacing: 4) {
78 Image(systemName: "shield.lefthalf.filled")
79 Text("DomainDig")
80 .fontWeight(.semibold)
81 Spacer()
82 }
83 .font(.caption2)
84 .foregroundStyle(.secondary)
85
86 Text("\(data.totalDomains)")
87 .font(.system(size: 34, weight: .bold, design: .rounded))
88 Text("tracked")
89 .font(.caption2)
90 .foregroundStyle(.secondary)
91
92 Spacer(minLength: 0)
93
94 HStack(spacing: 10) {
95 countPill(data.healthyCount, .green)
96 countPill(data.warningCount, .orange)
97 countPill(data.criticalCount, .red)
98 }
99 }
100 }
101
102 private func countPill(_ value: Int, _ color: Color) -> some View {
103 HStack(spacing: 3) {
104 Circle().fill(color).frame(width: 7, height: 7)
105 Text("\(value)").font(.caption).fontWeight(.medium)
106 }
107 }
108
109 // MARK: Medium / Large
110
111 private var mediumOrLargeView: some View {
112 VStack(alignment: .leading, spacing: 10) {
113 HStack {
114 Label("Domain Portfolio", systemImage: "shield.lefthalf.filled")
115 .font(.caption)
116 .fontWeight(.semibold)
117 .foregroundStyle(.secondary)
118 Spacer()
119 Text("\(data.totalDomains) tracked")
120 .font(.caption2)
121 .foregroundStyle(.secondary)
122 }
123
124 HStack(spacing: 12) {
125 summaryStat(data.healthyCount, "Healthy", .green)
126 summaryStat(data.warningCount, "Warning", .orange)
127 summaryStat(data.criticalCount, "Critical", .red)
128 summaryStat(data.expiringSoonCount, "Expiring", .yellow)
129 }
130
131 Divider()
132
133 VStack(spacing: 6) {
134 ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in
135 Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) {
136 domainRow(domain)
137 }
138 }
139 }
140 Spacer(minLength: 0)
141 }
142 }
143
144 private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View {
145 VStack(alignment: .leading, spacing: 1) {
146 Text("\(value)")
147 .font(.headline)
148 .foregroundStyle(color)
149 Text(label)
150 .font(.system(size: 9))
151 .foregroundStyle(.secondary)
152 }
153 .frame(maxWidth: .infinity, alignment: .leading)
154 }
155
156 private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
157 HStack(spacing: 6) {
158 Circle()
159 .fill(color(for: domain.status))
160 .frame(width: 8, height: 8)
161 if domain.isPinned {
162 Image(systemName: "pin.fill")
163 .font(.system(size: 8))
164 .foregroundStyle(.secondary)
165 }
166 Text(domain.domain)
167 .font(.caption)
168 .lineLimit(1)
169 Spacer(minLength: 4)
170 Text(certLabel(for: domain))
171 .font(.caption2)
172 .foregroundStyle(.secondary)
173 }
174 }
175
176 private func certLabel(for domain: DomainDigWidgetDomain) -> String {
177 guard let days = domain.certDaysRemaining else { return "—" }
178 if days < 0 { return "expired" }
179 return "\(days)d"
180 }
181
182 private func color(for status: DomainDigWidgetStatus) -> Color {
183 switch status {
184 case .healthy: return .green
185 case .warning: return .orange
186 case .critical: return .red
187 }
188 }
189}