import SwiftUI import WidgetKit private struct ActivityEntry: TimelineEntry { let date: Date let serverName: String let rows: [ActivityRow] let message: String? } private struct PullsEntry: TimelineEntry { let date: Date let serverName: String let rows: [PullRow] let message: String? } private struct ActivityProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> ActivityEntry { ActivityEntry( date: .now, serverName: "My Server", rows: [ ActivityRow( icon: .push, title: "Pushed to main in owner/project", detail: "", meta: "Just now", target: .commit, owner: "owner", repository: "project", number: 0, sha: "" ), ActivityRow( icon: .issue, title: "Opened an issue in owner/project", detail: "", meta: "5m ago", target: .issue, owner: "owner", repository: "project", number: 1, sha: "" ), ], message: nil ) } func snapshot(for configuration: ActivityWidgetIntent, in context: Context) async -> ActivityEntry { context.isPreview ? placeholder(in: context) : await load(configuration) } func timeline(for configuration: ActivityWidgetIntent, in context: Context) async -> Timeline { Timeline( entries: [await load(configuration)], policy: .after(Date.now.addingTimeInterval(15 * 60)) ) } private func load(_ configuration: ActivityWidgetIntent) async -> ActivityEntry { guard let selection = configuration.server, let server = WidgetEnvironment.server(for: selection) else { return ActivityEntry( date: .now, serverName: "Activity", rows: [], message: "Add a server in Gotcha, then configure this widget." ) } do { let page = try await WidgetEnvironment.core().widgetActivity(serverId: server.id, limit: 6) return ActivityEntry( date: .now, serverName: page.serverName, rows: page.rows, message: page.rows.isEmpty ? "No recent activity." : nil ) } catch { return ActivityEntry( date: .now, serverName: server.name, rows: [], message: error.localizedDescription ) } } } private struct PullsProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> PullsEntry { PullsEntry( date: .now, serverName: "My Server", rows: [ PullRow( number: 42, owner: "owner", repository: "project", state: .open, title: "project #42\nImprove the activity screen", summary: "", meta: "Open · developer · Just now" ), PullRow( number: 41, owner: "owner", repository: "project", state: .open, title: "project #41\nFix repository navigation", summary: "", meta: "Open · developer · 10m ago" ), ], message: nil ) } func snapshot(for configuration: PullsWidgetIntent, in context: Context) async -> PullsEntry { context.isPreview ? placeholder(in: context) : await load(configuration) } func timeline(for configuration: PullsWidgetIntent, in context: Context) async -> Timeline { Timeline( entries: [await load(configuration)], policy: .after(Date.now.addingTimeInterval(15 * 60)) ) } private func load(_ configuration: PullsWidgetIntent) async -> PullsEntry { guard let selection = configuration.server, let server = WidgetEnvironment.server(for: selection) else { return PullsEntry( date: .now, serverName: "Pull Requests", rows: [], message: "Add a server in Gotcha, then configure this widget." ) } do { let page = try await WidgetEnvironment.core().widgetPulls(serverId: server.id, limit: 5) return PullsEntry( date: .now, serverName: page.serverName, rows: page.rows, message: page.rows.isEmpty ? "No open pull requests." : nil ) } catch { return PullsEntry( date: .now, serverName: server.name, rows: [], message: error.localizedDescription ) } } } private struct WidgetHeader: View { let title: String let symbol: String let server: String let updated: Date var body: some View { HStack(alignment: .firstTextBaseline) { Label(title, systemImage: symbol) .font(.headline) Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 1) { Text(server) .font(.caption.weight(.semibold)) .lineLimit(1) Text(updated, style: .relative) .font(.caption2) .foregroundStyle(.secondary) } } } } private struct ActivityWidgetView: View { let entry: ActivityEntry var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeader( title: "Activity", symbol: "waveform.path.ecg", server: entry.serverName, updated: entry.date ) Divider() if let message = entry.message { emptyState(message, symbol: "clock.arrow.circlepath") } else { VStack(alignment: .leading, spacing: 7) { ForEach(Array(entry.rows.enumerated()), id: \.offset) { _, row in HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: symbol(for: row.icon)) .foregroundStyle(.tint) .frame(width: 16) Text(row.title) .font(.caption) .lineLimit(1) Spacer(minLength: 4) Text(row.meta) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } .accessibilityElement(children: .combine) } } .privacySensitive() } } .containerBackground(.background, for: .widget) .widgetURL(URL(string: "gotcha://home")) } private func symbol(for icon: ActivityIcon) -> String { switch icon { case .repository: "shippingbox" case .issue: "exclamationmark.circle" case .pullRequest: "arrow.triangle.pull" case .branch: "arrow.triangle.branch" case .tag: "tag" case .push: "arrow.up.circle" case .release: "shippingbox.fill" } } } private struct PullsWidgetView: View { let entry: PullsEntry var body: some View { VStack(alignment: .leading, spacing: 8) { WidgetHeader( title: "Open Pull Requests", symbol: "arrow.triangle.pull", server: entry.serverName, updated: entry.date ) Divider() if let message = entry.message { emptyState(message, symbol: "arrow.triangle.pull") } else { VStack(alignment: .leading, spacing: 8) { ForEach(Array(entry.rows.enumerated()), id: \.offset) { _, row in VStack(alignment: .leading, spacing: 2) { Text(row.title) .font(.caption.weight(.semibold)) .lineLimit(2) Text(row.meta) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } .accessibilityElement(children: .combine) } } .privacySensitive() } } .containerBackground(.background, for: .widget) .widgetURL(URL(string: "gotcha://pulls")) } } private func emptyState(_ message: String, symbol: String) -> some View { VStack(spacing: 8) { Spacer() Image(systemName: symbol) .font(.title2) .foregroundStyle(.secondary) Text(message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Spacer() } .frame(maxWidth: .infinity) } struct ActivityWidget: Widget { let kind = "GotchaActivityWidget" var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: ActivityWidgetIntent.self, provider: ActivityProvider()) { ActivityWidgetView(entry: $0) } .configurationDisplayName("Recent Activity") .description("See the latest activity from a configured Gitea or Forgejo server.") .supportedFamilies([.systemLarge]) } } struct PullsWidget: Widget { let kind = "GotchaPullsWidget" var body: some WidgetConfiguration { AppIntentConfiguration(kind: kind, intent: PullsWidgetIntent.self, provider: PullsProvider()) { PullsWidgetView(entry: $0) } .configurationDisplayName("Open Pull Requests") .description("See open pull requests from a configured Gitea or Forgejo server.") .supportedFamilies([.systemLarge]) } } @main struct GotchaWidgetBundle: WidgetBundle { var body: some Widget { ActivityWidget() PullsWidget() } }