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.
293 lines
9.6 KiB
Swift
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
|