import SwiftUI struct InboxListView: View { @ObservedObject var model: AppViewModel @Binding var selectedRequestID: ApprovalRequest.ID? @Binding var searchText: String @Binding var isSearchPresented: Bool var usesSelection = false private var filteredRequests: [ApprovalRequest] { guard !searchText.isEmpty else { return model.requests } return model.requests.filter { $0.inboxTitle.localizedCaseInsensitiveContains(searchText) || $0.source.localizedCaseInsensitiveContains(searchText) || $0.subtitle.localizedCaseInsensitiveContains(searchText) } } private var recentRequests: [ApprovalRequest] { filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) <= 60 * 30 } } private var earlierRequests: [ApprovalRequest] { filteredRequests.filter { Date.now.timeIntervalSince($0.createdAt) > 60 * 30 } } private var highlightedRequestID: ApprovalRequest.ID? { filteredRequests.first?.id } private var oldestPendingMinutes: Int? { guard let oldest = model.pendingRequests.min(by: { $0.createdAt < $1.createdAt }) else { return nil } return max(1, Int(Date.now.timeIntervalSince(oldest.createdAt)) / 60) } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 10, pinnedViews: []) { InboxHeader( pendingCount: model.pendingRequests.count, oldestMinutes: oldestPendingMinutes ) .padding(.horizontal, 4) .padding(.bottom, 6) if filteredRequests.isEmpty { EmptyPaneView( title: "No sign-in requests", message: "New approval requests will appear here as soon as a relying party asks for proof.", systemImage: "tray" ) .padding(.top, 40) } else { ForEach(recentRequests) { request in row(for: request, compact: false) .transition(.move(edge: .top).combined(with: .opacity)) } if !earlierRequests.isEmpty { Text("Earlier today") .font(.caption2.weight(.semibold)) .tracking(0.5) .textCase(.uppercase) .foregroundStyle(Color.idpMutedForeground) .padding(.top, 12) .padding(.leading, 4) ForEach(earlierRequests) { request in row(for: request, compact: true) .transition(.move(edge: .top).combined(with: .opacity)) } } } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 120) } .scrollIndicators(.hidden) .background(Color.idpBackground.ignoresSafeArea()) .navigationTitle("Inbox") .animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id)) #if !os(macOS) .searchable(text: $searchText, isPresented: $isSearchPresented, placement: .navigationBarDrawer(displayMode: .automatic)) #else .searchable(text: $searchText, isPresented: $isSearchPresented) #endif } @ViewBuilder private func row(for request: ApprovalRequest, compact: Bool) -> some View { let handle = model.profile?.handle ?? "@you" let highlighted = highlightedRequestID == request.id let isBusy = model.activeRequestID == request.id let rowContent = ApprovalRow( request: request, handle: handle, compact: compact, highlighted: highlighted, onApprove: compact ? nil : { Task { await model.approve(request) } }, onDeny: compact ? nil : { Haptics.warning() Task { await model.reject(request) } }, isBusy: isBusy ) if usesSelection { Button { selectedRequestID = request.id Haptics.selection() } label: { rowContent } .buttonStyle(.plain) } else { NavigationLink(value: request.id) { rowContent } .buttonStyle(.plain) } } } private struct InboxHeader: View { let pendingCount: Int let oldestMinutes: Int? var body: some View { HStack(spacing: 10) { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.idpPrimary) Image(systemName: "shield.lefthalf.filled") .font(.caption.weight(.semibold)) .foregroundStyle(Color.idpPrimaryForeground) } .frame(width: 24, height: 24) Text("idp.global") .font(.subheadline.weight(.semibold)) Spacer() if pendingCount > 0 { ShadcnBadge(title: "\(pendingCount) pending", tone: .ok) if let oldestMinutes { Text("oldest \(oldestMinutes) min ago") .font(.caption) .foregroundStyle(Color.idpMutedForeground) } } else { ShadcnBadge(title: "all clear", tone: .ok) } } } } struct NotificationCenterView: View { @ObservedObject var model: AppViewModel private var groupedNotifications: [(String, [AppNotification])] { let calendar = Calendar.current let groups = Dictionary(grouping: model.notifications) { calendar.startOfDay(for: $0.sentAt) } return groups .keys .sorted(by: >) .map { day in (sectionTitle(for: day), groups[day]?.sorted(by: { $0.sentAt > $1.sentAt }) ?? []) } } var body: some View { Group { if model.notifications.isEmpty { EmptyPaneView( title: "All clear", message: "You'll see new sign-in requests here.", systemImage: "shield" ) } else { List { if model.notificationPermission == .unknown || model.notificationPermission == .denied { NotificationPermissionCard(model: model) .listRowInsets(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) .listRowSeparator(.hidden) .listRowBackground(Color.clear) } ForEach(groupedNotifications, id: \.0) { section in Section { ForEach(section.1) { notification in Button { guard notification.isUnread else { return } Task { await model.markNotificationRead(notification) } } label: { NotificationEventRow(notification: notification) } .buttonStyle(.plain) } } header: { Text(section.0) .textCase(nil) } } } .listStyle(.plain) } } .navigationTitle("Notifications") } private func sectionTitle(for date: Date) -> String { if Calendar.current.isDateInToday(date) { return "Today" } if Calendar.current.isDateInYesterday(date) { return "Yesterday" } return date.formatted(.dateTime.month(.wide).day()) } } struct DevicesView: View { @ObservedObject var model: AppViewModel private var devices: [DevicePresentation] { guard let session else { return [] } if !model.devices.isEmpty { return model.devices.map { device in DevicePresentation( name: device.label, systemImage: symbolName(for: device.label), lastSeen: device.lastSeenAt ?? session.pairedAt, isCurrent: device.isCurrent, isTrusted: true ) } } return [ DevicePresentation( name: session.deviceName, systemImage: symbolName(for: session.deviceName), lastSeen: .now, isCurrent: true, isTrusted: true ) ] } private var session: AuthSession? { model.session } var body: some View { Form { Section("This device") { if let current = devices.first { DeviceItemRow(device: current) } } Section("Other devices ยท \(max(devices.count - 1, 0))") { ForEach(Array(devices.dropFirst())) { device in DeviceItemRow(device: device) } } Section { VStack(spacing: 12) { Text("Start new device pairing from your idp.global web session, then scan the fresh pairing QR in this app.") .font(.footnote) .foregroundStyle(.secondary) Button("Sign out everywhere") { model.signOut() } .buttonStyle(DestructiveStyle()) } .padding(.vertical, 6) } } .navigationTitle("Devices") } private func symbolName(for deviceName: String) -> String { let lowercased = deviceName.lowercased() if lowercased.contains("ipad") { return "ipad" } if lowercased.contains("watch") { return "applewatch" } if lowercased.contains("mac") || lowercased.contains("safari") { return "laptopcomputer" } return "iphone" } } struct IdentityView: View { @ObservedObject var model: AppViewModel var body: some View { Form { if let profile = model.profile { Section("Identity") { LabeledContent("Name", value: profile.name) LabeledContent("Handle", value: profile.handle) LabeledContent("Organization", value: profile.organization) } Section("Recovery") { Text(profile.recoverySummary) .font(.body) } } if let session = model.session { Section("Session") { LabeledContent("Device", value: session.deviceName) LabeledContent("Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened)) LabeledContent("Origin", value: session.originHost) LabeledContent("Transport", value: session.pairingTransport.title) } Section("Pairing payload") { Text(session.pairingCode) .font(.footnote.monospaced()) .textSelection(.enabled) } } } .navigationTitle("Identity") } } struct SettingsView: View { @ObservedObject var model: AppViewModel var body: some View { Form { Section("Alerts") { LabeledContent("Notifications", value: model.notificationPermission.title) Button("Enable Notifications") { Task { await model.requestNotificationAccess() } } .buttonStyle(SecondaryActionStyle()) Button("Send Test Notification") { Task { await model.sendTestNotification() } } .buttonStyle(SecondaryActionStyle()) } Section("Demo") { Button("Simulate Incoming Request") { Task { await model.simulateIncomingRequest() } } .buttonStyle(PrimaryActionStyle()) Button("Refresh") { Task { await model.refreshDashboard() } } .buttonStyle(SecondaryActionStyle()) } } .navigationTitle("Settings") } } enum PreviewFixtures { static let profile = MemberProfile( name: "Jurgen Meyer", handle: "@jurgen", organization: "idp.global", deviceCount: 4, recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." ) static let session = AuthSession( deviceName: "iPhone 17 Pro", originHost: "github.com", pairedAt: .now.addingTimeInterval(-60 * 90), tokenPreview: "berlin", pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=github.com&device=iPhone%2017%20Pro", pairingTransport: .preview ) static let requests: [ApprovalRequest] = [ ApprovalRequest( title: "Prove identity for GitHub", subtitle: "GitHub is asking for a routine sign-in confirmation.", source: "github.com", createdAt: .now.addingTimeInterval(-60 * 4), kind: .signIn, risk: .routine, scopes: ["email", "profile", "session:read"], status: .pending ), ApprovalRequest( title: "Prove identity for workspace", subtitle: "Your secure workspace needs a stronger proof before unlocking.", source: "workspace.idp.global", createdAt: .now.addingTimeInterval(-60 * 42), kind: .elevatedAction, risk: .elevated, scopes: ["profile", "device", "location"], status: .pending ), ApprovalRequest( title: "CLI session", subtitle: "A CLI login was completed earlier today.", source: "cli.idp.global", createdAt: .now.addingTimeInterval(-60 * 120), kind: .signIn, risk: .routine, scopes: ["profile"], status: .approved ) ] static let notifications: [AppNotification] = [ AppNotification( title: "GitHub sign-in approved", message: "Your latest sign-in request for github.com was approved.", sentAt: .now.addingTimeInterval(-60 * 9), kind: .approval, isUnread: true ), AppNotification( title: "Recovery check passed", message: "Backup recovery channels were verified in the last 24 hours.", sentAt: .now.addingTimeInterval(-60 * 110), kind: .system, isUnread: false ), AppNotification( title: "Session expired", message: "A pending workstation approval expired before it could be completed.", sentAt: .now.addingTimeInterval(-60 * 1_500), kind: .security, isUnread: false ) ] @MainActor static func model() -> AppViewModel { let model = AppViewModel( service: MockIDPService.shared, notificationCoordinator: PreviewNotificationCoordinator(), appStateStore: PreviewStateStore(), launchArguments: [] ) model.session = session model.profile = profile model.requests = requests model.notifications = notifications model.selectedSection = .inbox model.manualPairingPayload = session.pairingCode model.suggestedPairingPayload = session.pairingCode model.notificationPermission = .allowed return model } } private struct PreviewNotificationCoordinator: NotificationCoordinating { func authorizationStatus() async -> NotificationPermissionState { .allowed } func requestAuthorization() async throws -> NotificationPermissionState { .allowed } func scheduleTestNotification(title: String, body: String) async throws {} } private struct PreviewStateStore: AppStateStoring { func load() -> PersistedAppState? { nil } func save(_ state: PersistedAppState) {} func clear() {} } #Preview("Inbox Light") { NavigationStack { InboxPreviewHost() } } #Preview("Inbox Dark") { NavigationStack { InboxPreviewHost() } .preferredColorScheme(.dark) } @MainActor private struct InboxPreviewHost: View { @State private var selectedRequestID = PreviewFixtures.requests.first?.id @State private var searchText = "" @State private var isSearchPresented = false @State private var model = PreviewFixtures.model() var body: some View { InboxListView( model: model, selectedRequestID: $selectedRequestID, searchText: $searchText, isSearchPresented: $isSearchPresented ) } }