diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 00:27:45 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-17 00:31:57 -0500 |
| commit | d3a27d06809e6744c092f9bed4bf0537843d0210 (patch) | |
| tree | 1527e935e11561dda4bc8c6cfaabdb92db5be95c /DomainDigWidget/DomainDigPortfolioWidget.swift | |
| parent | 685f84ad0a8d2dc7b62125d5d9c3a50b75ae2468 (diff) | |
| download | domain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.tar.gz domain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.tar.bz2 domain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.zip | |
Complete v4.5.0: Run Sweep intent, detail deep link, and portfolio widget
Finishes the v4.5.0 "Home Screen & Shortcuts reach" scope that the tag shipped
partially:
- Add RunSweepIntent (opens the app and runs refreshAllTrackedDomains via the
in-process router) and expose it in DomainDigShortcuts.
- Extend the domaindig:// scheme with `sweep` and `domain` (detail) actions;
route .detail to present TrackedDomainDetailView and .sweep to refresh the
watchlist. Move DomainDigDeepLink into Shared/ so the widget can build links.
- Add a WidgetKit extension (DomainDigWidgetExtension) with small/medium/large
Portfolio widgets showing health counts, per-domain status, and certificate
countdowns; tapping a domain deep-links into its detail.
- Share portfolio state via an App Group (group.net.cleberg.DomainDig): the app
writes a DomainDigWidgetData snapshot on launch/foreground and on watchlist
changes and reloads timelines; the widget reads the same store.
Diffstat (limited to 'DomainDigWidget/DomainDigPortfolioWidget.swift')
| -rw-r--r-- | DomainDigWidget/DomainDigPortfolioWidget.swift | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift new file mode 100644 index 0000000..f26964c --- /dev/null +++ b/DomainDigWidget/DomainDigPortfolioWidget.swift @@ -0,0 +1,189 @@ +import SwiftUI +import WidgetKit + +struct DomainDigEntry: TimelineEntry { + let date: Date + let data: DomainDigWidgetData +} + +struct DomainDigProvider: TimelineProvider { + func placeholder(in context: Context) -> DomainDigEntry { + DomainDigEntry(date: Date(), data: .placeholder) + } + + func getSnapshot(in context: Context, completion: @escaping (DomainDigEntry) -> Void) { + let data = context.isPreview ? .placeholder : (DomainDigWidgetStore.read() ?? .placeholder) + completion(DomainDigEntry(date: Date(), data: data)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline<DomainDigEntry>) -> Void) { + let data = DomainDigWidgetStore.read() ?? .empty + let entry = DomainDigEntry(date: Date(), data: data) + // The app reloads timelines on foreground and on watchlist changes; this + // periodic refresh is a backstop so cert countdowns stay roughly current. + let next = Calendar.current.date(byAdding: .hour, value: 6, to: Date()) + ?? Date().addingTimeInterval(6 * 3600) + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +struct DomainDigPortfolioWidget: Widget { + let kind = "DomainDigPortfolioWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in + DomainDigWidgetView(data: entry.data) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Domain Portfolio") + .description("Health and certificate status for your tracked domains.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct DomainDigWidgetView: View { + @Environment(\.widgetFamily) private var family + let data: DomainDigWidgetData + + var body: some View { + if data.totalDomains == 0 { + emptyState + } else { + switch family { + case .systemSmall: + smallView + default: + mediumOrLargeView + } + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.title2) + .foregroundStyle(.secondary) + Text("No tracked domains") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + // MARK: Small + + private var smallView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 4) { + Image(systemName: "shield.lefthalf.filled") + Text("DomainDig") + .fontWeight(.semibold) + Spacer() + } + .font(.caption2) + .foregroundStyle(.secondary) + + Text("\(data.totalDomains)") + .font(.system(size: 34, weight: .bold, design: .rounded)) + Text("tracked") + .font(.caption2) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + + HStack(spacing: 10) { + countPill(data.healthyCount, .green) + countPill(data.warningCount, .orange) + countPill(data.criticalCount, .red) + } + } + } + + private func countPill(_ value: Int, _ color: Color) -> some View { + HStack(spacing: 3) { + Circle().fill(color).frame(width: 7, height: 7) + Text("\(value)").font(.caption).fontWeight(.medium) + } + } + + // MARK: Medium / Large + + private var mediumOrLargeView: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Domain Portfolio", systemImage: "shield.lefthalf.filled") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Text("\(data.totalDomains) tracked") + .font(.caption2) + .foregroundStyle(.secondary) + } + + HStack(spacing: 12) { + summaryStat(data.healthyCount, "Healthy", .green) + summaryStat(data.warningCount, "Warning", .orange) + summaryStat(data.criticalCount, "Critical", .red) + summaryStat(data.expiringSoonCount, "Expiring", .yellow) + } + + Divider() + + VStack(spacing: 6) { + ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in + Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) { + domainRow(domain) + } + } + } + Spacer(minLength: 0) + } + } + + private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text("\(value)") + .font(.headline) + .foregroundStyle(color) + Text(label) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func domainRow(_ domain: DomainDigWidgetDomain) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color(for: domain.status)) + .frame(width: 8, height: 8) + if domain.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + Text(domain.domain) + .font(.caption) + .lineLimit(1) + Spacer(minLength: 4) + Text(certLabel(for: domain)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private func certLabel(for domain: DomainDigWidgetDomain) -> String { + guard let days = domain.certDaysRemaining else { return "—" } + if days < 0 { return "expired" } + return "\(days)d" + } + + private func color(for status: DomainDigWidgetStatus) -> Color { + switch status { + case .healthy: return .green + case .warning: return .orange + case .critical: return .red + } + } +} |
