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>
267 lines
10 KiB
Swift
267 lines
10 KiB
Swift
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 }
|
||
}
|
||
}
|