Files
swiftapp/swift/Sources/Features/Home/HomePanels.swift
Jürgen Kunz 61a0cc1f7d
Some checks failed
CI / test (push) Has been cancelled
Overhaul native approval UX and add widget surfaces
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
2026-04-19 16:29:13 +02:00

468 lines
16 KiB
Swift

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
}
var body: some View {
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 {
ForEach(recentRequests) { request in
row(for: request, compact: false)
.transition(.move(edge: .top).combined(with: .opacity))
}
if !earlierRequests.isEmpty {
Section {
ForEach(earlierRequests) { request in
row(for: request, compact: true)
.transition(.move(edge: .top).combined(with: .opacity))
}
} 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 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
)
}
.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)
}
}
}
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
@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
)
}
}