Files
swiftapp/swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift
Jürgen Kunz 61a0cc1f7d
Some checks failed
CI / test (push) Has been cancelled
Overhaul native approval UX and add widget surfaces
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
2026-04-19 16:29:13 +02:00

293 lines
9.6 KiB
Swift

import SwiftUI
import WidgetKit
#if os(iOS)
import ActivityKit
import AppIntents
import UIKit
#endif
struct ApprovalWidgetEntry: TimelineEntry {
let date: Date
let pendingCount: Int
let topPayload: ApprovalActivityPayload?
}
struct ApprovalWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> ApprovalWidgetEntry {
ApprovalWidgetEntry(
date: .now,
pendingCount: 2,
topPayload: ApprovalActivityPayload(
requestID: UUID().uuidString,
title: "github.com wants to sign in",
appName: "github.com",
source: "github.com",
handle: "@jurgen",
location: "Berlin, DE",
createdAt: .now
)
)
}
func getSnapshot(in context: Context, completion: @escaping (ApprovalWidgetEntry) -> Void) {
completion(makeEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<ApprovalWidgetEntry>) -> Void) {
let entry = makeEntry()
completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60))))
}
private func makeEntry() -> ApprovalWidgetEntry {
let state = UserDefaultsAppStateStore().load()
let pendingRequests = (state?.requests ?? [])
.filter { $0.status == .pending }
.sorted { $0.createdAt > $1.createdAt }
let handle = state?.profile.handle ?? "@you"
return ApprovalWidgetEntry(
date: .now,
pendingCount: pendingRequests.count,
topPayload: pendingRequests.first?.activityPayload(handle: handle)
)
}
}
@main
struct IDPGlobalWidgetsBundle: WidgetBundle {
var body: some Widget {
#if os(iOS)
ApprovalLiveActivityWidget()
#endif
#if os(watchOS)
ApprovalAccessoryRectangularWidget()
ApprovalAccessoryCircularWidget()
ApprovalAccessoryCornerWidget()
#endif
}
}
#if os(iOS)
struct ApproveLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Approve"
static var openAppWhenRun = false
@Parameter(title: "Request ID")
var requestID: String
init() {}
init(requestID: String) {
self.requestID = requestID
}
func perform() async throws -> some IntentResult {
guard let id = UUID(uuidString: requestID) else {
return .result()
}
_ = try? await MockIDPService.shared.approveRequest(id: id)
await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Approved")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
struct DenyLiveActivityIntent: LiveActivityIntent {
static var title: LocalizedStringResource = "Deny"
static var openAppWhenRun = false
@Parameter(title: "Request ID")
var requestID: String
init() {}
init(requestID: String) {
self.requestID = requestID
}
func perform() async throws -> some IntentResult {
guard let id = UUID(uuidString: requestID) else {
return .result()
}
_ = try? await MockIDPService.shared.rejectRequest(id: id)
await ApprovalLiveActivityActionHandler.complete(requestID: requestID, outcome: "Denied")
WidgetCenter.shared.reloadAllTimelines()
return .result()
}
}
private enum ApprovalLiveActivityActionHandler {
static func complete(requestID: String, outcome: String) async {
guard let activity = Activity<ApprovalActivityAttributes>.activities.first(where: { $0.attributes.requestID == requestID }) else {
return
}
let state = ApprovalActivityAttributes.ContentState(
requestID: requestID,
title: outcome,
appName: activity.content.state.appName,
source: activity.content.state.source,
handle: activity.content.state.handle,
location: activity.content.state.location
)
await activity.end(ActivityContent(state: state, staleDate: .now), dismissalPolicy: .immediate)
}
}
struct ApprovalLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ApprovalActivityAttributes.self) { context in
VStack(alignment: .leading, spacing: 12) {
Text(context.state.title)
.font(.headline)
Text("Sign in as \(Text(context.state.handle).foregroundStyle(.purple))")
.font(.subheadline)
Text(context.state.location)
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) {
Text("Deny")
}
.buttonStyle(.bordered)
Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) {
Text("Approve")
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
.padding(16)
.activityBackgroundTint(Color(uiColor: .systemBackground))
.activitySystemActionForegroundColor(.purple)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.purple)
}
DynamicIslandExpandedRegion(.trailing) {
MonogramBubble(title: context.state.appName)
}
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .leading, spacing: 4) {
Text(context.state.title)
.font(.headline)
Text(context.state.handle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack(spacing: 10) {
Button(intent: DenyLiveActivityIntent(requestID: context.state.requestID)) {
Text("Deny")
}
.buttonStyle(.bordered)
Button(intent: ApproveLiveActivityIntent(requestID: context.state.requestID)) {
Text("Approve")
}
.buttonStyle(.borderedProminent)
.tint(.purple)
}
}
} compactLeading: {
Image(systemName: "shield.lefthalf.filled")
.foregroundStyle(.purple)
} compactTrailing: {
MonogramBubble(title: context.state.appName)
} minimal: {
Image(systemName: "shield")
.foregroundStyle(.purple)
}
}
}
}
private struct MonogramBubble: View {
let title: String
private var letter: String {
String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased()
}
var body: some View {
ZStack {
Circle()
.fill(Color.purple.opacity(0.18))
Text(letter)
.font(.caption.weight(.bold))
.foregroundStyle(.purple)
}
.frame(width: 24, height: 24)
}
}
#endif
#if os(watchOS)
struct ApprovalAccessoryRectangularWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryRectangular", provider: ApprovalWidgetProvider()) { entry in
VStack(alignment: .leading, spacing: 3) {
Label("idp.global", systemImage: "shield.lefthalf.filled")
.font(.caption2)
Text("\(entry.pendingCount) requests")
.font(.headline)
Text(entry.topPayload?.appName ?? "Inbox")
.font(.caption2)
.foregroundStyle(.secondary)
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Queue")
.description("Pending sign-in requests.")
.supportedFamilies([.accessoryRectangular])
}
}
struct ApprovalAccessoryCircularWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryCircular", provider: ApprovalWidgetProvider()) { entry in
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 2) {
Image(systemName: "shield.lefthalf.filled")
Text("\(entry.pendingCount)")
.font(.caption2.weight(.bold))
}
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Count")
.supportedFamilies([.accessoryCircular])
}
}
struct ApprovalAccessoryCornerWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "IDPGlobalAccessoryCorner", provider: ApprovalWidgetProvider()) { entry in
Text("\(entry.pendingCount)")
.widgetCurvesContent()
.widgetLabel {
Image(systemName: "shield.lefthalf.filled")
}
.widgetURL(URL(string: "idpglobal://inbox"))
}
.configurationDisplayName("Approval Corner")
.supportedFamilies([.accessoryCorner])
}
}
#endif