summaryrefslogtreecommitdiff
path: root/DomainDigWidget/DomainDigPortfolioWidget.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 00:27:45 -0500
committerChristian Cleberg <[email protected]>2026-07-17 00:31:57 -0500
commitd3a27d06809e6744c092f9bed4bf0537843d0210 (patch)
tree1527e935e11561dda4bc8c6cfaabdb92db5be95c /DomainDigWidget/DomainDigPortfolioWidget.swift
parent685f84ad0a8d2dc7b62125d5d9c3a50b75ae2468 (diff)
downloaddomain-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.swift189
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
+ }
+ }
+}