Overhaul native approval UX and add widget surfaces
Some checks failed
CI / test (push) Has been cancelled
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:
292
swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift
Normal file
292
swift/WatchApp/Widgets/IDPGlobalWidgetsBundle.swift
Normal 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
|
||||
31
swift/WatchApp/Widgets/Info.plist
Normal file
31
swift/WatchApp/Widgets/Info.plist
Normal 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>
|
||||
Reference in New Issue
Block a user