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>
This commit is contained in:
266
ios/Sources/ItemViews.swift
Normal file
266
ios/Sources/ItemViews.swift
Normal file
@@ -0,0 +1,266 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user