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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
892c52e49a
commit
f04d80a330
@@ -419,6 +419,22 @@ actor APIClient {
|
||||
|
||||
// MARK: - Listen
|
||||
|
||||
/// Die ganze Einkaufsliste - gerechnete Bedarfe UND eigene Eintraege.
|
||||
///
|
||||
/// Ein Aufruf statt drei, und vor allem der einzige, der die selbst
|
||||
/// draufgesetzten Posten mitbringt.
|
||||
func shoppingAll() async throws -> ShoppingListAll {
|
||||
try await send(try makeRequest("/shopping-list/all"), as: ShoppingListAll.self)
|
||||
}
|
||||
|
||||
/// Einen eigenen Eintrag abhaken oder wieder aufmachen.
|
||||
@discardableResult
|
||||
func setShoppingEntryDone(_ id: Int, done: Bool) async throws -> ShoppingEntry {
|
||||
var request = try makeRequest("/shopping-list/entries/\(id)", method: "PATCH")
|
||||
try jsonBody(&request, ["done": done])
|
||||
return try await send(request, as: ShoppingEntry.self)
|
||||
}
|
||||
|
||||
func shoppingList() async throws -> [ShoppingItem] {
|
||||
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
|
||||
}
|
||||
|
||||
@@ -226,18 +226,33 @@ struct ShoppingCard: View {
|
||||
let stand: Int
|
||||
|
||||
@State private var items: [ShoppingItem] = []
|
||||
/// Selbst draufgesetzte Posten. Ohne sie meldete die Karte, alles sei
|
||||
/// gedeckt, waehrend auf der Liste fuenf Dinge standen.
|
||||
@State private var eintraege: [ShoppingEntry] = []
|
||||
@State private var fehler: String?
|
||||
|
||||
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if items.isEmpty {
|
||||
} else if items.isEmpty && offene.isEmpty {
|
||||
Text("Alle Mindestbestände sind gedeckt.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items.prefix(5)) { item in
|
||||
// Eigene Eintraege zuerst: Sie stehen da, weil jemand sie
|
||||
// bewusst draufgesetzt hat.
|
||||
ForEach(offene.prefix(5)) { eintrag in
|
||||
HStack {
|
||||
Text(eintrag.name).font(.callout)
|
||||
Spacer()
|
||||
Text(eintrag.mengeText.isEmpty ? "selbst notiert" : eintrag.mengeText)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
ForEach(items.prefix(max(0, 5 - offene.count))) { item in
|
||||
HStack {
|
||||
Text(item.name).font(.callout)
|
||||
Spacer()
|
||||
@@ -252,7 +267,11 @@ struct ShoppingCard: View {
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
items = try await APIClient.shared.shoppingList()
|
||||
// Die gebuendelte Route: Nur sie bringt auch die selbst
|
||||
// draufgesetzten Posten mit.
|
||||
let alles = try await APIClient.shared.shoppingAll()
|
||||
items = alles.products
|
||||
eintraege = alles.entries
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
|
||||
@@ -51,14 +51,39 @@ struct ShoppingListView: View {
|
||||
@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
|
||||
@@ -103,7 +128,8 @@ struct ShoppingListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
|
||||
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
|
||||
&& offeneEintraege.isEmpty && !busy {
|
||||
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -114,13 +140,32 @@ struct ShoppingListView: View {
|
||||
.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 {
|
||||
items = try await APIClient.shared.shoppingList()
|
||||
groups = try await APIClient.shared.shoppingGroups()
|
||||
byLocation = try await APIClient.shared.shoppingByLocation()
|
||||
// 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
|
||||
|
||||
@@ -482,6 +482,59 @@ struct NewProductRequest: Codable {
|
||||
|
||||
// MARK: - Listen (Einkaufsliste, bald ablaufend)
|
||||
|
||||
/// Ein Posten, den jemand selbst auf die Einkaufsliste gesetzt hat - von Hand
|
||||
/// oder uebernommen aus einem Rezept.
|
||||
///
|
||||
/// Die gerechneten Posten (Artikel und Gruppen unter ihrem Mindestbestand)
|
||||
/// entstehen bei jedem Aufruf neu; diese hier liegen in der Datenbank und
|
||||
/// bleiben, bis sie abgehakt sind. Beides zusammen ergibt erst die Liste.
|
||||
struct ShoppingEntry: Codable, Identifiable {
|
||||
let id: Int
|
||||
/// "product" | "group" | "text"
|
||||
let art: String
|
||||
let name: String
|
||||
/// "2 Packungen" bzw. "150 g" - fertig formatiert vom Server, damit App und
|
||||
/// Weboberflaeche dieselbe Sprache sprechen.
|
||||
let mengeText: String
|
||||
let source: String?
|
||||
let recipeName: String?
|
||||
let done: Bool
|
||||
|
||||
var istNotiz: Bool { art == "text" }
|
||||
var istGruppe: Bool { art == "group" }
|
||||
|
||||
/// Zeile unter dem Namen: woher der Posten kommt und wie viel fehlt.
|
||||
var untertitel: String {
|
||||
[recipeName.map { "für \($0)" }, mengeText.isEmpty ? nil : mengeText]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, art, name, source, done
|
||||
case mengeText = "menge_text"
|
||||
case recipeName = "recipe_name"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die komplette Einkaufsliste in einer Antwort: gerechnete Bedarfe UND
|
||||
/// gespeicherte Eintraege. Die drei Einzelrouten liefern nur die gerechnete
|
||||
/// Seite - wer nur sie abfragt, sieht die selbst draufgesetzten Posten nicht.
|
||||
struct ShoppingListAll: Codable {
|
||||
let products: [ShoppingItem]
|
||||
let groups: [GroupShoppingItem]
|
||||
let byLocation: [LocationNeeds]
|
||||
let entries: [ShoppingEntry]
|
||||
/// Kennungen der abgehakten GERECHNETEN Posten. Die App zeigt sie noch
|
||||
/// nicht an, liest sie aber mit, damit das Feld nicht ueberrascht.
|
||||
let checked: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case products, groups, entries, checked
|
||||
case byLocation = "by_location"
|
||||
}
|
||||
}
|
||||
|
||||
struct ShoppingItem: Codable, Identifiable {
|
||||
let productId: Int
|
||||
let name: String
|
||||
|
||||
Reference in New Issue
Block a user