Files
Vorrania/ios/Sources/ItemViews.swift
Scarriffle 7f7129c87a iOS: Einzelstücke (UID/QR) + Scan öffnet das Stück; Seed ohne Modell-Kaufdatum
iOS-Parität zum Web: Item-Modell + API, Einzelstueck-Liste in der Produkt-
Detailansicht (anlegen, je-Stueck Lagerort/Kaufdatum/Garantie/Bezugsquelle/Notiz
aendern, entfernen mit Grund, QR je Stueck via CoreImage). Anlegen-Formular hat
den Schalter "Einzelstuecke". Der Scanner erkennt Einzelstueck-QR (…/i/<UID>) und
oeffnet direkt das Stueck.

Seed: "Kaufdatum"/"Garantie bis" nicht mehr als Modell-Felder auf Elektronik -
diese Angaben gehoeren je Einzelstueck ans Item, nicht ans Modell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:48:25 +02:00

267 lines
10 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
import CoreImage
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
/// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet.
enum QRImage {
static func make(_ text: String, scale: CGFloat = 8) -> UIImage? {
guard let data = text.data(using: .utf8),
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("M", forKey: "inputCorrectionLevel")
guard let ci = filter.outputImage?
.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil }
let context = CIContext()
guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil }
return UIImage(cgImage: cg)
}
}
func qrLink(forUid uid: String) -> String {
let base = Session.shared.baseURL?.absoluteString
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
return base.isEmpty ? uid : "\(base)/i/\(uid)"
}
private func dateToString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
}
private func stringToDate(_ s: String) -> Date? {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
}
// MARK: - Liste (in der Produkt-Detailansicht)
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
/// Detail-/Bearbeiten-Ansicht.
struct ProductItemsSection: View {
let product: Product
var onChanged: () async -> Void
@State private var items: [Item] = []
@State private var showAdd = false
var body: some View {
Section("Einzelstücke (\(items.count))") {
ForEach(items) { it in
NavigationLink {
ItemEditView(item: it) { Task { await refresh() } }
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(it.uid).font(.body.monospaced()).bold()
Text(untertitel(it)).font(.caption).foregroundStyle(.secondary)
}
}
}
if items.isEmpty {
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
}
Button { showAdd = true } label: {
Label("Einzelstücke anlegen", systemImage: "plus")
}
}
.task(id: product.id) { await reload() }
.sheet(isPresented: $showAdd) {
NavigationStack {
ItemAddSheet(productId: product.id) { await refresh() }
}
}
}
private func untertitel(_ it: Item) -> String {
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
.compactMap { $0 }.joined(separator: " · ")
}
private func reload() async {
items = (try? await APIClient.shared.items(productId: product.id)) ?? []
}
private func refresh() async { await reload(); await onChanged() }
}
// MARK: - Detail / Bearbeiten (auch Ziel eines Scans)
struct ItemEditView: View {
let item: Item
var onChanged: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var locationId: Int?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var busy = false
@State private var error: String?
@State private var showRemove = false
@State private var reason = "broken"
@State private var removeNote = ""
var body: some View {
Form {
Section {
if let img = QRImage.make(qrLink(forUid: item.uid)) {
Image(uiImage: img)
.interpolation(.none).resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(height: 180)
}
LabeledContent("UID", value: item.uid)
if let name = item.productName {
LabeledContent("Artikel", value: name)
}
}
Section {
Picker("Lagerort", selection: $locationId) {
Text(" ohne ").tag(Int?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
}
Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
LabeledField(label: "Notiz", text: $note)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy)
Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true }
}
}
.navigationTitle("Einzelstück")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
.task { await load() }
.sheet(isPresented: $showRemove) {
NavigationStack { removeSheet }
}
}
private var removeSheet: some View {
Form {
Picker("Grund", selection: $reason) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
}
LabeledField(label: "Notiz", text: $removeNote)
Section {
Button("Entfernen", role: .destructive) { Task { await remove() } }
}
}
.navigationTitle("Entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
}
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
locationId = item.locationId
shopId = item.shopId
note = item.note ?? ""
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
}
private func save() async {
busy = true; defer { busy = false }; error = nil
do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note))
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription }
}
private func remove() async {
do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
reason: reason, note: removeNote.isEmpty ? nil : removeNote))
showRemove = false
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription; showRemove = false }
}
}
// MARK: - Anlegen
struct ItemAddSheet: View {
let productId: Int
var onDone: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var count = 1
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var locationId: Int?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
Picker("Lagerort", selection: $locationId) {
Text(" ohne ").tag(Int?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
}
Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
LabeledField(label: "Notiz", text: $note)
} footer: {
Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
}
}
.navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.task {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
}
}
private func create() async {
busy = true; defer { busy = false }; error = nil
do {
_ = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note))
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
}