Add configurable iPhone widgets

This commit is contained in:
Georg Bauer
2026-08-03 22:06:32 +02:00
parent 2534010d1d
commit c67d274a95
23 changed files with 1151 additions and 56 deletions

View File

@@ -0,0 +1,324 @@
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<ActivityEntry> {
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<PullsEntry> {
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()
}
}