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:
@@ -1,5 +1,10 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class AppViewModel: ObservableObject {
|
||||
@@ -10,12 +15,13 @@ final class AppViewModel: ObservableObject {
|
||||
@Published var requests: [ApprovalRequest] = []
|
||||
@Published var notifications: [AppNotification] = []
|
||||
@Published var notificationPermission: NotificationPermissionState = .unknown
|
||||
@Published var selectedSection: AppSection = .overview
|
||||
@Published var selectedSection: AppSection = .inbox
|
||||
@Published var isBootstrapping = false
|
||||
@Published var isAuthenticating = false
|
||||
@Published var isIdentifying = false
|
||||
@Published var isRefreshing = false
|
||||
@Published var isNotificationCenterPresented = false
|
||||
@Published var isShowingPairingSuccess = false
|
||||
@Published var activeRequestID: ApprovalRequest.ID?
|
||||
@Published var isScannerPresented = false
|
||||
@Published var errorMessage: String?
|
||||
@@ -32,14 +38,25 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
let rawValue = String(argument.dropFirst("--mock-section=".count))
|
||||
if rawValue == "notifications" {
|
||||
return .activity
|
||||
|
||||
switch rawValue {
|
||||
case "requests", "inbox":
|
||||
return .inbox
|
||||
case "notifications", "activity":
|
||||
return .notifications
|
||||
case "devices", "account":
|
||||
return .devices
|
||||
case "identity", "overview":
|
||||
return .identity
|
||||
case "settings":
|
||||
return .settings
|
||||
default:
|
||||
return AppSection(rawValue: rawValue)
|
||||
}
|
||||
return AppSection(rawValue: rawValue)
|
||||
}
|
||||
|
||||
init(
|
||||
service: IDPServicing = MockIDPService(),
|
||||
service: IDPServicing = MockIDPService.shared,
|
||||
notificationCoordinator: NotificationCoordinating = NotificationCoordinator(),
|
||||
appStateStore: AppStateStoring = UserDefaultsAppStateStore(),
|
||||
launchArguments: [String] = ProcessInfo.processInfo.arguments
|
||||
@@ -148,15 +165,28 @@ final class AppViewModel: ObservableObject {
|
||||
isAuthenticating = true
|
||||
defer { isAuthenticating = false }
|
||||
|
||||
let wasSignedOut = session == nil
|
||||
|
||||
do {
|
||||
let result = try await service.signIn(with: normalizedRequest)
|
||||
session = result.session
|
||||
apply(snapshot: result.snapshot)
|
||||
persistCurrentState()
|
||||
notificationPermission = await notificationCoordinator.authorizationStatus()
|
||||
selectedSection = .overview
|
||||
selectedSection = .inbox
|
||||
errorMessage = nil
|
||||
isScannerPresented = false
|
||||
|
||||
if wasSignedOut {
|
||||
isShowingPairingSuccess = true
|
||||
Haptics.success()
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
guard let self, self.session != nil else { return }
|
||||
self.isShowingPairingSuccess = false
|
||||
}
|
||||
}
|
||||
} catch let error as AppError {
|
||||
errorMessage = error.errorDescription
|
||||
} catch {
|
||||
@@ -250,7 +280,7 @@ final class AppViewModel: ObservableObject {
|
||||
let snapshot = try await service.simulateIncomingRequest()
|
||||
apply(snapshot: snapshot)
|
||||
persistCurrentState()
|
||||
selectedSection = .requests
|
||||
selectedSection = .inbox
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = "Unable to create a mock identity check right now."
|
||||
@@ -296,9 +326,36 @@ final class AppViewModel: ObservableObject {
|
||||
profile = nil
|
||||
requests = []
|
||||
notifications = []
|
||||
selectedSection = .overview
|
||||
selectedSection = .inbox
|
||||
manualPairingPayload = suggestedPairingPayload
|
||||
isShowingPairingSuccess = false
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await ApprovalActivityController.endAll()
|
||||
#if canImport(WidgetKit)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func openDeepLink(_ url: URL) {
|
||||
let destination = (url.host ?? url.lastPathComponent).lowercased()
|
||||
|
||||
switch destination {
|
||||
case "inbox":
|
||||
selectedSection = .inbox
|
||||
case "notifications":
|
||||
selectedSection = .notifications
|
||||
case "devices":
|
||||
selectedSection = .devices
|
||||
case "identity":
|
||||
selectedSection = .identity
|
||||
case "settings":
|
||||
selectedSection = .settings
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateRequest(_ request: ApprovalRequest, approve: Bool) async {
|
||||
@@ -352,8 +409,20 @@ final class AppViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func apply(snapshot: DashboardSnapshot) {
|
||||
profile = snapshot.profile
|
||||
requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.9)) {
|
||||
self.profile = snapshot.profile
|
||||
self.requests = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
self.notifications = snapshot.notifications.sorted { $0.sentAt > $1.sentAt }
|
||||
}
|
||||
|
||||
let profileValue = snapshot.profile
|
||||
let requestsValue = snapshot.requests.sorted { $0.createdAt > $1.createdAt }
|
||||
|
||||
Task {
|
||||
await ApprovalActivityController.sync(requests: requestsValue, profile: profileValue)
|
||||
#if canImport(WidgetKit)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(ActivityKit) && os(iOS)
|
||||
import ActivityKit
|
||||
|
||||
enum ApprovalActivityController {
|
||||
static func sync(requests: [ApprovalRequest], profile: MemberProfile) async {
|
||||
let pendingRequest = requests.first(where: { $0.status == .pending })
|
||||
|
||||
guard let pendingRequest else {
|
||||
await endAll()
|
||||
return
|
||||
}
|
||||
|
||||
let payload = pendingRequest.activityPayload(handle: profile.handle)
|
||||
let contentState = ApprovalActivityAttributes.ContentState(
|
||||
requestID: payload.requestID,
|
||||
title: payload.title,
|
||||
appName: payload.appName,
|
||||
source: payload.source,
|
||||
handle: payload.handle,
|
||||
location: payload.location
|
||||
)
|
||||
let content = ActivityContent(state: contentState, staleDate: pendingRequest.activityExpiryDate)
|
||||
|
||||
if let currentActivity = Activity<ApprovalActivityAttributes>.activities.first(where: { $0.attributes.requestID == payload.requestID }) {
|
||||
await currentActivity.update(content)
|
||||
} else {
|
||||
do {
|
||||
_ = try Activity.request(
|
||||
attributes: ApprovalActivityAttributes(requestID: payload.requestID, createdAt: payload.createdAt),
|
||||
content: content
|
||||
)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
for activity in Activity<ApprovalActivityAttributes>.activities where activity.attributes.requestID != payload.requestID {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {
|
||||
guard let profile else {
|
||||
await endAll()
|
||||
return
|
||||
}
|
||||
|
||||
await sync(requests: requests, profile: profile)
|
||||
}
|
||||
|
||||
static func endAll() async {
|
||||
for activity in Activity<ApprovalActivityAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
enum ApprovalActivityController {
|
||||
static func sync(requests: [ApprovalRequest], profile: MemberProfile?) async {}
|
||||
static func endAll() async {}
|
||||
}
|
||||
#endif
|
||||
@@ -5,9 +5,27 @@ struct IDPGlobalApp: App {
|
||||
@StateObject private var model = AppViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView(model: model)
|
||||
.tint(AppTheme.accent)
|
||||
#if os(macOS)
|
||||
MenuBarExtra("idp.global", systemImage: "shield.lefthalf.filled") {
|
||||
RootSceneContent(model: model)
|
||||
.frame(minWidth: 400, minHeight: 560)
|
||||
.tint(IdP.tint)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
.alert("Something went wrong", isPresented: errorPresented) {
|
||||
Button("OK") {
|
||||
model.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
#else
|
||||
WindowGroup {
|
||||
RootSceneContent(model: model)
|
||||
.tint(IdP.tint)
|
||||
.task {
|
||||
await model.bootstrap()
|
||||
}
|
||||
@@ -19,8 +37,6 @@ struct IDPGlobalApp: App {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.defaultSize(width: 1380, height: 920)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -36,19 +52,47 @@ struct IDPGlobalApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
private struct RootView: View {
|
||||
private struct RootSceneContent: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.session == nil {
|
||||
LoginRootView(model: model)
|
||||
} else if model.isShowingPairingSuccess {
|
||||
PairingSuccessView()
|
||||
} else {
|
||||
#if os(macOS)
|
||||
MenuBarPopover(model: model)
|
||||
#else
|
||||
HomeRootView(model: model)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.background {
|
||||
AppBackground()
|
||||
Color.idpGroupedBackground.ignoresSafeArea()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
model.openDeepLink(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PairingSuccessView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 72, weight: .semibold))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("Passport linked")
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text("Your device is ready to approve sign-ins.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user