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,317 +1,467 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OverviewPanel: View {
|
||||
struct InboxListView: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
@Binding var selectedRequestID: ApprovalRequest.ID?
|
||||
@Binding var searchText: String
|
||||
@Binding var isSearchPresented: Bool
|
||||
var usesSelection = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
OverviewHero(
|
||||
profile: profile,
|
||||
session: session,
|
||||
pendingCount: model.pendingRequests.count,
|
||||
unreadCount: model.unreadNotificationCount,
|
||||
compactLayout: compactLayout
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RequestsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
let onOpenRequest: (ApprovalRequest) -> Void
|
||||
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
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.requests.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No checks waiting",
|
||||
systemImage: "checkmark.circle",
|
||||
message: "Identity proof requests from sites and devices appear here."
|
||||
)
|
||||
}
|
||||
List {
|
||||
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"
|
||||
)
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
RequestList(
|
||||
requests: model.requests,
|
||||
compactLayout: compactLayout,
|
||||
activeRequestID: model.activeRequestID,
|
||||
onApprove: { request in
|
||||
Task { await model.approve(request) }
|
||||
},
|
||||
onReject: { request in
|
||||
Task { await model.reject(request) }
|
||||
},
|
||||
onOpenRequest: onOpenRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if model.notifications.isEmpty {
|
||||
AppPanel(compactLayout: compactLayout) {
|
||||
EmptyStateCopy(
|
||||
title: "No proof activity yet",
|
||||
systemImage: "clock.badge.xmark",
|
||||
message: "Identity proofs and security events will appear here."
|
||||
)
|
||||
ForEach(recentRequests) { request in
|
||||
row(for: request, compact: false)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
AppSectionCard(title: "Delivery", compactLayout: compactLayout) {
|
||||
NotificationPermissionSummary(model: model, compactLayout: compactLayout)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Alerts", compactLayout: compactLayout) {
|
||||
if model.notifications.isEmpty {
|
||||
EmptyStateCopy(
|
||||
title: "No alerts yet",
|
||||
systemImage: "bell.slash",
|
||||
message: "New passport and identity-proof alerts will accumulate here."
|
||||
)
|
||||
} else {
|
||||
NotificationList(
|
||||
notifications: model.notifications,
|
||||
compactLayout: compactLayout,
|
||||
onMarkRead: { notification in
|
||||
Task { await model.markNotificationRead(notification) }
|
||||
if !earlierRequests.isEmpty {
|
||||
Section {
|
||||
ForEach(earlierRequests) { request in
|
||||
row(for: request, compact: true)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountPanel: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppLayout.sectionSpacing(for: compactLayout)) {
|
||||
if let profile = model.profile, let session = model.session {
|
||||
AccountHero(profile: profile, session: session, compactLayout: compactLayout)
|
||||
|
||||
AppSectionCard(title: "Session", compactLayout: compactLayout) {
|
||||
AccountFactsGrid(profile: profile, session: session, compactLayout: compactLayout)
|
||||
}
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Pairing payload", compactLayout: compactLayout) {
|
||||
AppTextSurface(text: model.suggestedPairingPayload, monospaced: true)
|
||||
}
|
||||
|
||||
AppSectionCard(title: "Actions", compactLayout: compactLayout) {
|
||||
Button(role: .destructive) {
|
||||
model.signOut()
|
||||
} label: {
|
||||
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverviewHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let pendingCount: Int
|
||||
let unreadCount: Int
|
||||
let compactLayout: Bool
|
||||
|
||||
private var detailColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
private var metricColumns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: 3)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Digital passport", tone: dashboardAccent)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 30 : 38, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text("\(profile.handle) • \(profile.organization)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
AppStatusTag(title: "Passport active", tone: dashboardAccent)
|
||||
AppStatusTag(title: session.pairingTransport.title, tone: dashboardGold)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: detailColumns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Device", value: session.deviceName)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
LazyVGrid(columns: metricColumns, alignment: .leading, spacing: 16) {
|
||||
AppMetric(title: "Pending", value: "\(pendingCount)")
|
||||
AppMetric(title: "Alerts", value: "\(unreadCount)")
|
||||
AppMetric(title: "Devices", value: "\(profile.deviceCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NotificationPermissionSummary: View {
|
||||
@ObservedObject var model: AppViewModel
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: model.notificationPermission.systemImage)
|
||||
.font(.headline)
|
||||
.foregroundStyle(dashboardAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.notificationPermission.title)
|
||||
.font(.headline)
|
||||
Text(model.notificationPermission.summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if compactLayout {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
permissionButtons
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
permissionButtons
|
||||
} header: {
|
||||
Text("Earlier today")
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Inbox")
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id))
|
||||
.idpSearchable(text: $searchText, isPresented: $isSearchPresented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var permissionButtons: some View {
|
||||
Button {
|
||||
Task { await model.requestNotificationAccess() }
|
||||
} label: {
|
||||
Label("Enable notifications", systemImage: "bell.and.waves.left.and.right.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
Task { await model.sendTestNotification() }
|
||||
} label: {
|
||||
Label("Send test alert", systemImage: "paperplane.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountHero: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
var body: some View {
|
||||
AppPanel(compactLayout: compactLayout, radius: AppLayout.largeCardRadius) {
|
||||
AppBadge(title: "Account", tone: dashboardAccent)
|
||||
|
||||
Text(profile.name)
|
||||
.font(.system(size: compactLayout ? 28 : 34, weight: .bold, design: .rounded))
|
||||
.lineLimit(2)
|
||||
|
||||
Text(profile.handle)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Active client: \(session.deviceName)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountFactsGrid: View {
|
||||
let profile: MemberProfile
|
||||
let session: AuthSession
|
||||
let compactLayout: Bool
|
||||
|
||||
private var columns: [GridItem] {
|
||||
Array(repeating: GridItem(.flexible(), spacing: 16), count: compactLayout ? 1 : 2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: columns, alignment: .leading, spacing: 16) {
|
||||
AppKeyValue(label: "Organization", value: profile.organization)
|
||||
AppKeyValue(label: "Origin", value: session.originHost, monospaced: true)
|
||||
AppKeyValue(label: "Linked At", value: session.pairedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
AppKeyValue(label: "Method", value: session.pairingTransport.title)
|
||||
AppKeyValue(label: "Token", value: "...\(session.tokenPreview)", monospaced: true)
|
||||
AppKeyValue(label: "Recovery", value: profile.recoverySummary)
|
||||
if let signedGPSPosition = session.signedGPSPosition {
|
||||
AppKeyValue(
|
||||
label: "Signed GPS",
|
||||
value: "\(signedGPSPosition.coordinateSummary) \(signedGPSPosition.accuracySummary)",
|
||||
monospaced: true
|
||||
private func row(for request: ApprovalRequest, compact: Bool) -> some View {
|
||||
if usesSelection {
|
||||
Button {
|
||||
selectedRequestID = request.id
|
||||
Haptics.selection()
|
||||
} label: {
|
||||
ApprovalRow(
|
||||
request: request,
|
||||
handle: model.profile?.handle ?? "@you",
|
||||
compact: compact,
|
||||
highlighted: highlightedRequestID == request.id
|
||||
)
|
||||
}
|
||||
AppKeyValue(label: "Trusted Devices", value: "\(profile.deviceCount)")
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
NavigationLink(value: request.id) {
|
||||
ApprovalRow(
|
||||
request: request,
|
||||
handle: model.profile?.handle ?? "@you",
|
||||
compact: compact,
|
||||
highlighted: highlightedRequestID == request.id
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyStateCopy: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let message: String
|
||||
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 {
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: systemImage,
|
||||
description: Text(message)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
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
|
||||
@State private var isPairingCodePresented = false
|
||||
|
||||
private var devices: [DevicePresentation] {
|
||||
guard let session else { return [] }
|
||||
|
||||
let current = DevicePresentation(
|
||||
name: session.deviceName,
|
||||
systemImage: symbolName(for: session.deviceName),
|
||||
lastSeen: .now,
|
||||
isCurrent: true,
|
||||
isTrusted: true
|
||||
)
|
||||
|
||||
let others = [
|
||||
DevicePresentation(name: "Phil's iPad Pro", systemImage: "ipad", lastSeen: .now.addingTimeInterval(-60 * 18), isCurrent: false, isTrusted: true),
|
||||
DevicePresentation(name: "Berlin MacBook Pro", systemImage: "laptopcomputer", lastSeen: .now.addingTimeInterval(-60 * 74), isCurrent: false, isTrusted: true),
|
||||
DevicePresentation(name: "Apple Watch", systemImage: "applewatch", lastSeen: .now.addingTimeInterval(-60 * 180), isCurrent: false, isTrusted: false)
|
||||
]
|
||||
|
||||
let count = max((model.profile?.deviceCount ?? 1) - 1, 0)
|
||||
return [current] + Array(others.prefix(count))
|
||||
}
|
||||
|
||||
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) {
|
||||
Button("Pair another device") {
|
||||
isPairingCodePresented = true
|
||||
}
|
||||
.buttonStyle(PrimaryActionStyle())
|
||||
|
||||
Button("Sign out everywhere") {
|
||||
model.signOut()
|
||||
}
|
||||
.buttonStyle(DestructiveStyle())
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Devices")
|
||||
.sheet(isPresented: $isPairingCodePresented) {
|
||||
if let session {
|
||||
OneTimePasscodeSheet(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user