Overhaul native approval UX and add widget surfaces
Some checks failed
CI / test (push) Has been cancelled

Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
This commit is contained in:
2026-04-19 16:29:13 +02:00
parent a6939453f8
commit 61a0cc1f7d
63 changed files with 3496 additions and 1769 deletions

View File

@@ -0,0 +1,292 @@
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

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>idp.global Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IDPGlobalWidgetsBundle</string>
</dict>
</dict>
</plist>