diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 7216272..d514683 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -182,6 +182,36 @@ actor APIClient { try await sendNoContent(request) } + func uploadItemDocument(itemId: Int, data: Data, filename: String, + contentType: String) async throws -> ItemDocumentUpload { + var request = try makeRequest("/items/\(itemId)/documents", method: "POST") + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", + forHTTPHeaderField: "Content-Type") + let safeName = filename.replacingOccurrences(of: "\"", with: "") + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n" + .data(using: .utf8)!) + body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + return try await send(request, as: ItemDocumentUpload.self) + } + + func itemDocumentData(itemId: Int, docId: Int) async throws -> Data { + let request = try makeRequest("/items/\(itemId)/documents/\(docId)") + let (data, response) = try await URLSession.shared.data(for: request) + try check(response, data: data) + return data + } + + func deleteItemDocument(itemId: Int, docId: Int) async throws { + try await sendNoContent( + try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE")) + } + func deleteItem(id: Int) async throws { try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE")) } diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 2229b80..d1832bf 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -1,5 +1,8 @@ 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. @@ -96,11 +99,20 @@ struct ItemEditView: View { @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 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 { @@ -136,6 +148,52 @@ struct ItemEditView: View { 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 suggWarranty != nil || suggPrice != nil { + VStack(alignment: .leading, spacing: 6) { + Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary) + if let w = suggWarranty { Text("Garantie bis \(w)") } + if let p = suggPrice { Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)") } + HStack { + Button("Übernehmen") { applySuggestion() } + Spacer() + Button("Verwerfen") { suggWarranty = nil; suggPrice = nil } + .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 { @@ -150,6 +208,30 @@ struct ItemEditView: View { .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 { @@ -174,23 +256,84 @@ struct ItemEditView: View { 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)) + 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) + suggWarranty = res.suggestedWarrantyUntil + suggPrice = res.suggestedPriceCents + 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 func applySuggestion() { + if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true } + if let p = suggPrice { priceText = ItemEditView.formatCents(p) } + suggWarranty = nil + suggPrice = 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( diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 5ca6346..e136055 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -583,6 +583,19 @@ enum RemovalReasons { // MARK: - Einzelstücke (Items mit UID/QR) +struct ItemDocument: Codable, Identifiable, Hashable { + let id: Int + let filename: String + let contentType: String + let uploadedAt: String + + enum CodingKeys: String, CodingKey { + case id, filename + case contentType = "content_type" + case uploadedAt = "uploaded_at" + } +} + struct Item: Codable, Identifiable, Hashable { let id: Int let uid: String @@ -594,12 +607,15 @@ struct Item: Codable, Identifiable, Hashable { let acquiredOn: String? // "yyyy-MM-dd" let warrantyUntil: String? let note: String? + let priceCents: Int? + let currency: String? let createdAt: String let productName: String? let productBrand: String? + let documents: [ItemDocument] enum CodingKeys: String, CodingKey { - case id, uid, note + case id, uid, note, currency, documents case productId = "product_id" case locationId = "location_id" case locationName = "location_name" @@ -607,12 +623,26 @@ struct Item: Codable, Identifiable, Hashable { case shopName = "shop_name" case acquiredOn = "acquired_on" case warrantyUntil = "warranty_until" + case priceCents = "price_cents" case createdAt = "created_at" case productName = "product_name" case productBrand = "product_brand" } } +/// Antwort nach dem Beleg-Upload – mit den aus dem PDF geschätzten Werten. +struct ItemDocumentUpload: Codable { + let id: Int + let suggestedWarrantyUntil: String? + let suggestedPriceCents: Int? + + enum CodingKeys: String, CodingKey { + case id + case suggestedWarrantyUntil = "suggested_warranty_until" + case suggestedPriceCents = "suggested_price_cents" + } +} + struct ItemCreateRequest: Codable { let count: Int let locationId: Int? @@ -636,13 +666,16 @@ struct ItemUpdateRequest: Encodable { var acquiredOn: String? var warrantyUntil: String? var note: String? + var priceCents: Int? + var currency: String? enum CodingKeys: String, CodingKey { - case note + case note, currency case locationId = "location_id" case shopId = "shop_id" case acquiredOn = "acquired_on" case warrantyUntil = "warranty_until" + case priceCents = "price_cents" } // Immer alle Felder senden (auch null), damit sich ein Feld leeren lässt. @@ -653,6 +686,8 @@ struct ItemUpdateRequest: Encodable { try c.encode(acquiredOn, forKey: .acquiredOn) try c.encode(warrantyUntil, forKey: .warrantyUntil) try c.encode(note, forKey: .note) + try c.encode(priceCents, forKey: .priceCents) + try c.encode(currency, forKey: .currency) } }