import SwiftUI /// Gemeinsame Zahlenformatierung der Listen. func formatAmount(_ value: Double) -> String { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } /// Kennzeichnung "Kategorie" an einer Listenzeile. struct CategoryBadge: View { var body: some View { Text("Kategorie") .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 Kategorie unterscheidbar sind. struct ListRow: 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 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("Kategorien 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" ) { CategoryBadge() } } } } if items.isEmpty && groups.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() 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 { @State private var query = "" @State private var products: [Product] = [] @State private var busy = false var body: some View { List { ForEach(products) { product in NavigationLink { ProductDetailView(product: product) } label: { ListRow( systemImage: "shippingbox", title: product.name, subtitle: "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)" ) { EmptyView() } } } if products.isEmpty && !busy { Text("Keine Treffer").foregroundStyle(.secondary) } } .searchable(text: $query, prompt: "Artikel suchen") .onChange(of: query) { _ in Task { await load() } } .navigationTitle("Produkte") .navigationBarTitleDisplayMode(.inline) .refreshable { await load() } .task { await load() } } private func load() async { busy = true defer { busy = false } products = (try? await APIClient.shared.searchProducts(query)) ?? [] } }