Server liefert /.well-known/apple-app-site-association (nur /i/* beansprucht; /l/* bleibt im Browser). iOS: Associated-Domains-Entitlement, Universal Link (https://…/i/<UID>) wird abgefangen (onContinueUserActivity) und öffnet das Einzelstück direkt. Hinweis: Damit das Signieren durchläuft, muss die Capability "Associated Domains" einmalig im Apple-Developer-Portal für die App-ID aktiv sein. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
7.5 KiB
Swift
210 lines
7.5 KiB
Swift
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?
|
||
|
||
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 }
|
||
linkedItem = try? await APIClient.shared.itemByUid(uid: uid)
|
||
router.openItemUid = nil
|
||
}
|
||
.sheet(item: $linkedItem) { it in
|
||
NavigationStack { ItemEditView(item: it) }
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|