Files
Vorrania/ios/Sources/ItemViews.swift
Scarriffle 9551751732 iOS: 'Anlegen & fertig' fuers Einzelstueck + Speichern-Nachfrage beim Zurueck
1) Im Einzelstueck-Anlegen gibt es jetzt 'Anlegen & fertig' - das schliesst in einem Tap bis zur Produktliste zurueck (statt Sheet schliessen + nochmal zurueck). 'Anlegen' bleibt fuer mehrere Stuecke nacheinander. 2) Beim Zurueck aus der Produkt-Detailansicht wird bei ungespeicherten Stammdaten-Aenderungen nachgefragt (Speichern/Verwerfen); ohne Aenderung direkt zurueck. Fussnote stellt klar: Foto und Lagerort-Bedarf speichern sofort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:42:43 +02:00

614 lines
25 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
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
/// 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)
}
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig deshalb hier ein fester
/// Formatierer und ein grafischer Picker im Popover.
struct DateRow: View {
let label: String
@Binding var date: Date
@State private var open = false
private static let fmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f
}()
var body: some View {
Group {
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
HStack {
Text(label).foregroundStyle(.primary)
Spacer()
Text(Self.fmt.string(from: date))
.foregroundStyle(open ? Color.accentColor : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
DatePicker(label, selection: $date, displayedComponents: .date)
.datePickerStyle(.graphical)
.labelsHidden()
}
}
}
}
// 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
/// Bis zur Liste schließen (für Anlegen & fertig").
var onFinish: (() -> Void)? = nil
@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, onDone: { await refresh() },
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
}
}
}
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 photo: Data?
@State private var locationId: String?
@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 priceText = ""
@State private var currency = "CHF"
@State private var documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var suggPriceCandidates: [Int] = []
@State private var suggAcquired: String?
@State private var suggShopId: Int?
@State private var suggShopName: String?
@State private var busy = false
@State private var busyDoc = false
@State private var error: String?
@State private var showRemove = false
@State private var reason = "broken"
@State private var removeNote = ""
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
var body: some View {
Form {
Section {
if let photo, let ui = UIImage(data: photo) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
if let name = item.productName {
LabeledContent("Artikel", value: name)
}
if let brand = item.productBrand, !brand.isEmpty {
LabeledContent("Marke", value: brand)
}
LabeledContent("UID", value: item.uid)
}
Section {
Picker("Lagerort", selection: $locationId) {
Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.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 { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section("Belege (Rechnung/Garantieschein)") {
if hatVorschlag {
VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let a = suggAcquired { Text("Gekauft am \(a)") }
if let w = suggWarranty { Text("Garantie bis \(w)") }
if suggPriceCandidates.count > 1 {
// Mehrere Beträge gefunden Auswahl anbieten.
Picker("Kaufpreis", selection: Binding(
get: { suggPrice ?? suggPriceCandidates.first ?? 0 },
set: { suggPrice = $0 }
)) {
ForEach(suggPriceCandidates, id: \.self) { c in
Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c)
}
}
.pickerStyle(.menu)
} else if let p = suggPrice {
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
}
if let sid = suggShopId {
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
} else if let name = suggShopName {
Text("Shop anlegen: \(name)")
}
HStack {
Button("Übernehmen") { Task { await applySuggestion() } }
Spacer()
Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
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 }
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.quickLookPreview($previewURL)
}
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()) ?? []
photo = try? await APIClient.shared.productImage(id: item.productId)
locationId = item.locationId
shopId = item.shopId
note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
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
let cents = ItemEditView.parseCents(priceText)
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,
priceCents: cents,
currency: cents == nil ? nil : currency))
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription }
}
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
let s = res.suggestions
suggWarranty = s.suggestedWarrantyUntil
suggPrice = s.suggestedPriceCents
suggPriceCandidates = s.suggestedPriceCandidates
suggAcquired = s.suggestedAcquiredOn
suggShopId = s.suggestedShopId
suggShopName = s.suggestedShopName
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private var hatVorschlag: Bool {
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|| suggShopId != nil || suggShopName != nil
}
private func applySuggestion() async {
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
if let sid = suggShopId {
shopId = sid
} else if let name = suggShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
shopId = shop.id
}
verwerfeVorschlag()
}
private func verwerfeVorschlag() {
suggWarranty = nil
suggPrice = nil
suggPriceCandidates = []
suggAcquired = nil
suggShopId = nil
suggShopName = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
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
/// Wenn gesetzt: Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet).
var onFinish: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var count = 1
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var locationId: String?
@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 priceText = ""
@State private var currency = "CHF"
@State private var docData: Data?
@State private var docName = ""
@State private var docType = ""
@State private var newShopName: String?
@State private var addInfo: String?
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@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(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.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 { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
} footer: {
Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section {
if docData != nil { Text(docName).font(.callout) }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild wählen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF wählen", systemImage: "doc.badge.plus")
}
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
if let newShopName {
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text("Beleg (Rechnung/Garantieschein)")
} footer: {
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create(finish: false) } }.disabled(busy)
if onFinish != nil {
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
.disabled(busy)
}
}
}
.navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
}
}
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await analyze(data, name: "foto.jpg", type: "image/jpeg")
}
pickerItem = nil
}
}
.task {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
}
}
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
/// der Server Treffer).
private func analyze(_ data: Data, name: String, type: String) async {
docData = data
docName = name
docType = type
addInfo = nil
newShopName = nil
guard let s = try? await APIClient.shared.analyzeItemDocument(
data: data, filename: name, contentType: type) else { return }
var teile: [String] = []
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
}
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
warranty = d; hasWarranty = true; teile.append("Garantie")
}
if let p = s.suggestedPriceCents {
priceText = ItemEditView.formatCents(p); teile.append("Preis")
}
if let sid = s.suggestedShopId {
shopId = sid; teile.append("Shop")
} else if let sn = s.suggestedShopName {
newShopName = sn
}
addInfo = teile.isEmpty ? "Beleg erkannt keine Automatik-Treffer."
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create(finish: Bool) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
if sid == nil, let name = newShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
sid = shop.id
}
let cents = ItemEditView.parseCents(priceText)
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: sid,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
if let data = docData, let first = created.first {
_ = try? await APIClient.shared.uploadItemDocument(
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
await onDone()
if finish, let onFinish { onFinish() } else { dismiss() }
} catch { self.error = error.localizedDescription }
}
}