Some checks are pending
CI / test (push) Waiting to run
Tighten the inbox, detail, and watch layouts so approval actions feel denser and more direct across compact surfaces.
270 lines
8.8 KiB
Swift
270 lines
8.8 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: 6) {
|
|
Button {
|
|
isSearchPresented.toggle()
|
|
} label: {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.footnote.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 32, height: 32)
|
|
}
|
|
.accessibilityLabel("Search inbox")
|
|
|
|
Button {
|
|
model.selectedSection = .identity
|
|
} label: {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(Color.idpAccentSoft)
|
|
Text(initials(from: model.profile?.name))
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(IdP.tint)
|
|
}
|
|
.frame(width: 28, height: 28)
|
|
}
|
|
.accessibilityLabel("Open identity")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func initials(from name: String?) -> String {
|
|
guard let name else { return "YOU" }
|
|
let letters = name
|
|
.split(separator: " ")
|
|
.prefix(2)
|
|
.compactMap { $0.first }
|
|
return String(letters.map(Character.init)).uppercased()
|
|
}
|
|
}
|