krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.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: 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}