Die App zeigte "Bald ablaufend" nur beim Oeffnen. Jetzt erinnert sie aktiv vor dem Ablauf - mit frei zusammenstellbaren Regeln, pro Server getrennt. Eine Regel ist "X Tage/Wochen vor Ablauf", optional nur fuer eine Kategorie (inklusive deren Unterkategorien), einmalig oder taeglich wiederkehrend. Eine Kategorie-Regel ersetzt fuer ihre Produkte die allgemeine - so kann Molkerei frueher warnen als Chips. Dazu am Ablauftag eine letzte, deutliche Warnung fuer alle Produkte (eigener Schalter). Die Meldungen kommen als Tages-Sammelmeldung, je Server eine, zur pro Server eingestellten Uhrzeit. Weil Vorrania im Heimnetz laeuft und von unterwegs oft nicht erreichbar ist, kann die App nicht zuverlaessig im Hintergrund abfragen - und iOS verlangt ohnehin, dass lokale Benachrichtigungen im Voraus geplant werden. Also: aus den Ablaufdaten kuenftige Termine berechnen und einplanen; aufgefrischt beim Oeffnen, im Vordergrund und best-effort per Hintergrund-Aktualisierung. Geplant wird fuer alle eingerichteten Server, jeder mit seinem Token aus dem Keychain; unerreichbare werden uebersprungen. iOS erlaubt 64 ausstehende Meldungen je App, deshalb die naechstliegenden zuerst und bei 60 gekappt. Einstellungen sitzen hinter der Glocke je Server in der Server-Verwaltung (nicht admin-beschraenkt, es ist eine Geraete-Einstellung). Getippt wechselt die Meldung auf den betroffenen Server und oeffnet "Bald ablaufend". Keine Backend-Aenderung: die Kategorie kommt aus /products, der Ablauf aus /expiring. Die Rechenlogik (ExpiryPlanner) ist bewusst netz- und iOS-frei und mit einem eigenstaendigen Swift-Harnisch geprueft: Kategorie-Vorrang, Unterkategorien, Rueckfall auf die allgemeine Regel, einmalig vs. wiederkehrend und die letzte Warnung. Modelle gegen echte /expiring- und /products-Antworten abgeglichen, App startet ohne Absturz (auch die BGTask-Registrierung). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
5.4 KiB
Swift
164 lines
5.4 KiB
Swift
import SwiftUI
|
|
|
|
struct RootView: View {
|
|
@EnvironmentObject private var session: Session
|
|
@EnvironmentObject private var router: Router
|
|
@EnvironmentObject private var display: DisplaySettings
|
|
|
|
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 .expiring:
|
|
ExpiringView()
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("Fertig") { router.route = nil }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
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")
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.navigationTitle("Scannen")
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|