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:
@@ -330,7 +330,13 @@ struct MovementsCard: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(movements.prefix(5)) { movement in
|
ForEach(movements.prefix(5)) { movement in
|
||||||
MovementRow(movement: movement)
|
// 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)
|
NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,17 @@ struct HistoryView: View {
|
|||||||
ForEach(tage, id: \.key) { tag in
|
ForEach(tage, id: \.key) { tag in
|
||||||
Section(display.formatDay(tag.eintraege[0].createdAt)) {
|
Section(display.formatDay(tag.eintraege[0].createdAt)) {
|
||||||
ForEach(tag.eintraege) { movement in
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ struct ExpiringView: View {
|
|||||||
// MARK: - Produktliste
|
// MARK: - Produktliste
|
||||||
|
|
||||||
struct ProductListView: View {
|
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
|
@EnvironmentObject private var session: Session
|
||||||
@State private var query = ""
|
@State private var query = ""
|
||||||
@State private var products: [Product] = []
|
@State private var products: [Product] = []
|
||||||
@@ -200,6 +204,20 @@ struct ProductListView: View {
|
|||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var showNew = 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 {
|
private var filterLabel: String {
|
||||||
if categoryId == 0 { return "Ohne Kategorie" }
|
if categoryId == 0 { return "Ohne Kategorie" }
|
||||||
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
|
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
|
||||||
@@ -228,21 +246,21 @@ struct ProductListView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(products) { product in
|
ForEach(sichtbar) { product in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductDetailView(product: product)
|
ProductDetailView(product: product)
|
||||||
} label: {
|
} label: {
|
||||||
ProductRow(product: product)
|
ProductRow(product: product)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if products.isEmpty && !busy {
|
if sichtbar.isEmpty && !busy {
|
||||||
Text("Keine Treffer").foregroundStyle(.secondary)
|
Text("Keine Treffer").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchable(text: $query, prompt: "Artikel suchen")
|
.searchable(text: $query, prompt: "Artikel suchen")
|
||||||
.onChange(of: query) { _ in Task { await load() } }
|
.onChange(of: query) { _ in Task { await load() } }
|
||||||
.navigationTitle("Produkte")
|
.navigationTitle(titel)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -256,9 +274,10 @@ struct ProductListView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showNew) {
|
.sheet(isPresented: $showNew) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
// Ohne Barcode: nur Name + Kategorie nötig. Nach dem Anlegen
|
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||||||
// schließt sich das Formular und die Liste wird aufgefrischt.
|
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
// Liste wird aufgefrischt.
|
||||||
|
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
|
||||||
showNew = false
|
showNew = false
|
||||||
Task { await load() }
|
Task { await load() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// Menge und MHD einer Charge korrigieren.
|
||||||
struct LotEditView: View {
|
struct LotEditView: View {
|
||||||
let lot: Lot
|
let lot: Lot
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ struct ProductFormView: View {
|
|||||||
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
|
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
|
||||||
var categoryId: Int?
|
var categoryId: Int?
|
||||||
var suggestion: LookupResult.Suggestion?
|
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
|
var onCreated: (Product) -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -203,6 +206,7 @@ struct ProductFormView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func prefill() {
|
private func prefill() {
|
||||||
|
if let initialType { modus = initialType }
|
||||||
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
|
||||||
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
|
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
|
||||||
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
|
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
|
||||||
|
|||||||
@@ -169,11 +169,19 @@ struct ListsTabView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
NavigationLink {
|
// Getrennte Produktlisten wie im Web: Lebensmittel und Gegenstände
|
||||||
ProductListView()
|
// nebeneinander als halbbreite Kacheln.
|
||||||
} label: {
|
HStack(spacing: 16) {
|
||||||
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
|
NavigationLink {
|
||||||
systemImage: "shippingbox")
|
ProductListView(fixedType: "food")
|
||||||
|
} label: {
|
||||||
|
CompactTile(title: "Lebensmittel", systemImage: "fork.knife")
|
||||||
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ProductListView(fixedType: "object")
|
||||||
|
} label: {
|
||||||
|
CompactTile(title: "Gegenstände", systemImage: "shippingbox")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ItemListView()
|
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 {
|
struct ActionTile: View {
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
|
|||||||
Reference in New Issue
Block a user