Files
Gotcha/ios/Widgets/GotchaWidgets.swift
2026-08-03 22:44:04 +02:00

343 lines
12 KiB
Swift

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: activityPlaceholderRows(for: context.family),
message: nil
)
}
func snapshot(for configuration: ActivityWidgetIntent, in context: Context) async -> ActivityEntry {
context.isPreview ? placeholder(in: context) : await load(configuration, family: context.family)
}
func timeline(for configuration: ActivityWidgetIntent, in context: Context) async -> Timeline<ActivityEntry> {
Timeline(
entries: [await load(configuration, family: context.family)],
policy: .after(Date.now.addingTimeInterval(15 * 60))
)
}
private func load(_ configuration: ActivityWidgetIntent, family: WidgetFamily) 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 limit = activityLimit(for: family)
let page = try await WidgetEnvironment.core().widgetActivity(serverId: server.id, limit: limit)
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: pullPlaceholderRows(for: context.family),
message: nil
)
}
func snapshot(for configuration: PullsWidgetIntent, in context: Context) async -> PullsEntry {
context.isPreview ? placeholder(in: context) : await load(configuration, family: context.family)
}
func timeline(for configuration: PullsWidgetIntent, in context: Context) async -> Timeline<PullsEntry> {
Timeline(
entries: [await load(configuration, family: context.family)],
policy: .after(Date.now.addingTimeInterval(15 * 60))
)
}
private func load(_ configuration: PullsWidgetIntent, family: WidgetFamily) 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 limit = pullLimit(for: family)
let page = try await WidgetEnvironment.core().widgetPulls(serverId: server.id, limit: limit)
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 func activityLimit(for family: WidgetFamily) -> UInt32 {
family == .systemMedium ? 3 : 10
}
private func pullLimit(for family: WidgetFamily) -> UInt32 {
family == .systemMedium ? 2 : 5
}
private func activityPlaceholderRows(for family: WidgetFamily) -> [ActivityRow] {
let samples: [(ActivityIcon, String, String)] = [
(.push, "Pushed to main in owner/project", "Just now"),
(.issue, "Opened an issue in owner/project", "5m ago"),
(.pullRequest, "Merged a pull request in owner/project", "12m ago"),
(.branch, "Created branch feature/widget", "25m ago"),
(.tag, "Published tag v1.0.0", "40m ago"),
(.release, "Published a release in owner/project", "1h ago"),
(.repository, "Created repository owner/new-project", "2h ago"),
(.push, "Pushed to release in owner/project", "3h ago"),
(.issue, "Closed an issue in owner/project", "4h ago"),
(.pullRequest, "Opened a pull request in owner/project", "5h ago"),
]
return samples.prefix(Int(activityLimit(for: family))).enumerated().map { index, sample in
ActivityRow(
icon: sample.0,
title: sample.1,
detail: "",
meta: sample.2,
target: .commit,
owner: "owner",
repository: "project",
number: Int64(index),
sha: ""
)
}
}
private func pullPlaceholderRows(for family: WidgetFamily) -> [PullRow] {
let titles = [
"Improve the activity screen",
"Fix repository navigation",
"Add compact widget layouts",
"Refresh server configuration",
"Polish issue filters",
]
return titles.prefix(Int(pullLimit(for: family))).enumerated().map { index, title in
let number = Int64(42 - index)
return PullRow(
number: number,
owner: "owner",
repository: "project",
state: .open,
title: "project #\(number)\n\(title)",
summary: "",
meta: "Open · developer · \(index * 10)m ago"
)
}
}
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([.systemMedium, .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([.systemMedium, .systemLarge])
}
}
@main
struct GotchaWidgetBundle: WidgetBundle {
var body: some Widget {
ActivityWidget()
PullsWidget()
}
}