Some checks failed
CI / test (push) Has been cancelled
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
357 lines
11 KiB
Swift
357 lines
11 KiB
Swift
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?
|
|
|
|
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
|
|
)
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
.sheet(isPresented: $model.isNotificationCenterPresented) {
|
|
NotificationCenterSheet(model: model)
|
|
}
|
|
}
|
|
|
|
private var usesCompactNavigation: Bool {
|
|
#if os(iOS)
|
|
true
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct CompactHomeContainer: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
|
|
|
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()
|
|
.toolbar {
|
|
DashboardToolbar(model: model)
|
|
}
|
|
}
|
|
.tag(section)
|
|
.tabItem {
|
|
Label(section.title, systemImage: section.systemImage)
|
|
}
|
|
}
|
|
}
|
|
.cleanTabBarOnIOS()
|
|
}
|
|
|
|
private var compactLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private struct RegularHomeContainer: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
Sidebar(model: model)
|
|
.navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320)
|
|
} detail: {
|
|
HomeSectionScreen(model: model, section: model.selectedSection, compactLayout: false)
|
|
.navigationTitle(model.selectedSection.title)
|
|
.toolbar {
|
|
DashboardToolbar(model: model)
|
|
}
|
|
}
|
|
.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(
|
|
model: model,
|
|
identifyReader: identifyReader,
|
|
onScanQR: { model.isScannerPresented = true },
|
|
onShowOTP: { isOTPPresented = true }
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.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 {
|
|
@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)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowBackground(
|
|
model.selectedSection == section
|
|
? dashboardAccent.opacity(0.10)
|
|
: Color.clear
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("idp.global")
|
|
}
|
|
|
|
private func badgeCount(for section: AppSection) -> Int {
|
|
switch section {
|
|
case .overview:
|
|
0
|
|
case .requests:
|
|
model.pendingRequests.count
|
|
case .activity:
|
|
model.unreadNotificationCount
|
|
case .account:
|
|
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)
|
|
}
|
|
}
|