import SwiftUI struct RootView: View { @EnvironmentObject private var session: Session @EnvironmentObject private var router: Router @EnvironmentObject private var display: DisplaySettings /// Über einen gescannten Universal Link geöffnetes Einzelstück. @State private var linkedItem: Item? @State private var linkError: String? var body: some View { Group { if session.isLoggedIn { HomeView() } else { LoginView() } } // Anzeigeeinstellungen (Datumsformat) gehoeren dem Server - bei einem // Serverwechsel muessen sie deshalb neu geladen werden, sonst zeigt // Server B die Formate von Server A. .task(id: session.activeProfileID) { if session.isLoggedIn { await display.load() await session.refreshMe() } } // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine // getippte Benachrichtigung öffnet „Bald ablaufend". .fullScreenCover(item: $router.route) { route in NavigationStack { switch route { case .checkin: CheckInView() case .checkout: CheckOutView() case .lookup: LookupScanView() case .assign: AssignScanView() case .expiring: ExpiringView() .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { router.route = nil } } } } } } .task(id: router.openItemUid) { guard let uid = router.openItemUid else { return } // openItemUid NICHT vor dem Abruf zurücksetzen: das ändert die // .task-id und SwiftUI bricht dadurch die laufende Anfrage ab // (URLError.cancelled). Erst nach dem Laden auf nil setzen. let host = session.activeProfile?.url?.host ?? "?" if session.isLoggedIn { do { linkedItem = try await APIClient.shared.itemByUid(uid: uid) } catch { linkError = "Einzelstück \(uid) auf Server \(host) nicht gefunden. " + "Zeigt der QR-Code auf denselben Server wie der oben gewählte? " + "(\(error.localizedDescription))" } } else { linkError = "Bitte zuerst am Server \(host) anmelden, dann erneut scannen." } router.openItemUid = nil } .sheet(item: $linkedItem) { it in NavigationStack { ItemEditView(item: it) } } .alert("Einzelstück öffnen", isPresented: Binding( get: { linkError != nil }, set: { if !$0 { linkError = nil } } )) { Button("OK", role: .cancel) { linkError = nil } } message: { Text(linkError ?? "") } } } extension Route: Identifiable { var id: String { rawValue } } struct HomeView: View { @EnvironmentObject private var session: Session var body: some View { TabView { DashboardView() .tabItem { Label("Start", systemImage: "house") } ScanTabView() .tabItem { Label("Scannen", systemImage: "barcode.viewfinder") } ListsTabView() .tabItem { Label("Listen", systemImage: "list.bullet") } // Stammdaten gehoeren Administratoren - dieselbe Regel wie in der // Web-Oberflaeche (web/src/App.jsx). if session.isAdmin { AdminTabView() .tabItem { Label("Verwaltung", systemImage: "gearshape") } } } } } /// Die beiden Kacheln, die frueher die Startseite waren. struct ScanTabView: View { @EnvironmentObject private var router: Router @EnvironmentObject private var session: Session @State private var showNew = false var body: some View { NavigationStack { VStack(spacing: 16) { Button { router.route = .checkin } label: { ActionTile(title: "Einlagern", subtitle: "Barcode scannen und Bestand erfassen", systemImage: "arrow.down.to.line") } Button { router.route = .checkout } label: { // "arrow.up.from.line" gibt es als SF Symbol nicht - das Icon // blieb dadurch leer und es war nur die Kachelflaeche zu sehen. ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen", systemImage: "arrow.up.to.line") } Button { router.route = .lookup } label: { ActionTile(title: "Nachschlagen", subtitle: "Barcode/QR scannen und nur ansehen", systemImage: "doc.text.magnifyingglass") } // Ohne Barcode: gerade bei Gegenstaenden (Kleidung, Werkzeug) gibt // es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg. if session.isAdmin { Button { showNew = true } label: { ActionTile(title: "Artikel anlegen", subtitle: "Ohne Barcode – Name, Kategorie, eigene Felder", systemImage: "plus") } Button { router.route = .assign } label: { ActionTile(title: "Ort zuordnen", subtitle: "Einzelstück-QR scannen, dann Lagerort-QR", systemImage: "mappin.and.ellipse") } } Spacer() } .padding() .navigationTitle("Scannen") .sheet(isPresented: $showNew) { NavigationStack { ProductFormView(prefillBarcode: nil, groupId: nil) { _ in showNew = false } } } } } } struct ListsTabView: View { var body: some View { NavigationStack { VStack(spacing: 16) { NavigationLink { ProductListView() } label: { ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten", systemImage: "shippingbox") } NavigationLink { ShoppingListView() } label: { ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt", systemImage: "cart") } NavigationLink { ExpiringView() } label: { ActionTile(title: "Bald ablaufend", subtitle: "Chargen mit knappem MHD", systemImage: "clock") } NavigationLink { HistoryView() } label: { ActionTile(title: "Verlauf", subtitle: "Wer hat wann was ein- und ausgelagert", systemImage: "clock.arrow.circlepath") } Spacer() } .padding() .navigationTitle("Listen") } } } struct ActionTile: View { let title: String let subtitle: String let systemImage: String var body: some View { HStack(spacing: 14) { Image(systemName: systemImage) .font(.title2) .frame(width: 44, height: 44) .background(Color.accentColor.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 2) { Text(title).font(.headline) Text(subtitle).font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.leading) } Spacer() Image(systemName: "chevron.right").foregroundStyle(.tertiary) } .padding() .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .foregroundStyle(.primary) } }