Overhaul native approval UX and add widget surfaces
CI / test (push) Has been cancelled

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:
2026-04-19 16:29:13 +02:00
parent a6939453f8
commit 61a0cc1f7d
63 changed files with 3496 additions and 1769 deletions
+182 -275
View File
@@ -1,78 +1,73 @@
import SwiftUI
let dashboardAccent = AppTheme.accent
let dashboardGold = AppTheme.warmAccent
extension View {
@ViewBuilder
func inlineNavigationTitleOnIOS() -> some View {
#if os(iOS)
navigationBarTitleDisplayMode(.inline)
#else
self
#endif
}
@ViewBuilder
func cleanTabBarOnIOS() -> some View {
#if os(iOS)
toolbarBackground(.visible, for: .tabBar)
.toolbarBackground(AppTheme.chromeFill, for: .tabBar)
#else
self
#endif
}
}
struct HomeRootView: View {
@ObservedObject var model: AppViewModel
@State private var notificationBellFrame: CGRect?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedRequestID: ApprovalRequest.ID?
@State private var searchText = ""
@State private var isSearchPresented = false
var body: some View {
Group {
if usesCompactNavigation {
CompactHomeContainer(model: model)
} else {
RegularHomeContainer(model: model)
}
}
.onPreferenceChange(NotificationBellFrameKey.self) { notificationBellFrame = $0 }
.overlay(alignment: .topLeading) {
if usesCompactNavigation {
NotificationBellBadgeOverlay(
unreadCount: model.unreadNotificationCount,
bellFrame: notificationBellFrame
if usesRegularNavigation {
RegularHomeContainer(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented
)
} else {
CompactHomeContainer(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented
)
.ignoresSafeArea()
}
}
.sheet(isPresented: $model.isNotificationCenterPresented) {
NotificationCenterSheet(model: model)
.onAppear(perform: syncSelection)
.onChange(of: model.requests.map(\.id)) { _, _ in
syncSelection()
}
}
private var usesCompactNavigation: Bool {
private var usesRegularNavigation: Bool {
#if os(iOS)
true
horizontalSizeClass == .regular
#else
false
#endif
}
private func syncSelection() {
if let selectedRequestID,
model.requests.contains(where: { $0.id == selectedRequestID }) {
return
}
selectedRequestID = model.pendingRequests.first?.id ?? model.requests.first?.id
}
}
private struct CompactHomeContainer: View {
@ObservedObject var model: AppViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var body: some View {
TabView(selection: $model.selectedSection) {
ForEach(AppSection.allCases) { section in
NavigationStack {
HomeSectionScreen(model: model, section: section, compactLayout: compactLayout)
.navigationTitle(section.title)
.inlineNavigationTitleOnIOS()
sectionContent(for: section)
.navigationDestination(for: ApprovalRequest.ID.self) { requestID in
ApprovalDetailView(model: model, requestID: requestID, dismissOnResolve: true)
}
.toolbar {
DashboardToolbar(model: model)
if section == .inbox {
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
}
}
}
.tag(section)
@@ -81,239 +76,130 @@ private struct CompactHomeContainer: View {
}
}
}
.cleanTabBarOnIOS()
.idpTabBarChrome()
}
private var compactLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
@ViewBuilder
private func sectionContent(for section: AppSection) -> some View {
switch section {
case .inbox:
InboxListView(
model: model,
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented,
usesSelection: false
)
case .notifications:
NotificationCenterView(model: model)
case .devices:
DevicesView(model: model)
case .identity:
IdentityView(model: model)
case .settings:
SettingsView(model: model)
}
}
}
private struct RegularHomeContainer: View {
@ObservedObject var model: AppViewModel
@Binding var selectedRequestID: ApprovalRequest.ID?
@Binding var searchText: String
@Binding var isSearchPresented: Bool
var body: some View {
NavigationSplitView {
Sidebar(model: model)
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
SidebarView(model: model)
.navigationSplitViewColumnWidth(min: 250, ideal: 280, max: 320)
} content: {
contentColumn
} detail: {
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
.navigationTitle(model.selectedSection.title)
.toolbar {
DashboardToolbar(model: model)
}
detailColumn
}
.navigationSplitViewStyle(.balanced)
}
}
private struct DashboardToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
var body: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
NotificationBellButton(model: model)
}
}
}
struct NotificationBellFrameKey: PreferenceKey {
static var defaultValue: CGRect? = nil
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
value = nextValue() ?? value
}
}
private struct NotificationBellBadgeOverlay: View {
let unreadCount: Int
let bellFrame: CGRect?
var body: some View {
GeometryReader { proxy in
if unreadCount > 0, let bellFrame {
let rootFrame = proxy.frame(in: .global)
Text("\(min(unreadCount, 9))")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.frame(minWidth: 18, minHeight: 18)
.padding(.horizontal, 3)
.background(Color.orange, in: Capsule())
.position(
x: bellFrame.maxX - rootFrame.minX - 2,
y: bellFrame.minY - rootFrame.minY + 2
)
}
}
.allowsHitTesting(false)
}
}
private struct HomeSectionScreen: View {
@ObservedObject var model: AppViewModel
let section: AppSection
let compactLayout: Bool
@State private var focusedRequest: ApprovalRequest?
@State private var isOTPPresented = false
@StateObject private var identifyReader = NFCIdentifyReader()
var body: some View {
AppScrollScreen(
compactLayout: compactLayout,
bottomPadding: compactLayout ? AppLayout.compactBottomDockPadding : AppLayout.regularBottomPadding
) {
HomeTopActions(
@ViewBuilder
private var contentColumn: some View {
switch model.selectedSection {
case .inbox:
InboxListView(
model: model,
identifyReader: identifyReader,
onScanQR: { model.isScannerPresented = true },
onShowOTP: { isOTPPresented = true }
selectedRequestID: $selectedRequestID,
searchText: $searchText,
isSearchPresented: $isSearchPresented,
usesSelection: true
)
.toolbar {
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
}
case .notifications:
NotificationCenterView(model: model)
case .devices:
DevicesView(model: model)
case .identity:
IdentityView(model: model)
case .settings:
SettingsView(model: model)
}
}
switch section {
case .overview:
OverviewPanel(model: model, compactLayout: compactLayout)
case .requests:
RequestsPanel(model: model, compactLayout: compactLayout, onOpenRequest: { focusedRequest = $0 })
case .activity:
ActivityPanel(model: model, compactLayout: compactLayout)
case .account:
AccountPanel(model: model, compactLayout: compactLayout)
}
}
.task {
identifyReader.onAuthenticationRequestDetected = { request in
Task {
await model.identifyWithNFC(request)
}
}
identifyReader.onError = { message in
model.errorMessage = message
}
}
.sheet(item: $focusedRequest) { request in
RequestDetailSheet(request: request, model: model)
}
.sheet(isPresented: $model.isScannerPresented) {
QRScannerSheet(
seededPayload: model.session?.pairingCode ?? model.suggestedPairingPayload,
title: "Scan proof QR",
description: "Use the camera to scan an idp.global QR challenge from the site or device asking you to prove that it is really you.",
navigationTitle: "Scan Proof QR",
onCodeScanned: { payload in
Task {
await model.identifyWithPayload(payload, transport: .qr)
}
}
@ViewBuilder
private var detailColumn: some View {
switch model.selectedSection {
case .inbox:
ApprovalDetailView(model: model, requestID: selectedRequestID)
case .notifications:
EmptyPaneView(
title: "Notification history",
message: "Select the inbox to review request context side by side.",
systemImage: "bell"
)
case .devices:
EmptyPaneView(
title: "Trusted hardware",
message: "Device trust and last-seen state appear here while you manage your passport.",
systemImage: "desktopcomputer"
)
case .identity:
EmptyPaneView(
title: "Identity overview",
message: "Your profile, recovery status, and pairing state stay visible here.",
systemImage: "person.crop.rectangle.stack"
)
case .settings:
EmptyPaneView(
title: "Preferences",
message: "Notification delivery and demo controls live in settings.",
systemImage: "gearshape"
)
}
.sheet(isPresented: $isOTPPresented) {
if let session = model.session {
OneTimePasscodeSheet(session: session)
}
}
}
}
private struct HomeTopActions: View {
@ObservedObject var model: AppViewModel
@ObservedObject var identifyReader: NFCIdentifyReader
let onScanQR: () -> Void
let onShowOTP: () -> Void
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
identifyButton
qrButton
otpButton
}
}
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 12), count: 3)
}
private var identifyButton: some View {
Button {
identifyReader.beginScanning()
} label: {
AppActionTile(
title: identifyReader.isScanning ? "Scanning NFC" : "Tap NFC",
systemImage: "dot.radiowaves.left.and.right",
tone: dashboardAccent,
isBusy: identifyReader.isScanning || model.isIdentifying
)
}
.buttonStyle(.plain)
.disabled(identifyReader.isScanning || !identifyReader.isSupported || model.isIdentifying)
}
private var qrButton: some View {
Button {
onScanQR()
} label: {
AppActionTile(
title: "Scan QR",
systemImage: "qrcode.viewfinder",
tone: dashboardAccent
)
}
.buttonStyle(.plain)
}
private var otpButton: some View {
Button {
onShowOTP()
} label: {
AppActionTile(
title: "OTP",
systemImage: "number.square.fill",
tone: dashboardGold
)
}
.buttonStyle(.plain)
}
}
private struct Sidebar: View {
struct SidebarView: View {
@ObservedObject var model: AppViewModel
var body: some View {
List {
Section {
SidebarStatusCard(
profile: model.profile,
pendingCount: model.pendingRequests.count,
unreadCount: model.unreadNotificationCount
)
}
Section("Workspace") {
ForEach(AppSection.allCases) { section in
Button {
model.selectedSection = section
} label: {
HStack {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
AppStatusTag(title: "\(badgeCount(for: section))", tone: dashboardAccent)
}
ForEach(Array(AppSection.allCases.enumerated()), id: \.element.id) { index, section in
Button {
model.selectedSection = section
Haptics.selection()
} label: {
HStack(spacing: 12) {
Label(section.title, systemImage: section.systemImage)
Spacer()
if badgeCount(for: section) > 0 {
StatusPill(title: "\(badgeCount(for: section))", color: IdP.tint)
}
}
.buttonStyle(.plain)
.listRowBackground(
model.selectedSection == section
? dashboardAccent.opacity(0.10)
: Color.clear
)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.listRowBackground(model.selectedSection == section ? IdP.tint.opacity(0.08) : Color.clear)
.keyboardShortcut(shortcut(for: index), modifiers: .command)
}
}
.navigationTitle("idp.global")
@@ -321,36 +207,57 @@ private struct Sidebar: View {
private func badgeCount(for section: AppSection) -> Int {
switch section {
case .overview:
0
case .requests:
case .inbox:
model.pendingRequests.count
case .activity:
case .notifications:
model.unreadNotificationCount
case .account:
case .devices:
max((model.profile?.deviceCount ?? 1) - 1, 0)
case .identity, .settings:
0
}
}
}
private struct SidebarStatusCard: View {
let profile: MemberProfile?
let pendingCount: Int
let unreadCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Digital Passport")
.font(.headline)
Text(profile?.handle ?? "No passport active")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
AppStatusTag(title: "\(pendingCount) pending", tone: dashboardAccent)
AppStatusTag(title: "\(unreadCount) unread", tone: dashboardGold)
}
}
.padding(.vertical, 6)
private func shortcut(for index: Int) -> KeyEquivalent {
let value = max(1, min(index + 1, 9))
return KeyEquivalent(Character("\(value)"))
}
}
private struct InboxToolbar: ToolbarContent {
@ObservedObject var model: AppViewModel
@Binding var isSearchPresented: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .idpTrailingToolbar) {
HStack(spacing: 8) {
Button {
isSearchPresented = true
} label: {
Image(systemName: "magnifyingglass")
.font(.headline)
.foregroundStyle(.primary)
}
.accessibilityLabel("Search inbox")
Button {
model.selectedSection = .identity
} label: {
MonogramAvatar(title: model.profile?.name ?? "idp.global", size: 28)
}
.accessibilityLabel("Open identity")
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.clear)
.idpGlassChrome()
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
}
}
}