import SwiftUI struct ApprovalDetailView: View { @ObservedObject var model: AppViewModel let requestID: ApprovalRequest.ID? var dismissOnResolve = false @Environment(\.dismiss) private var dismiss private var request: ApprovalRequest? { guard let requestID else { return nil } return model.requests.first(where: { $0.id == requestID }) } var body: some View { Group { if let request { VStack(spacing: 0) { RequestHeroCard( request: request, handle: model.profile?.handle ?? "@you" ) .padding(.horizontal, 16) .padding(.top, 16) Form { Section("Context") { LabeledContent("From device", value: request.deviceSummary) LabeledContent("Location", value: request.locationSummary) LabeledContent("Network", value: request.networkSummary) LabeledContent("IP") { Text(request.ipSummary) .monospacedDigit() } } Section("Will share") { ForEach(request.scopes, id: \.self) { scope in Label(scope, systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } Section("Trust signals") { TrustSignalBanner(request: request) } } .scrollContentBackground(.hidden) .background(Color.idpGroupedBackground) } .background(Color.idpGroupedBackground) .navigationTitle(request.appDisplayName) .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .idpTrailingToolbar) { IdPGlassCapsule(padding: EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) { Text(request.expiresAt, style: .timer) .font(.caption.weight(.semibold)) .monospacedDigit() } } } .safeAreaInset(edge: .bottom) { if request.status == .pending { HStack(spacing: 12) { Button("Deny") { Task { await performReject(request) } } .buttonStyle(SecondaryActionStyle()) HoldToApproveButton(isBusy: model.activeRequestID == request.id) { await performApprove(request) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background { Rectangle() .fill(.clear) .idpGlassChrome() } } } .background { keyboardShortcuts(for: request) } } else { EmptyPaneView( title: "Nothing selected", message: "Choose a sign-in request from the inbox to review the full context.", systemImage: "checkmark.circle" ) } } } @ViewBuilder private func keyboardShortcuts(for request: ApprovalRequest) -> some View { Group { Button("Approve") { Task { await performApprove(request) } } .keyboardShortcut(.return, modifiers: .command) .hidden() .accessibilityHidden(true) Button("Deny") { Task { await performReject(request) } } .keyboardShortcut(.delete, modifiers: .command) .hidden() .accessibilityHidden(true) } } private func performApprove(_ request: ApprovalRequest) async { guard model.activeRequestID != request.id else { return } await model.approve(request) if dismissOnResolve { dismiss() } } private func performReject(_ request: ApprovalRequest) async { guard model.activeRequestID != request.id else { return } Haptics.warning() await model.reject(request) if dismissOnResolve { dismiss() } } } struct RequestDetailSheet: View { let request: ApprovalRequest @ObservedObject var model: AppViewModel var body: some View { NavigationStack { ApprovalDetailView(model: model, requestID: request.id, dismissOnResolve: true) } } } struct HoldToApproveButton: View { var title = "Hold to approve" var isBusy = false let action: () async -> Void @State private var progress: CGFloat = 0 var body: some View { ZStack { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .fill(isBusy ? Color.secondary.opacity(0.24) : IdP.tint) RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .stroke(Color.white.opacity(0.16), lineWidth: 1) label .padding(.horizontal, 20) .padding(.vertical, 14) GeometryReader { geometry in RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) .stroke(Color.white.opacity(0.85), style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(2) } } .frame(minHeight: 52) .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 20, pressing: updateProgress) { guard !isBusy else { return } Task { Haptics.success() await action() progress = 0 } } .accessibilityAddTraits(.isButton) .accessibilityLabel(title) .accessibilityHint("Press and hold to approve this request.") } @ViewBuilder private var label: some View { if isBusy { ProgressView() .tint(.white) } else { Text(title) .font(.headline) .foregroundStyle(.white) } } private func updateProgress(_ isPressing: Bool) { guard !isBusy else { return } withAnimation(.linear(duration: isPressing ? 0.6 : 0.15)) { progress = isPressing ? 1 : 0 } } } struct NFCSheet: View { var title = "Hold near reader" var message = "Tap to confirm sign-in. Your location will be signed and sent." var actionTitle = "Approve" let onSubmit: (PairingAuthenticationRequest) async -> Void @Environment(\.dismiss) private var dismiss @StateObject private var reader = NFCIdentifyReader() @State private var pendingRequest: PairingAuthenticationRequest? @State private var isSubmitting = false @State private var pulse = false private var isPreview: Bool { ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } var body: some View { VStack(spacing: 24) { ZStack { ForEach(0..<3, id: \.self) { index in Circle() .stroke(IdP.tint.opacity(0.16), lineWidth: 1.5) .frame(width: 88 + CGFloat(index * 34), height: 88 + CGFloat(index * 34)) .scaleEffect(pulse ? 1.08 : 0.92) .opacity(pulse ? 0.2 : 0.6) .animation(.easeInOut(duration: 1.4).repeatForever().delay(Double(index) * 0.12), value: pulse) } Image(systemName: "wave.3.right") .font(.system(size: 34, weight: .semibold)) .foregroundStyle(IdP.tint) } .frame(height: 160) VStack(spacing: 8) { Text(title) .font(.title3.weight(.semibold)) Text(message) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } VStack(spacing: 12) { Button("Cancel") { dismiss() } .buttonStyle(SecondaryActionStyle()) Button(primaryTitle) { guard let pendingRequest else { return } Task { isSubmitting = true await onSubmit(pendingRequest) isSubmitting = false dismiss() } } .buttonStyle(PrimaryActionStyle()) .disabled(pendingRequest == nil || isSubmitting) } } .padding(24) .presentationDetents([.medium]) .presentationDragIndicator(.visible) .task { pulse = true reader.onAuthenticationRequestDetected = { request in pendingRequest = request Haptics.selection() } reader.onError = { _ in } guard !isPreview else { return } reader.beginScanning() } } private var primaryTitle: String { if isSubmitting { return "Approving…" } return pendingRequest == nil ? "Waiting…" : actionTitle } } struct OneTimePasscodeSheet: View { let session: AuthSession @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { TimelineView(.periodic(from: .now, by: 1)) { context in let code = OneTimePasscodeGenerator.code(for: session.pairingCode, at: context.date) let secondsRemaining = OneTimePasscodeGenerator.renewalCountdown(at: context.date) VStack(alignment: .leading, spacing: 18) { Text("One-time pairing code") .font(.title3.weight(.semibold)) Text("Use this code on the next device you want to pair with your idp.global passport.") .font(.subheadline) .foregroundStyle(.secondary) Text(code) .font(.system(size: 42, weight: .bold, design: .rounded).monospacedDigit()) .tracking(5) .frame(maxWidth: .infinity) .padding(.vertical, 18) .background(Color.idpSecondaryGroupedBackground, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) HStack { StatusPill(title: "Renews in \(secondsRemaining)s", color: IdP.tint) StatusPill(title: session.originHost, color: .secondary) } Spacer() } .padding(24) } .navigationTitle("Pair Device") .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } } } } } struct MenuBarPopover: View { @ObservedObject var model: AppViewModel @State private var notificationsPaused = false @State private var isPairingCodePresented = false var body: some View { VStack(alignment: .leading, spacing: 18) { header if let request = model.pendingRequests.first { RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") } else { EmptyPaneView( title: "Inbox clear", message: "New sign-in requests will appear here.", systemImage: "shield" ) .approvalCard() } if model.pendingRequests.count > 1 { VStack(alignment: .leading, spacing: 6) { Text("Queued") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(model.pendingRequests.dropFirst().prefix(3)) { request in ApprovalRow(request: request, handle: model.profile?.handle ?? "@you", compact: true) } } } Divider() VStack(spacing: 8) { Button { model.selectedSection = .inbox } label: { MenuRowLabel(title: "Open inbox", systemImage: "tray.full") } .buttonStyle(.plain) .keyboardShortcut("o", modifiers: .command) Button { isPairingCodePresented = true } label: { MenuRowLabel(title: "Pair new device", systemImage: "plus.viewfinder") } .buttonStyle(.plain) .keyboardShortcut("n", modifiers: .command) Button { notificationsPaused.toggle() Haptics.selection() } label: { MenuRowLabel(title: notificationsPaused ? "Resume notifications" : "Pause notifications", systemImage: notificationsPaused ? "bell.badge" : "bell.slash") } .buttonStyle(.plain) Button { model.selectedSection = .settings } label: { MenuRowLabel(title: "Preferences", systemImage: "gearshape") } .buttonStyle(.plain) .keyboardShortcut(",", modifiers: .command) } } .padding(20) .sheet(isPresented: $isPairingCodePresented) { if let session = model.session { OneTimePasscodeSheet(session: session) } } } private var header: some View { HStack(alignment: .center, spacing: 12) { Image(systemName: "shield.lefthalf.filled") .font(.title2) .foregroundStyle(IdP.tint) VStack(alignment: .leading, spacing: 2) { Text("idp.global") .font(.headline) StatusPill(title: "Connected", color: .green) } Spacer() } } } private struct MenuRowLabel: View { let title: String let systemImage: String var body: some View { HStack(spacing: 12) { Image(systemName: systemImage) .frame(width: 18) .foregroundStyle(IdP.tint) Text(title) Spacer() } .padding(.vertical, 6) .contentShape(Rectangle()) } } #Preview("Approval Detail Light") { NavigationStack { ApprovalDetailPreviewHost() } } #Preview("Approval Detail Dark") { NavigationStack { ApprovalDetailPreviewHost() } .preferredColorScheme(.dark) } #Preview("NFC Sheet Light") { NFCSheet { _ in } } #Preview("NFC Sheet Dark") { NFCSheet { _ in } .preferredColorScheme(.dark) } #Preview("Request Hero Card Light") { RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) .padding() } #Preview("Request Hero Card Dark") { RequestHeroCard(request: PreviewFixtures.requests[0], handle: PreviewFixtures.profile.handle) .padding() .preferredColorScheme(.dark) } #if os(macOS) #Preview("Menu Bar Popover Light") { MenuBarPopover(model: PreviewFixtures.model()) .frame(width: 420) } #Preview("Menu Bar Popover Dark") { MenuBarPopover(model: PreviewFixtures.model()) .frame(width: 420) .preferredColorScheme(.dark) } #endif @MainActor private struct ApprovalDetailPreviewHost: View { @State private var model = PreviewFixtures.model() var body: some View { ApprovalDetailView(model: model, requestID: PreviewFixtures.requests.first?.id) } }