iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
diesem Rechner ist kein Xcode vorhanden.
Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
fragt bei den zerstoerenden Modi nach.
Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
"vor 2 Wochen", dazu heute/morgen/gestern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
4.8 KiB
Swift
137 lines
4.8 KiB
Swift
import SwiftUI
|
||
|
||
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
|
||
struct ProductSearchView: View {
|
||
var onPick: (Product) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var query = ""
|
||
@State private var results: [Product] = []
|
||
@State private var busy = false
|
||
|
||
var body: some View {
|
||
List {
|
||
ForEach(results) { product in
|
||
Button {
|
||
onPick(product)
|
||
dismiss()
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(product.name)
|
||
Text("\(formatted(product.stockInArticleUnits)) \(product.articleUnitLabel)")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
if results.isEmpty && !busy {
|
||
Text("Keine Treffer").foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.searchable(text: $query, prompt: "Artikel suchen")
|
||
.onSubmit(of: .search) { Task { await search() } }
|
||
.task { await search() }
|
||
.onChange(of: query) { _, _ in Task { await search() } }
|
||
.navigationTitle("Artikel suchen")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||
}
|
||
}
|
||
|
||
private func formatted(_ value: Double) -> String {
|
||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||
}
|
||
|
||
private func search() async {
|
||
busy = true
|
||
defer { busy = false }
|
||
results = (try? await APIClient.shared.searchProducts(query)) ?? []
|
||
}
|
||
}
|
||
|
||
/// Neues Produkt anlegen – bei Bedarf mit den Daten von Open Food Facts vorbefüllt.
|
||
struct ProductFormView: View {
|
||
let prefillBarcode: String?
|
||
let groupId: Int?
|
||
var suggestion: LookupResult.Suggestion?
|
||
var onCreated: (Product) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var barcode = ""
|
||
@State private var name = ""
|
||
@State private var brand = ""
|
||
@State private var baseUnit = "piece"
|
||
@State private var packageSize = ""
|
||
@State private var packageLabel = ""
|
||
@State private var busy = false
|
||
@State private var error: String?
|
||
|
||
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
|
||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section("Artikel") {
|
||
TextField("Barcode", text: $barcode).keyboardType(.numberPad)
|
||
TextField("Name", text: $name)
|
||
TextField("Marke", text: $brand)
|
||
}
|
||
Section("Einheit") {
|
||
Picker("Basiseinheit", selection: $baseUnit) {
|
||
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
|
||
}
|
||
TextField("Packungsgröße (in Basiseinheit)", text: $packageSize)
|
||
.keyboardType(.decimalPad)
|
||
Picker("Bezeichnung", selection: $packageLabel) {
|
||
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
|
||
}
|
||
}
|
||
if let error {
|
||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||
}
|
||
Section {
|
||
Button(busy ? "Anlegen…" : "Anlegen & weiter") { Task { await create() } }
|
||
.disabled(busy || name.isEmpty)
|
||
}
|
||
}
|
||
.navigationTitle("Neuer Artikel")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.onAppear(perform: prefill)
|
||
}
|
||
|
||
private func prefill() {
|
||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||
if let suggestion {
|
||
name = suggestion.name
|
||
brand = suggestion.brand ?? ""
|
||
baseUnit = suggestion.baseUnit ?? "piece"
|
||
if let size = suggestion.packageSize { packageSize = String(size) }
|
||
}
|
||
}
|
||
|
||
private func create() async {
|
||
error = nil
|
||
busy = true
|
||
defer { busy = false }
|
||
do {
|
||
let product = try await APIClient.shared.createProduct(
|
||
NewProductRequest(
|
||
barcode: barcode.isEmpty ? nil : barcode,
|
||
name: name,
|
||
brand: brand.isEmpty ? nil : brand,
|
||
imageUrl: suggestion?.imageUrl,
|
||
baseUnit: baseUnit,
|
||
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
|
||
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
|
||
groupId: groupId
|
||
)
|
||
)
|
||
onCreated(product)
|
||
dismiss()
|
||
} catch {
|
||
self.error = error.localizedDescription
|
||
}
|
||
}
|
||
}
|