Add configurable iPhone widgets
This commit is contained in:
324
ios/Widgets/GotchaWidgets.swift
Normal file
324
ios/Widgets/GotchaWidgets.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user