diff options
| author | Christian Cleberg <[email protected]> | 2026-03-24 18:28:21 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-24 18:28:21 -0500 |
| commit | 94d0b581bbd2be7b63c07fe7f86d736daa96e1f9 (patch) | |
| tree | e0fb93129ab426c2ebdabd2ea3a84b713f843656 /HutchWidgetExtension | |
| parent | 28c5b36bed9e3a4217415079827f657205a080f7 (diff) | |
| download | hutch-94d0b581bbd2be7b63c07fe7f86d736daa96e1f9.tar.gz hutch-94d0b581bbd2be7b63c07fe7f86d736daa96e1f9.tar.bz2 hutch-94d0b581bbd2be7b63c07fe7f86d736daa96e1f9.zip | |
v2.4.0: widgetsv2.4.0
Diffstat (limited to 'HutchWidgetExtension')
| -rw-r--r-- | HutchWidgetExtension/HutchWidgetExtension.entitlements | 10 | ||||
| -rw-r--r-- | HutchWidgetExtension/Info.plist | 29 | ||||
| -rw-r--r-- | HutchWidgetExtension/NeedsAttentionWidget.swift | 226 |
3 files changed, 265 insertions, 0 deletions
diff --git a/HutchWidgetExtension/HutchWidgetExtension.entitlements b/HutchWidgetExtension/HutchWidgetExtension.entitlements new file mode 100644 index 0000000..9ed683a --- /dev/null +++ b/HutchWidgetExtension/HutchWidgetExtension.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.cleberg.Hutch</string> + </array> +</dict> +</plist> diff --git a/HutchWidgetExtension/Info.plist b/HutchWidgetExtension/Info.plist new file mode 100644 index 0000000..7b41344 --- /dev/null +++ b/HutchWidgetExtension/Info.plist @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Needs Attention</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.widgetkit-extension</string> + </dict> +</dict> +</plist> diff --git a/HutchWidgetExtension/NeedsAttentionWidget.swift b/HutchWidgetExtension/NeedsAttentionWidget.swift new file mode 100644 index 0000000..fff030f --- /dev/null +++ b/HutchWidgetExtension/NeedsAttentionWidget.swift @@ -0,0 +1,226 @@ +import SwiftUI +import WidgetKit + +struct NeedsAttentionEntry: TimelineEntry { + let date: Date + let snapshot: NeedsAttentionSnapshot? +} + +struct NeedsAttentionTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> NeedsAttentionEntry { + NeedsAttentionEntry( + date: .now, + snapshot: NeedsAttentionSnapshot( + unreadInboxThreads: 3, + assignedOpenTickets: 2, + failedBuilds: 1, + updatedAt: .now + ) + ) + } + + func getSnapshot(in context: Context, completion: @escaping (NeedsAttentionEntry) -> Void) { + completion( + NeedsAttentionEntry( + date: .now, + snapshot: NeedsAttentionSnapshotStore.load() + ) + ) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline<NeedsAttentionEntry>) -> Void) { + let refreshDate = Calendar.current.date(byAdding: .minute, value: 20, to: .now) ?? .now.addingTimeInterval(1200) + let entry = NeedsAttentionEntry(date: .now, snapshot: NeedsAttentionSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .after(refreshDate))) + } +} + +struct NeedsAttentionWidget: Widget { + static let kind = NeedsAttentionWidgetConfiguration.kind + + var body: some WidgetConfiguration { + StaticConfiguration(kind: Self.kind, provider: NeedsAttentionTimelineProvider()) { entry in + NeedsAttentionWidgetView(entry: entry) + } + .configurationDisplayName("Needs Attention") + .description("Unread inbox threads, assigned tickets, and failed builds.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +private struct NeedsAttentionWidgetView: View { + let entry: NeedsAttentionEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + Group { + if let snapshot = entry.snapshot, snapshot.hasAnyData { + if snapshot.allCountsAreZero { + clearState + } else { + switch family { + case .systemMedium: + mediumView(snapshot: snapshot) + default: + smallView(snapshot: snapshot) + } + } + } else { + fallbackState + } + } + .widgetURL(URL(string: "hutch://home")) + .containerBackground(for: .widget) { + Color(.systemBackground) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 1) { + Text("Hutch") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("Needs Attention") + .font(.headline) + .lineLimit(1) + } + } + + private func smallView(snapshot: NeedsAttentionSnapshot) -> some View { + VStack(alignment: .leading, spacing: 12) { + Spacer(minLength: 0) + VStack(alignment: .leading, spacing: 8) { + compactMetric( + count: snapshot.unreadInboxThreads, + label: "Unread", + tint: unreadTint(for: snapshot.unreadInboxThreads) + ) + compactMetric(count: snapshot.assignedOpenTickets, label: "Assigned") + compactMetric( + count: snapshot.failedBuilds, + label: "Failed", + tint: failedTint(for: snapshot.failedBuilds) + ) + } + Spacer(minLength: 0) + } + .padding() + } + + private func mediumView(snapshot: NeedsAttentionSnapshot) -> some View { + VStack(alignment: .leading, spacing: 18) { + header + .padding(.top, 6) + HStack(alignment: .top, spacing: 12) { + metricColumn( + count: snapshot.unreadInboxThreads, + label: "Unread", + systemImage: "tray", + tint: unreadTint(for: snapshot.unreadInboxThreads) + ) + metricColumn( + count: snapshot.assignedOpenTickets, + label: "Assigned", + systemImage: "ticket" + ) + metricColumn( + count: snapshot.failedBuilds, + label: "Failed", + systemImage: "hammer", + tint: failedTint(for: snapshot.failedBuilds) + ) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 16) + } + + private var clearState: some View { + VStack(alignment: .leading, spacing: 12) { + header + Spacer(minLength: 0) + Text("All clear") + .font(.title3.weight(.semibold)) + Text("No unread threads, assigned tickets, or failed builds.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } + + private var fallbackState: some View { + VStack(alignment: .leading, spacing: 12) { + header + Spacer(minLength: 0) + Text("Open Hutch") + .font(.title3.weight(.semibold)) + Text("Unable to load recent counts.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + } + + private func compactMetric(count: Int?, label: String, tint: Color = .primary) -> some View { + HStack(alignment: .center, spacing: 6) { + Text(displayCount(count)) + .font(.title2.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(tint) + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + } + + private func metricColumn(count: Int?, label: String, systemImage: String, tint: Color = .primary) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(tint) + .frame(width: 24, alignment: .leading) + .padding(.trailing, 4) + Text(displayCount(count)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundStyle(tint) + } + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(height: 16, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func displayCount(_ count: Int?) -> String { + guard let count else { return "—" } + return "\(count)" + } + + private func unreadTint(for count: Int?) -> Color { + guard let count, count > 0 else { return .primary } + return .blue + } + + private func failedTint(for count: Int?) -> Color { + guard let count else { return .primary } + return count > 0 ? .red : .green + } +} + +@main +struct HutchWidgets: WidgetBundle { + var body: some Widget { + NeedsAttentionWidget() + } +} |
