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)
|
|
}
|
|
}
|