Files
Vorrania/ios/Sources/ListViews.swift
Scarriffle 4f8e6e3185 iOS: Produktliste - Kategoriefilter nach Typ + Sortierung
Kategoriefilter zeigt nur noch Kategorien des Listentyps (Lebensmittel-Liste nur
Food-Kategorien, Gegenstaende-Liste nur Objekt-Kategorien) - vorher konnte man
sinnlos ueberkreuz filtern. Neue Sortierung (Name A-Z/Z-A, Zuletzt geaendert)
per Menue-Picker; Product traegt dafuer updated_at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:43:44 +02:00

414 lines
15 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] = []
@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) }
}
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"
) { 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 && !busy {
Text("Nichts einzukaufen alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary)
}
}
.navigationTitle("Einkaufsliste")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
}
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()
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("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(restText(item.daysLeft))
.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)"
}
private func restText(_ days: Int) -> String {
if days < 0 { return "abgelaufen" }
if days == 0 { return "heute" }
return days == 1 ? "1 Tag" : "\(days) Tage"
}
}
// 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
@State private var busy = false
@State private var showNew = false
enum SortMode: String, CaseIterable, Identifiable {
case nameAsc = "Name AZ"
case nameDesc = "Name ZA"
case updated = "Zuletzt geändert"
var id: String { rawValue }
}
/// 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 }
}
/// 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)
}
}
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) {
NavigationStack {
// 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() }
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { showNew = false }
}
}
}
}
.task {
categories = (try? await APIClient.shared.categories()) ?? []
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
}
}