diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 5b73b4f..238f35c 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -197,6 +197,14 @@ def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]: def shopping_list( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[ShoppingItem]: + """Artikel unter ihrem „Ueberall"-Mindestbestand – NUR die gerechneten. + + Diese Route ist ein Drittel der Einkaufsliste. Was jemand selbst + draufgesetzt hat (eigene Eintraege, uebernommene Rezeptzutaten) steht hier + nicht, ebenso wenig Gruppen und Bedarfe je Lagerort. Wer „die Liste" will, + nimmt ``/shopping-list/all`` – diese Aufteilung hat schon zweimal jemanden + glauben lassen, es sei nichts da. + """ return einkaufsliste_artikel(db) diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index c342ea5..7285d03 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -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) } diff --git a/ios/Sources/DashboardCards.swift b/ios/Sources/DashboardCards.swift index 42ddb4e..19d6dcf 100644 --- a/ios/Sources/DashboardCards.swift +++ b/ios/Sources/DashboardCards.swift @@ -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 diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 52ca28f..d2fa6a8 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -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 diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 3e0948e..ac60058 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -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