Files
Vorrania/ios/Sources/ListViews.swift
T
ScarriffleandClaude Opus 5 f04d80a330 App: die selbst draufgesetzten Posten fehlten in der Einkaufsliste
Die Einkaufsliste besteht aus zwei Haelften: gerechnete Bedarfe (Artikel
und Gruppen unter ihrem Mindestbestand, Bedarfe je Lagerort) und das,
was jemand selbst draufgesetzt hat - eigene Eintraege und die aus einem
Rezept uebernommenen Zutaten.

Die App kannte nur die erste Haelfte. Sie fragte /shopping-list,
/shopping-list/groups und /shopping-list/by-location ab - drei Routen,
die ausschliesslich rechnen. Die gespeicherten Eintraege liegen in
/shopping-list/entries und kommen gebuendelt ueber /shopping-list/all;
danach hat die App nie gefragt. Ergebnis: Man setzt im Web etwas auf die
Liste, nimmt das Telefon mit in den Laden, und es ist nicht da.

Jetzt holt die App die gebuendelte Route - ein Aufruf statt drei - und
zeigt die eigenen Posten in einem eigenen Abschnitt "Selbst
draufgesetzt", mit Herkunft ("fuer Pfannkuchen") und Menge. Antippen
hakt ab: Der Posten verschwindet sofort, geht das Speichern schief,
kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.

Dieselbe Blindstelle hatte die Dashboard-Karte der App; sie meldete
"alle Mindestbestaende sind gedeckt", waehrend Posten auf der Liste
standen. Auch sie nimmt jetzt die gebuendelte Route.

Dazu ein Hinweis im Docstring von GET /shopping-list: Diese Route ist
ein Drittel der Liste, wer "die Liste" will, nimmt /all. Die Aufteilung
hat in zwei Tagen zweimal jemanden glauben lassen, es sei nichts da -
beim zweiten Mal war es die App selbst.

Gegen den laufenden Server geprueft: Mit einem Eintrag "Backpapier"
liefert /shopping-list weiterhin [] und /shopping-list/all den Eintrag
samt art, name, menge_text, source, recipe_name und done - genau die
Felder, die das neue Swift-Modell liest.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind
Klammer- und Anfuehrungsbalance, dass die drei alten Routen niemand mehr
aufruft, und dass die benutzten Hilfsfunktionen (makeRequest, jsonBody,
send) wirklich so heissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:24:58 +02:00

652 lines
25 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
/// Gemeinsame Zahlenformatierung der Listen.
func formatAmount(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}
/// Kennzeichnung "Gruppe" an einer Listenzeile.
struct GroupBadge: View {
var body: some View {
Text("Gruppe")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15))
.clipShape(Capsule())
.foregroundStyle(.secondary)
}
}
/// Zeile mit Symbol links, damit Artikel und Gruppe unterscheidbar sind.
struct ListRow<Trailing: View>: View {
let systemImage: String
let title: String
let subtitle: String
@ViewBuilder var trailing: Trailing
var body: some View {
HStack(spacing: 12) {
Image(systemName: systemImage)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(title)
trailing
}
Text(subtitle)
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
// MARK: - Einkaufsliste
struct ShoppingListView: View {
@EnvironmentObject private var display: DisplaySettings
@State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
/// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen.
@State private var entries: [ShoppingEntry] = []
@State private var busy = true
@State private var error: String?
/// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum
/// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die
/// Weboberflaeche.
private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } }
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
if !offeneEintraege.isEmpty {
Section("Selbst draufgesetzt") {
ForEach(offeneEintraege) { eintrag in
Button {
Task { await hake(eintrag) }
} label: {
ListRow(
systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox",
title: eintrag.name,
subtitle: eintrag.untertitel
) {
if eintrag.istGruppe { GroupBadge() }
}
}
.buttonStyle(.plain)
}
}
}
if !items.isEmpty {
Section("Artikel unter Mindestbestand") {
ForEach(items) { item in
ListRow(
systemImage: "shippingbox",
title: item.name,
subtitle: "fehlt \(display.amountText(item.deficit, packageSize: item.packageSize, baseUnit: item.baseUnit))"
) { EmptyView() }
}
}
}
if !groups.isEmpty {
Section("Gruppen unter Mindestbestand") {
ForEach(groups) { group in
ListRow(
systemImage: "square.stack.3d.up",
title: group.name,
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
+ " · \(group.productCount) Artikel"
+ ((group.subgroupCount ?? 0) > 0
? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen")
: "")
) { GroupBadge() }
}
}
}
ForEach(byLocation) { ort in
Section("Bedarf: \(ort.locationName)") {
ForEach(ort.products) { p in
ListRow(
systemImage: "shippingbox",
title: p.name,
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
) { EmptyView() }
}
ForEach(ort.groups) { g in
ListRow(
systemImage: "square.stack.3d.up",
title: g.name,
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
) { GroupBadge() }
}
}
}
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
&& offeneEintraege.isEmpty && !busy {
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Einkaufsliste")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
}
/// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief,
/// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
private func hake(_ eintrag: ShoppingEntry) async {
let vorher = entries
entries.removeAll { $0.id == eintrag.id }
do {
try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true)
} catch {
entries = vorher
self.error = error.localizedDescription
}
}
private func load() async {
busy = true
defer { busy = false }
do {
// EIN Aufruf statt drei - und der einzige, der die selbst
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
// nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die
// eigenen Eintraege ueberhaupt nicht.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
groups = alles.groups
byLocation = alles.byLocation
entries = alles.entries
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Bald ablaufend
struct ExpiringView: View {
@EnvironmentObject private var display: DisplaySettings
@State private var items: [ExpiringItem] = []
@State private var busy = true
@State private var error: String?
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ForEach(items) { item in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(item.productName)
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision)) · \(articleAmount(item))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(item.restlaufzeitText)
.font(.caption).bold()
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
}
}
if items.isEmpty && !busy {
Text("Nichts läuft demnächst ab.").foregroundStyle(.secondary)
}
}
.navigationTitle("Bald ablaufend")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
}
private func load() async {
busy = true
defer { busy = false }
do {
items = try await APIClient.shared.expiring()
error = nil
} catch {
self.error = error.localizedDescription
}
}
/// Gebinde ist die Leitangabe, sonst die Produkteinheit.
private func articleAmount(_ item: ExpiringItem) -> String {
if let size = item.packageSize, size > 0 {
return "\(formatAmount(item.quantity / size)) \(item.packageLabel ?? "Packung")"
}
let faktor = item.unitFactor == 0 ? 1 : item.unitFactor
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
}
}
// MARK: - Mindestbestände
/// Übersicht aller Mindestbestände – nach LAGERORT gegliedert.
///
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. „Überall" (egal wo,
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
/// in die Erfassungseinheit des Ziels umgerechnet.
///
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe – beide lassen
/// sich dort bearbeiten.
struct MinStockView: View {
@State private var products: [Product] = []
@State private var groups: [GroupItem] = []
@State private var busy = true
@State private var error: String?
private struct MinRow: Identifiable {
let id: String
let title: String
let soll: String
let bestand: String?
let productId: Int? // Tippziel (Artikel)
let groupId: Int? // Tippziel (Gruppe)
let unter: Bool
let untergruppen: Int
}
private struct OrtBlock: Identifiable {
let id: String
let name: String
let zeilen: [MinRow]
}
/// Alle Zeilen, gebündelt je Ort. „Überall" (Ort nil) steht zuerst.
private var bloecke: [OrtBlock] {
var jeOrt: [String: [MinRow]] = [:]
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
let key = locationId ?? UEBERALL_ID
if let n = locationName, locationId != nil { namen[key] = n }
jeOrt[key, default: []].append(row)
}
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
for l in (p.locationMinStocks ?? []) {
merken(l.locationId, l.locationName, MinRow(
id: "p\(p.id)l\(l.id)", title: p.name,
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
productId: p.id, groupId: nil,
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
}
}
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
let einheit = g.minEinheit
for l in (g.locationMinStocks ?? []) {
merken(l.locationId, l.locationName, MinRow(
id: "g\(g.id)l\(l.id)", title: g.name,
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
productId: nil, groupId: g.id,
unter: (l.stock ?? 0) < l.minStock,
untergruppen: g.childIds?.count ?? 0))
}
}
// „Überall" zuerst, danach die Lagerorte alphabetisch.
let schluessel = jeOrt.keys.sorted { a, b in
if a == UEBERALL_ID { return true }
if b == UEBERALL_ID { return false }
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
}
return schluessel.map {
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
zeilen: jeOrt[$0] ?? [])
}
}
var body: some View {
List {
if !busy && bloecke.isEmpty {
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
}
ForEach(bloecke) { block in
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
ForEach(block.zeilen) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
NavigationLink {
GroupEditor(item: g, all: groups) { Task { await load() } }
} label: { zeile(r) }
} else {
zeile(r)
}
}
}
}
}
.navigationTitle("Mindestbestände")
.navigationBarTitleDisplayMode(.inline)
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
.refreshable { await load() }
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
@ViewBuilder
private func zeile(_ r: MinRow) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(r.title)
if r.groupId != nil {
Text("Gruppe").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.marke.opacity(0.2), in: Capsule())
}
if r.untergruppen > 0 {
Text("inkl. \(r.untergruppen)").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
}
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if r.unter {
Text("niedrig").font(.caption2)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.2), in: Capsule())
}
}
}
private func load() async {
busy = true
defer { busy = false }
do {
async let ps = APIClient.shared.searchProducts("")
async let gs = APIClient.shared.groups()
products = try await ps
groups = try await gs
Task { await ProductImageCache.shared.preload(products) }
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Produktliste
struct ProductListView: View {
/// nil = alle, "food" = nur Lebensmittel, "object" = nur Gegenstände.
/// Getrennte Listen wie im Web (dort über `fixedType`/`p.tracking`).
var fixedType: String? = nil
@EnvironmentObject private var session: Session
@State private var query = ""
@State private var products: [Product] = []
@State private var categories: [CategoryItem] = []
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
@State private var categoryId: Int?
@State private var sortMode: SortMode = .nameAsc
/// nil = alle; sonst "count"/"individual"/"bulk" (nur in der Gegenstände-Liste).
@State private var verwaltungFilter: String?
@State private var busy = false
@State private var showNew = false
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
@State private var angelegt: Product?
@State private var einzelstueckeFuer: Product?
enum SortMode: String, CaseIterable, Identifiable {
case nameAsc = "Name A–Z"
case nameDesc = "Name Z–A"
case updated = "Zuletzt geändert"
var id: String { rawValue }
}
private let verwaltungOptionen: [(String, String)] = [
("count", "Menge je Lagerort"),
("individual", "Einzelstücke"),
("bulk", "Verbrauchsgegenstand"),
]
/// Verwaltungsart eines Gegenstands.
private func verwaltung(_ p: Product) -> String {
p.isIndividual ? "individual" : (p.isBulk ? "bulk" : "count")
}
/// Auf den festen Typ und ggf. die Verwaltungsart gefiltert (wie im Web).
private var sichtbar: [Product] {
products.filter { p in
if let fixedType, (p.isObject ? "object" : "food") != fixedType { return false }
if let vf = verwaltungFilter, verwaltung(p) != vf { return false }
return true
}
}
/// Sichtbare Produkte in der gewählten Reihenfolge.
private var sortiert: [Product] {
switch sortMode {
case .nameAsc:
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .nameDesc:
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedDescending }
case .updated:
// ISO-Strings gleichen Formats sind chronologisch vergleichbar; neueste zuerst.
return sichtbar.sorted { ($0.updatedAt ?? "") > ($1.updatedAt ?? "") }
}
}
/// Nur Kategorien des Listentyps (Lebensmittel-Liste zeigt nur Food-Kategorien usw.).
private var typKategorien: [CategoryItem] {
guard let fixedType else { return categories }
return categories.filter { ($0.tracking ?? "food") == fixedType }
}
private var titel: String {
switch fixedType {
case "food": return "Lebensmittel"
case "object": return "Gegenstände"
default: return "Produkte"
}
}
private var filterLabel: String {
if categoryId == 0 { return "Ohne Kategorie" }
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
return treffer.name
}
return "Alle Kategorien"
}
var body: some View {
List {
Section {
Menu {
Button("Alle Kategorien") { pick(nil) }
Button("Ohne Kategorie") { pick(0) }
Divider()
ForEach(categoryTree()) { eintrag in
Button(eintrag.label) { pick(eintrag.item.id) }
}
} label: {
HStack {
Label("Kategorie", systemImage: "line.3.horizontal.decrease.circle")
Spacer()
Text(filterLabel).foregroundStyle(.secondary)
}
}
if fixedType == "object" {
Picker(selection: $verwaltungFilter) {
Text("Alle").tag(String?.none)
ForEach(verwaltungOptionen, id: \.0) { opt in
Text(opt.1).tag(String?.some(opt.0))
}
} label: {
Label("Verwaltung", systemImage: "square.grid.2x2")
}
.pickerStyle(.menu)
}
Picker(selection: $sortMode) {
ForEach(SortMode.allCases) { Text($0.rawValue).tag($0) }
} label: {
Label("Sortierung", systemImage: "arrow.up.arrow.down")
}
.pickerStyle(.menu)
}
Section {
ForEach(sortiert) { product in
NavigationLink {
ProductDetailView(product: product)
} label: {
ProductRow(product: product)
}
}
if sichtbar.isEmpty && !busy {
Text("Keine Treffer").foregroundStyle(.secondary)
}
}
}
.searchable(text: $query, prompt: "Artikel suchen")
.onChange(of: query) { _ in Task { await load() } }
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.toolbar {
if session.isAdmin {
ToolbarItem(placement: .topBarTrailing) {
Button { showNew = true } label: {
Label("Artikel anlegen", systemImage: "plus")
}
}
}
}
.sheet(isPresented: $showNew, onDismiss: {
if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt }
angelegt = nil
}) {
NavigationStack {
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
// vorbelegen. Nach dem Anlegen schließt sich das Formular, die
// Liste wird aufgefrischt – und bei Gegenständen mit eigener UID
// je Stück geht es gleich weiter zum Anlegen der Einzelstücke.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
angelegt = created
showNew = false
Task { await load() }
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { showNew = false }
}
}
}
}
.einzelstueckeNachAnlegen($einzelstueckeFuer)
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
// Einzelstücke) sofort stimmt und nicht „0" hängen bleibt.
.onAppear { Task { await load() } }
}
private func pick(_ id: Int?) {
categoryId = id
Task { await load() }
}
/// Kategorien in Baumreihenfolge mit eingerueckter Beschriftung.
private struct TreeEntry: Identifiable {
let item: CategoryItem
let depth: Int
var id: Int { item.id }
var label: String { String(repeating: " ", count: depth) + item.name }
}
private func categoryTree() -> [TreeEntry] {
let liste = typKategorien
let ids = Set(liste.map(\.id))
var result: [TreeEntry] = []
func walk(_ node: CategoryItem, _ depth: Int) {
result.append(TreeEntry(item: node, depth: depth))
for child in liste.filter({ $0.parentId == node.id }) {
walk(child, depth + 1)
}
}
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result
}
private func load() async {
busy = true
defer { busy = false }
let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
products = geladen
// Bilder im Hintergrund sicherstellen – nur fehlende/geänderte werden geholt,
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
Task { await ProductImageCache.shared.preload(geladen) }
}
}
/// Artikelzeile mit Gebinde und Hinweis auf den Mindestbestand.
struct ProductRow: View {
let product: Product
var body: some View {
HStack(spacing: 12) {
ProductThumb(productId: product.id)
VStack(alignment: .leading, spacing: 2) {
Text(product.name)
Text(bestand)
.font(.caption).foregroundStyle(.secondary)
if let hinweis = mindestbestand {
Text(hinweis).font(.caption2).foregroundStyle(farbe)
}
}
Spacer()
}
}
private var bestand: String {
var text = "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)"
// Bei einem Gebinde die Basismenge dahinter, damit "2 Glas" greifbar wird.
if let size = product.packageSize, size > 0 {
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
}
return text
}
private var mindestbestand: String? {
guard let anzeige = product.minStockDisplay else { return nil }
let einheit = product.minStockUnitLabel ?? ""
switch product.stockLevel {
case .below: return "unter Mindestbestand (\(formatAmount(anzeige)) \(einheit))"
case .close: return "bald nachkaufen (Minimum \(formatAmount(anzeige)) \(einheit))"
case .ok, .none: return nil
}
}
private var farbe: Color {
product.stockLevel == .below ? .red : .orange
}
}