Files
Vorrania/ios/Sources/ProductDetailView.swift
Scarriffle ff158a1e5f iOS: mehrere Server, TabBar, Uebersicht, Verlauf und Stammdaten
Die App konnte fuenf Dinge, die Web-Oberflaeche vierzehn. Alles darueber hinaus
- nachsehen was gestern passiert ist, einen Lagerort anlegen - zwang zurueck an
den Browser.

Mehrere Server: Statt einer Adresse und einem Token kennt die App jetzt
beliebig viele Profile, jedes mit eigenem Token im Keychain. Ein Wechsel ist
damit ein Tipp und meldet an keinem der Server ab. Jedes Profil hat einen frei
waehlbaren Namen ("Zuhause"), der oben in der App steht; ohne Angabe faellt er
auf den Rechnernamen zurueck. Eine bestehende Anmeldung wird beim ersten Start
in ein Profil ueberfuehrt - ohne das waere man nach dem Update abgemeldet.

Statt einer Startseite mit Kacheln gibt es vier Tabs: Start, Scannen, Listen
und - nur fuer Administratoren, wie im Web - Verwaltung. Neu darin sind eine
Uebersicht mit den Kennzahlen des Servers, die per Tipp in die passende Liste
fuehren, der Bewegungsverlauf (auch je Artikel, weil /movements danach filtern
kann) und die Stammdaten: Lagerorte, Einheiten, Gebinde, Kategorien und
Gruppen. Die fuenf teilen sich eine Ansicht und unterscheiden sich nur darin,
wie geladen und geschrieben wird.

Zwei Dinge, die beim Pruefen gegen einen echten Server auffielen: Der Server
schickt Zeitstempel in UTC, haengt hinter SQLite aber keine Zeitzone an. Naiv
gelesen haette die App jede Uhrzeit um die eigene Zeitverschiebung daneben
angezeigt, deshalb wird die nackte Form ausdruecklich als UTC gelesen. Und die
Anzeigeeinstellungen gehoeren dem Server, werden beim Wechsel also neu geladen.

Bewusst nicht dabei: Benutzer, Einstellungen, Branding und Import/Export. Die
bleiben vorerst im Web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 18:24:07 +02:00

300 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Produkt ansehen und ändern, dazu die Chargen korrigieren oder löschen.
struct ProductDetailView: View {
let product: Product
@EnvironmentObject private var display: DisplaySettings
@State private var current: Product
@State private var lots: [Lot] = []
@State private var busy = false
@State private var error: String?
@State private var status: String?
// Bearbeitbare Felder
@State private var name = ""
@State private var brand = ""
@State private var packageSize = ""
@State private var packageLabel = ""
@State private var datePrecision = "day"
@State private var groupId: Int?
@State private var categoryId: Int?
@State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = []
@State private var editLot: Lot?
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
init(product: Product) {
self.product = product
_current = State(initialValue: product)
}
var body: some View {
Form {
if let status {
Section { Text(status).foregroundStyle(.green).font(.callout) }
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section("Bestand") {
LabeledContent("Vorrat",
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
if current.expiredCount > 0 {
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
.foregroundStyle(.red)
}
}
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
Section("Artikel") {
LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand)
QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
Picker("MHD-Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
Section {
CategoryPicker(categories: categories, selection: $categoryId)
} footer: {
Text("Kategorie: nur für den Überblick in der Artikelliste.")
}
Section {
Picker("Gruppe", selection: $groupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
}
}
} footer: {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
+ "wandert bei einem Wechsel mit.")
}
Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "")
if !current.barcodes.isEmpty {
LabeledContent("Weitere Codes", value: "\(current.barcodes.count)")
}
}
Section {
Button(busy ? "Speichern…" : "Änderungen speichern") { Task { await save() } }
.disabled(busy || name.isEmpty)
}
Section("Chargen") {
ForEach(lots) { lot in
Button {
editLot = lot
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}
}
.foregroundStyle(.primary)
}
.onDelete { indexSet in
Task { await deleteLots(at: indexSet) }
}
if lots.isEmpty {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
}
}
Section {
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
// gefiltert.
NavigationLink { HistoryView(productId: current.id) } label: {
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
}
}
}
.navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $editLot) { lot in
NavigationStack {
LotEditView(lot: lot, product: current) {
Task { await reload() }
}
}
}
.task {
fillForm()
await reload()
}
}
private func fillForm() {
name = current.name
brand = current.brand ?? ""
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day"
groupId = current.groupId
categoryId = current.categoryId
}
private func reload() async {
groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
if let frisch = try? await APIClient.shared.product(id: current.id) {
current = frisch
}
}
private func save() async {
error = nil
status = nil
busy = true
defer { busy = false }
do {
current = try await APIClient.shared.updateProduct(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision,
groupId: groupId,
categoryId: categoryId
)
)
status = "Gespeichert."
} catch {
self.error = error.localizedDescription
}
}
private func deleteLots(at indexSet: IndexSet) async {
error = nil
for index in indexSet {
do {
try await APIClient.shared.deleteLot(id: lots[index].id)
} catch {
self.error = error.localizedDescription
}
}
await reload()
}
}
/// Menge und MHD einer Charge korrigieren.
struct LotEditView: View {
let lot: Lot
let product: Product
var onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var quantity = ""
@State private var hasDate = false
@State private var bestBefore = Date()
@State private var precision = "day"
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section("Menge") {
QuantityField(label: "Menge", text: $quantity,
suffix: product.articleUnitLabel)
}
Section("MHD") {
Toggle("MHD angeben", isOn: $hasDate)
if hasDate {
Picker("Angabe", selection: $precision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
if precision == "month" {
MonthYearPicker(date: $bestBefore)
} else {
DatePicker("MHD", selection: $bestBefore, displayedComponents: .date)
}
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy)
}
}
.navigationTitle("Charge ändern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.onAppear(perform: fill)
}
private func fill() {
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
if let raw = lot.bestBefore {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
if let datum = formatter.date(from: raw) {
bestBefore = datum
hasDate = true
}
}
}
private func save() async {
error = nil
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."
return
}
busy = true
defer { busy = false }
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let datum = precision == "month" ? bestBefore.startOfMonth : bestBefore
do {
// Das Backend rechnet Chargenmengen in Basiseinheiten.
_ = try await APIClient.shared.updateLot(
id: lot.id,
LotUpdateRequest(
quantity: menge * product.articleUnitFactor,
bestBefore: hasDate ? formatter.string(from: datum) : nil,
bestBeforePrecision: precision
)
)
onSaved()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}