Some checks failed
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.
264 lines
8.5 KiB
Swift
264 lines
8.5 KiB
Swift
import SwiftUI
|
|
|
|
struct HomeRootView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
@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 usesRegularNavigation {
|
|
RegularHomeContainer(
|
|
model: model,
|
|
selectedRequestID: $selectedRequestID,
|
|
searchText: $searchText,
|
|
isSearchPresented: $isSearchPresented
|
|
)
|
|
} else {
|
|
CompactHomeContainer(
|
|
model: model,
|
|
selectedRequestID: $selectedRequestID,
|
|
searchText: $searchText,
|
|
isSearchPresented: $isSearchPresented
|
|
)
|
|
}
|
|
}
|
|
.onAppear(perform: syncSelection)
|
|
.onChange(of: model.requests.map(\.id)) { _, _ in
|
|
syncSelection()
|
|
}
|
|
}
|
|
|
|
private var usesRegularNavigation: Bool {
|
|
#if os(iOS)
|
|
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
|
|
@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 {
|
|
sectionContent(for: section)
|
|
.navigationDestination(for: ApprovalRequest.ID.self) { requestID in
|
|
ApprovalDetailView(model: model, requestID: requestID, dismissOnResolve: true)
|
|
}
|
|
.toolbar {
|
|
if section == .inbox {
|
|
InboxToolbar(model: model, isSearchPresented: $isSearchPresented)
|
|
}
|
|
}
|
|
}
|
|
.tag(section)
|
|
.tabItem {
|
|
Label(section.title, systemImage: section.systemImage)
|
|
}
|
|
}
|
|
}
|
|
.idpTabBarChrome()
|
|
}
|
|
|
|
@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 {
|
|
SidebarView(model: model)
|
|
.navigationSplitViewColumnWidth(min: 250, ideal: 280, max: 320)
|
|
} content: {
|
|
contentColumn
|
|
} detail: {
|
|
detailColumn
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var contentColumn: some View {
|
|
switch model.selectedSection {
|
|
case .inbox:
|
|
InboxListView(
|
|
model: model,
|
|
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)
|
|
}
|
|
}
|
|
|
|
@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"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SidebarView: View {
|
|
@ObservedObject var model: AppViewModel
|
|
|
|
var body: some View {
|
|
List {
|
|
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)
|
|
}
|
|
}
|
|
.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")
|
|
}
|
|
|
|
private func badgeCount(for section: AppSection) -> Int {
|
|
switch section {
|
|
case .inbox:
|
|
model.pendingRequests.count
|
|
case .notifications:
|
|
model.unreadNotificationCount
|
|
case .devices:
|
|
max((model.profile?.deviceCount ?? 1) - 1, 0)
|
|
case .identity, .settings:
|
|
0
|
|
}
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|
|
}
|