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,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user