iOS: getrennte Lebensmittel/Gegenstaende-Listen + anklickbarer Verlauf

Listen-Tab: zwei halbe Kacheln "Lebensmittel" und "Gegenstaende" (ProductListView
mit fixedType, clientseitig ueber isObject gefiltert wie im Web); Neuanlage
uebernimmt den Typ. Einzelstuecke bleiben unveraendert (reine Gegenstands-Welt).

Verlauf und "Letzte Bewegungen" auf der Startseite: Tippen auf eine Bewegung
oeffnet ueber den neuen Lader ProductByIdView das jeweilige Produkt. Der
artikel-eigene Verlauf bleibt ohne Link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-28 07:29:49 +02:00
parent c28ac7d7dd
commit 0ea188374a
6 changed files with 119 additions and 13 deletions

View File

@@ -330,8 +330,14 @@ struct MovementsCard: View {
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(movements.prefix(5)) { movement in
// Tippen auf eine Bewegung öffnet das jeweilige Produkt.
NavigationLink {
ProductByIdView(productId: movement.productId)
} label: {
MovementRow(movement: movement)
}
.buttonStyle(.plain)
}
NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
}
}

View File

@@ -23,7 +23,17 @@ struct HistoryView: View {
ForEach(tage, id: \.key) { tag in
Section(display.formatDay(tag.eintraege[0].createdAt)) {
ForEach(tag.eintraege) { movement in
MovementRow(movement: movement, showProduct: productId == nil)
// Im Gesamtverlauf führt jede Zeile zum Produkt; im
// Artikel-eigenen Verlauf ist man schon dort (kein Link).
if productId == nil {
NavigationLink {
ProductByIdView(productId: movement.productId)
} label: {
MovementRow(movement: movement, showProduct: true)
}
} else {
MovementRow(movement: movement, showProduct: false)
}
}
}
}

View File

@@ -191,6 +191,10 @@ struct ExpiringView: View {
// 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] = []
@@ -200,6 +204,20 @@ struct ProductListView: View {
@State private var busy = false
@State private var showNew = false
/// Auf den festen Typ gefiltert (clientseitig, wie im Web).
private var sichtbar: [Product] {
guard let fixedType else { return products }
return products.filter { ($0.isObject ? "object" : "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 }) {
@@ -228,21 +246,21 @@ struct ProductListView: View {
}
Section {
ForEach(products) { product in
ForEach(sichtbar) { product in
NavigationLink {
ProductDetailView(product: product)
} label: {
ProductRow(product: product)
}
}
if products.isEmpty && !busy {
if sichtbar.isEmpty && !busy {
Text("Keine Treffer").foregroundStyle(.secondary)
}
}
}
.searchable(text: $query, prompt: "Artikel suchen")
.onChange(of: query) { _ in Task { await load() } }
.navigationTitle("Produkte")
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.toolbar {
@@ -256,9 +274,10 @@ struct ProductListView: View {
}
.sheet(isPresented: $showNew) {
NavigationStack {
// Ohne Barcode: nur Name + Kategorie nötig. Nach dem Anlegen
// schließt sich das Formular und die Liste wird aufgefrischt.
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
// Liste wird aufgefrischt.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
showNew = false
Task { await load() }
}

View File

@@ -395,6 +395,42 @@ struct ProductDetailView: View {
}
}
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht
/// z.B. beim Antippen einer Bewegung im Verlauf oder auf der Startseite.
struct ProductByIdView: View {
let productId: Int
@State private var product: Product?
@State private var error: String?
var body: some View {
Group {
if let product {
ProductDetailView(product: product)
} else if let error {
VStack(spacing: 12) {
Text(error).foregroundStyle(.red).font(.callout)
.multilineTextAlignment(.center)
Button("Erneut versuchen") { Task { await load() } }
}
.padding()
} else {
ProgressView()
}
}
.task { if product == nil { await load() } }
}
private func load() async {
do {
product = try await APIClient.shared.product(id: productId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
/// Menge und MHD einer Charge korrigieren.
struct LotEditView: View {
let lot: Lot

View File

@@ -56,6 +56,9 @@ struct ProductFormView: View {
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
var categoryId: Int?
var suggestion: LookupResult.Suggestion?
/// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder
/// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang.
var initialType: String?
var onCreated: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@@ -203,6 +206,7 @@ struct ProductFormView: View {
}
private func prefill() {
if let initialType { modus = initialType }
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit

View File

@@ -169,11 +169,19 @@ struct ListsTabView: View {
var body: some View {
NavigationStack {
VStack(spacing: 16) {
// Getrennte Produktlisten wie im Web: Lebensmittel und Gegenstände
// nebeneinander als halbbreite Kacheln.
HStack(spacing: 16) {
NavigationLink {
ProductListView()
ProductListView(fixedType: "food")
} label: {
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
systemImage: "shippingbox")
CompactTile(title: "Lebensmittel", systemImage: "fork.knife")
}
NavigationLink {
ProductListView(fixedType: "object")
} label: {
CompactTile(title: "Gegenstände", systemImage: "shippingbox")
}
}
NavigationLink {
ItemListView()
@@ -207,6 +215,29 @@ struct ListsTabView: View {
}
}
/// Kompakte, halbbreite Kachel (Symbol oben, Titel darunter) für zwei
/// nebeneinanderstehende Einträge z.B. Lebensmittel/Gegenstände.
struct CompactTile: View {
let title: String
let systemImage: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: systemImage)
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(title).font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.primary)
}
}
struct ActionTile: View {
let title: String
let subtitle: String