Files
swiftapp/swift/Sources/Features/Home/HomeRootView.swift
Jürgen Kunz 61a0cc1f7d
Some checks failed
CI / test (push) Has been cancelled
Overhaul native approval UX and add widget surfaces
Bring the SwiftUI app in line with the Apple-native mock and keep pending approvals actionable from Live Activities and watch complications.
2026-04-19 16:29:13 +02:00

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