From 09a763a8233f872edff0aeea70221dbec1d8f79c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 13:37:24 +0200 Subject: [PATCH] NFC-Tags fuer Einzelstuecke: beschreiben und lesen Ein aufgeklebtes QR-Etikett uebersteht keine Waesche. Kleidungsstuecke bekommen deshalb eingenaehte NFC-Tags. Auf das Tag kommt genau das, was auch im QR steckt (vorrania://i/) - damit bleibt das Einlesen inhaltlich unveraendert, nur die Erfassungsart ist neu. Backend und Web brauchten nichts: die UID vergibt der Server, und GET /items/by-uid/ gibt es laengst. Gelesen wird nur bei geoeffneter App. iOS startet Apps beim Antippen im Hintergrund ausschliesslich ueber https-Universal-Links auf einer beanspruchten Domain und ignoriert eigene Schemes dabei - eine feste Domain ist fuer einen selbstgehosteten Dienst aber gerade der falsche Weg (siehe f981f41). Die /i/-Zerlegung lag dreifach kopiert in CheckInView, LookupScanView und AssignScanView und steckt jetzt in einzelstueckCode(aus:). Die nackte UID wird nur fuer NFC akzeptiert: eine achtstellige EAN-8 kann zufaellig genau wie eine UID aussehen, und die Kamera sieht fremde Barcodes - ein Tag haben wir selbst beschrieben. Zwei Altlasten in denselben Dateien mit erledigt: - project.yml deklarierte die applinks-Domain weiter, obwohl f981f41 sie entfernt hat. XcodeGen erzeugt die Entitlements aus diesem Block und schrieb sie bei jedem "generate" zurueck; die Domain existiert laengst nicht mehr. - AssignScanView sendete ItemUpdateRequest ohne priceCents/currency. Da encode immer alle Felder schreibt (auch null), hat ein Zuordnen per Scan Kaufpreis und Waehrung stillschweigend geloescht. Ebenfalls raus: QRImage und qrLink(forUid:) in ItemViews - ungenutzt, und qrLink baute noch das alte https-Format. Co-Authored-By: Claude Opus 5 --- ios/README.md | 18 +- ios/Sources/AssignScanView.swift | 42 +++- ios/Sources/CheckInView.swift | 14 +- ios/Sources/Info.plist | 4 + ios/Sources/ItemViews.swift | 34 +-- ios/Sources/LookupScanView.swift | 26 ++- ios/Sources/NFCTag.swift | 273 +++++++++++++++++++++++++ ios/Sources/NFCViews.swift | 124 +++++++++++ ios/Sources/Vorrania.entitlements | 4 +- ios/Vorrania.xcodeproj/project.pbxproj | 8 + ios/project.yml | 17 +- 11 files changed, 508 insertions(+), 56 deletions(-) create mode 100644 ios/Sources/NFCTag.swift create mode 100644 ios/Sources/NFCViews.swift diff --git a/ios/README.md b/ios/README.md index 5977e54..4784383 100644 --- a/ios/README.md +++ b/ios/README.md @@ -63,6 +63,21 @@ Es gibt zwei Wege direkt in den Scan-Bildschirm: Beide Wege öffnen die App **direkt in der Kamera**. +## NFC-Tags für Kleidung +Ein aufgeklebtes QR-Etikett übersteht keine Wäsche. Für Kleidungsstücke gibt es +deshalb eingenähte NFC-Tags: Einzelstück öffnen ▸ **NFC-Tag beschreiben** ▸ Tag +an die Oberseite des iPhones halten. Auf das Tag kommt derselbe Link, der auch im +QR steckt (`vorrania://i/`). + +Gelesen wird es unter **Nachschlagen** oder **Ort zuordnen** über +*NFC-Tag lesen*. Das Antippen bei gesperrtem Handy startet die App bewusst +nicht: iOS macht das nur über Universal Links auf einer festen Domain, und die +gibt es bei einem selbst gehosteten Dienst nicht. + +> Zum Bauen muss für die App-ID im Apple-Developer-Portal die Capability +> *Near Field Communication Tag Reading* aktiv sein, sonst schlägt das Signieren +> fehl. NFC gibt es nur auf dem iPhone – auf dem iPad sind die Knöpfe ausgeblendet. + ## Aufbau der Quellen | Datei | Inhalt | |---|---| @@ -79,7 +94,8 @@ Beide Wege öffnen die App **direkt in der Kamera**. | `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren | | `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) | | `ItemViews.swift` / `ItemListView.swift` | Einzelstücke anlegen, bearbeiten und auflisten | -| `AssignScanView.swift` | Einzelstück (Scan oder Liste) einem Lagerort-QR zuordnen | +| `AssignScanView.swift` | Einzelstück (Scan, NFC oder Liste) einem Lagerort-QR zuordnen | +| `NFCTag.swift` / `NFCViews.swift` | NFC-Tags lesen und beschreiben, Einzelstück-Code zerlegen | | `ListViews.swift` | Produktlisten, Einkaufsliste, „bald ablaufend", Mindestbestände | | `DashboardView.swift` / `DashboardCards.swift` / `ChartCards.swift` | Übersicht mit Karten und Diagrammen | | `NotificationScheduler.swift` / `NotificationSettings*.swift` | Push-Benachrichtigungen bei Ablauf | diff --git a/ios/Sources/AssignScanView.swift b/ios/Sources/AssignScanView.swift index 6db3f17..4a598d8 100644 --- a/ios/Sources/AssignScanView.swift +++ b/ios/Sources/AssignScanView.swift @@ -38,7 +38,19 @@ struct AssignScanView: View { VStack(spacing: 8) { Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:") .font(.caption).foregroundStyle(.secondary) - // Für Stücke ohne eigenen QR (z.B. Kleidung): aus der Liste wählen. + // Kleidung trägt keinen QR, aber ein eingenähtes NFC-Tag. + NFCLesenButton(titel: "Einzelstück-Tag lesen") { uid in + Task { + let gefunden = await waehle(uid: uid) + if !gefunden { + error = "Kein Einzelstück mit der UID \(uid)." + status = nil + } + } + } onFehler: { meldung in + error = meldung; status = nil + } + // Für Stücke ohne Code jeder Art: aus der Liste wählen. Button { showPicker = true } label: { Label("Einzelstück aus Liste wählen", systemImage: "list.bullet") } @@ -78,9 +90,14 @@ struct AssignScanView: View { error = "Erst ein Einzelstück scannen."; status = nil; return } do { + // Alle Felder mitsenden: ItemUpdateRequest schreibt auch null + // mit, damit sich Felder leeren lassen. Kaufpreis und Waehrung + // fehlten hier – ein Zuordnen per Scan hat sie stillschweigend + // geloescht. _ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest( locationId: locCode, shopId: it.shopId, - acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note)) + acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note, + priceCents: it.priceCents, currency: it.currency)) status = "\(it.uid) zugeordnet. Nächstes Stück scannen." error = nil pending = nil @@ -89,16 +106,23 @@ struct AssignScanView: View { } } // Einzelstück-QR (…/i/)? - if let r = code.range(of: "/i/") { - let uid = String(code[r.upperBound...]) - .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased() - if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) { - pending = it; status = nil; error = nil - return - } + if let uid = einzelstueckCode(aus: code), await waehle(uid: uid) { + return } error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen." } + + /// Einzelstück als „wartet auf Lagerort" vormerken – aus Scan, NFC oder Liste. + /// Gibt zurueck, ob es gefunden wurde; der Aufrufer entscheidet ueber die + /// Fehlermeldung, weil ein Scan noch andere Deutungen zulaesst als ein Tag. + @discardableResult + private func waehle(uid: String) async -> Bool { + guard let it = try? await APIClient.shared.itemByUid(uid: uid) else { return false } + pending = it + status = nil + error = nil + return true + } } /// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR. diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 9301bd1..55b0277 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -311,15 +311,11 @@ struct CheckInView: View { unknownCode = nil do { // Einzelstück-QR (…/i/) → direkt das Stück öffnen statt Barcode-Suche. - if let r = code.range(of: "/i/") { - let uid = String(code[r.upperBound...]) - .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) - .uppercased() - if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { - scannedItem = item - scanShown = false - return - } + if let uid = einzelstueckCode(aus: code), + let item = try? await APIClient.shared.itemByUid(uid: uid) { + scannedItem = item + scanShown = false + return } let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId diff --git a/ios/Sources/Info.plist b/ios/Sources/Info.plist index cbe6c77..f9fbd87 100644 --- a/ios/Sources/Info.plist +++ b/ios/Sources/Info.plist @@ -65,6 +65,10 @@ NSCameraUsageDescription Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet. + + NFCReaderUsageDescription + NFC wird zum Lesen und Beschreiben der Tags an Einzelstücken verwendet. + NSAppTransportSecurity diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index e07812f..37e116c 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -1,31 +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. -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) } @@ -217,6 +194,7 @@ struct ItemEditView: View { @State private var pickerItem: PhotosPickerItem? @State private var previewURL: URL? @State private var locScanShown = false + @State private var nfcShown = false var body: some View { Form { @@ -234,6 +212,13 @@ struct ItemEditView: View { LabeledContent("Marke", value: brand) } LabeledContent("UID", value: item.uid) + // Für Kleidung: ein eingenähtes NFC-Tag hält, was ein aufgeklebter + // QR nicht übersteht. Auf dem iPad ausgeblendet – dort gibt es kein NFC. + if NFCTag.verfuegbar { + Button { nfcShown = true } label: { + Label("NFC-Tag beschreiben", systemImage: "wave.3.right") + } + } } Section { @@ -337,6 +322,9 @@ struct ItemEditView: View { LocationScannerView(locations: locations) { loc in locationId = loc.id } .ignoresSafeArea() } + .sheet(isPresented: $nfcShown) { + NavigationStack { NFCSchreibenView(item: item) } + } .sheet(isPresented: $showRemove) { NavigationStack { removeSheet } } diff --git a/ios/Sources/LookupScanView.swift b/ios/Sources/LookupScanView.swift index 36199fb..d3906eb 100644 --- a/ios/Sources/LookupScanView.swift +++ b/ios/Sources/LookupScanView.swift @@ -27,6 +27,21 @@ struct LookupScanView: View { .font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.center) + // Eingenähte Tags an Kleidung lassen sich nicht scannen. + NFCLesenButton { uid in + Task { + paused = true + if let found = try? await APIClient.shared.itemByUid(uid: uid) { + item = found + } else { + error = "Kein Einzelstück mit der UID \(uid)." + paused = false + } + } + } onFehler: { meldung in + error = meldung + } + if let error { Text(error).font(.callout).foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .leading) @@ -58,13 +73,10 @@ struct LookupScanView: View { paused = true error = nil // Einzelstück-QR (…/i/)? - if let r = code.range(of: "/i/") { - let uid = String(code[r.upperBound...]) - .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased() - if !uid.isEmpty, let found = try? await APIClient.shared.itemByUid(uid: uid) { - item = found - return - } + if let uid = einzelstueckCode(aus: code), + let found = try? await APIClient.shared.itemByUid(uid: uid) { + item = found + return } // Lagerort-QR (…/l/)? → alles zeigen, was dort (inkl. Unterorte) liegt. if let r = code.range(of: "/l/") { diff --git a/ios/Sources/NFCTag.swift b/ios/Sources/NFCTag.swift new file mode 100644 index 0000000..dc1dfb4 --- /dev/null +++ b/ios/Sources/NFCTag.swift @@ -0,0 +1,273 @@ +import AudioToolbox +import CoreNFC +import Foundation + +// MARK: - Einzelstück-Codes + +/// Zeichenvorrat der vom Server vergebenen Einzelstück-UID. +/// Spiegelt `_ALPHABET` in backend/app/services/items.py – ohne 0/O/1/I/L. +private let uidAlphabet = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789") +private let uidLaenge = 8 + +/// Was auf ein NFC-Tag geschrieben und in einen QR kodiert wird. +/// Wortgleich mit `itemDeepLink` in web/src/qr.jsx – bewusst ein eigenes Scheme +/// statt einer https-Adresse, damit der Code keinen Server verrät und auf jeder +/// selbst gehosteten Instanz funktioniert. +func einzelstueckLink(fuer uid: String) -> String { "vorrania://i/\(uid)" } + +/// Zieht die Einzelstück-UID aus einem gescannten oder gelesenen Code. +/// +/// Erkennt `vorrania://i/` und die frühere Form `https://server/i/`, +/// beide über das Teilstück `/i/`. +/// +/// `nackteUidErlauben` ist nur für NFC gedacht. Eine achtstellige EAN-8 kann +/// zufällig genau wie eine UID aussehen (z.B. „96385274" – acht Zeichen, alle im +/// Alphabet), und die Kamera sieht beliebige fremde Barcodes. Sie würde dann als +/// Einzelstück durchgehen, statt im Katalog nachgeschlagen zu werden. Ein +/// NFC-Tag dagegen haben wir selbst beschrieben. +func einzelstueckCode(aus roh: String, nackteUidErlauben: Bool = false) -> String? { + let text = roh.trimmingCharacters(in: .whitespacesAndNewlines) + + if let r = text.range(of: "/i/") { + let uid = String(text[r.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) + .uppercased() + return uid.isEmpty ? nil : uid + } + + guard nackteUidErlauben else { return nil } + let uid = text.uppercased() + guard uid.count == uidLaenge, uid.allSatisfy({ uidAlphabet.contains($0) }) else { return nil } + return uid +} + +// MARK: - NFC + +/// Liest und beschreibt NDEF-Tags – gedacht für die in Kleidung eingenähten +/// Tags, wo ein aufgeklebter QR die Wäsche nicht überstehen würde. +/// +/// Core NFC meldet sich über ein Delegate auf einer eigenen Warteschlange; nach +/// aussen sieht das hier wie ein normales `await` aus, damit die Views genauso +/// aussehen wie beim Kamera-Scan. +/// +/// Gelesen wird nur mit geöffneter App. iOS startet Apps beim Antippen im +/// Hintergrund ausschliesslich über https-Universal-Links auf einer beanspruchten +/// Domain – eigene Schemes ignoriert es dabei stillschweigend. Eine feste Domain +/// wäre für einen selbst gehosteten Dienst aber gerade der falsche Weg (siehe +/// Kommentar an `Router.handle(url:)`). +final class NFCTag: NSObject { + + /// Ob das Gerät NFC lesen kann. Auf dem iPad immer `false` – die App läuft + /// dort ebenfalls (`TARGETED_DEVICE_FAMILY "1,2"`), deshalb muss jede + /// NFC-Bedienung daran hängen. + static var verfuegbar: Bool { NFCNDEFReaderSession.readingAvailable } + + enum Fehler: LocalizedError { + case nichtVerfuegbar + case abgebrochen + case leer + case schreibgeschuetzt + case zuKlein(benoetigt: Int, platz: Int) + case nichtBeschreibbar + case system(String) + + var errorDescription: String? { + switch self { + case .nichtVerfuegbar: + return "Dieses Gerät kann keine NFC-Tags lesen." + case .abgebrochen: + return "Abgebrochen." + case .leer: + return "Auf dem Tag steht nichts." + case .schreibgeschuetzt: + return "Dieses Tag ist schreibgeschützt und lässt sich nicht mehr ändern." + case let .zuKlein(benoetigt, platz): + return "Zu wenig Platz auf dem Tag (\(benoetigt) Byte nötig, \(platz) frei)." + case .nichtBeschreibbar: + return "Dieses Tag lässt sich nicht beschreiben." + case let .system(text): + return text + } + } + } + + private enum Auftrag { + case lesen + case schreiben(nutzlast: String, sperren: Bool) + } + + private let queue = DispatchQueue(label: "com.scarriffle.vorrania.nfc") + private var session: NFCNDEFReaderSession? + private var auftrag: Auftrag = .lesen + private var weiter: CheckedContinuation? + /// Die Sitzung hält sich selbst am Leben, solange sie läuft – sonst gibt der + /// Aufrufer die Instanz frei und das Delegate meldet sich nie. + private var festhalten: NFCTag? + + // MARK: Öffentlich + + /// Liest ein Tag und gibt seinen Inhalt roh zurück. + static func lesen() async throws -> String { + try await NFCTag().starten(.lesen, hinweis: "Halte das Tag an die Oberseite des iPhones.") + } + + /// Beschreibt ein Tag. `sperren` macht es **unwiderruflich** schreibgeschützt. + static func schreiben(_ nutzlast: String, sperren: Bool = false) async throws { + _ = try await NFCTag().starten( + .schreiben(nutzlast: nutzlast, sperren: sperren), + hinweis: "Halte das Tag an die Oberseite des iPhones." + ) + } + + // MARK: Ablauf + + private func starten(_ auftrag: Auftrag, hinweis: String) async throws -> String { + guard Self.verfuegbar else { throw Fehler.nichtVerfuegbar } + self.auftrag = auftrag + return try await withCheckedThrowingContinuation { fortsetzung in + weiter = fortsetzung + festhalten = self + let s = NFCNDEFReaderSession(delegate: self, queue: queue, invalidateAfterFirstRead: false) + s.alertMessage = hinweis + session = s + s.begin() + } + } + + /// Beendet den Vorgang genau einmal. Nach dem Invalidieren meldet sich das + /// Delegate noch ein weiteres Mal – ohne diese Sperre liefe `resume` zweimal + /// und die App stürzte ab. + private func fertig(_ ergebnis: Result, meldung: String? = nil) { + guard let fortsetzung = weiter else { return } + weiter = nil + if let meldung { + session?.alertMessage = meldung + session?.invalidate() + } else if case .failure = ergebnis { + session?.invalidate() + } + session = nil + fortsetzung.resume(with: ergebnis) + festhalten = nil + } + + private func lesen(_ tag: NFCNDEFTag) { + tag.readNDEF { nachricht, fehler in + if let fehler { + self.fertig(.failure(Fehler.system(fehler.localizedDescription))) + return + } + guard let text = nachricht?.records.compactMap(Self.text(aus:)).first, !text.isEmpty else { + self.fertig(.failure(Fehler.leer)) + return + } + AudioServicesPlaySystemSound(1057) + self.fertig(.success(text), meldung: "Gelesen.") + } + } + + private func schreiben(_ tag: NFCNDEFTag, nutzlast: String, sperren: Bool) { + guard let record = NFCNDEFPayload.wellKnownTypeURIPayload(string: nutzlast) else { + fertig(.failure(Fehler.system("Der Inhalt lässt sich nicht in ein NDEF-Format bringen."))) + return + } + let nachricht = NFCNDEFMessage(records: [record]) + + tag.queryNDEFStatus { status, kapazitaet, fehler in + if let fehler { + self.fertig(.failure(Fehler.system(fehler.localizedDescription))) + return + } + switch status { + case .notSupported: + self.fertig(.failure(Fehler.nichtBeschreibbar)) + return + case .readOnly: + self.fertig(.failure(Fehler.schreibgeschuetzt)) + return + case .readWrite: + break + @unknown default: + self.fertig(.failure(Fehler.nichtBeschreibbar)) + return + } + guard nachricht.length <= kapazitaet else { + self.fertig(.failure(Fehler.zuKlein(benoetigt: nachricht.length, platz: kapazitaet))) + return + } + + tag.writeNDEF(nachricht) { fehler in + if let fehler { + self.fertig(.failure(Fehler.system(fehler.localizedDescription))) + return + } + guard sperren else { + AudioServicesPlaySystemSound(1057) + self.fertig(.success(nutzlast), meldung: "Tag beschrieben.") + return + } + tag.writeLock { fehler in + if let fehler { + // Geschrieben ist geschrieben – nur das Sperren ging schief. + // Das gehört gesagt, statt den ganzen Vorgang als + // gescheitert auszugeben. + self.fertig(.failure(Fehler.system( + "Tag beschrieben, aber das Sperren ist fehlgeschlagen: " + + fehler.localizedDescription))) + return + } + AudioServicesPlaySystemSound(1057) + self.fertig(.success(nutzlast), meldung: "Tag beschrieben und gesperrt.") + } + } + } + } + + /// Holt den lesbaren Inhalt aus einem Record – URI oder Text. + private static func text(aus record: NFCNDEFPayload) -> String? { + if let url = record.wellKnownTypeURIPayload() { return url.absoluteString } + // Liefert ein Tupel aus Optionals, nicht ein optionales Tupel. + if let text = record.wellKnownTypeTextPayload().0 { return text } + return String(data: record.payload, encoding: .utf8) + } +} + +// MARK: - NFCNDEFReaderSessionDelegate + +extension NFCTag: NFCNDEFReaderSessionDelegate { + + /// Wird nicht aufgerufen, solange `didDetect tags:` vorhanden ist – Core NFC + /// nimmt dann den Weg über die Tags. Das Protokoll verlangt die Methode + /// trotzdem. + func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {} + + func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) { + guard tags.count == 1, let tag = tags.first else { + session.alertMessage = "Mehrere Tags erkannt. Bitte nur eines anhalten." + session.restartPolling() + return + } + session.connect(to: tag) { fehler in + if let fehler { + self.fertig(.failure(Fehler.system(fehler.localizedDescription))) + return + } + switch self.auftrag { + case .lesen: + self.lesen(tag) + case let .schreiben(nutzlast, sperren): + self.schreiben(tag, nutzlast: nutzlast, sperren: sperren) + } + } + } + + func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) { + // Hat der Vorgang schon ein Ergebnis geliefert, ist das hier nur noch das + // Aufräumen danach. + guard weiter != nil else { return } + let abbruch = (error as? NFCReaderError)?.code + == .readerSessionInvalidationErrorUserCanceled + self.session = nil + fertig(.failure(abbruch ? Fehler.abgebrochen : Fehler.system(error.localizedDescription))) + } +} diff --git a/ios/Sources/NFCViews.swift b/ios/Sources/NFCViews.swift new file mode 100644 index 0000000..97416fa --- /dev/null +++ b/ios/Sources/NFCViews.swift @@ -0,0 +1,124 @@ +import SwiftUI + +/// Ein NFC-Tag mit dem Link auf ein Einzelstück beschreiben. +/// +/// Gedacht für Kleidung: Das Tag wird eingenäht und ersetzt den QR, der die +/// Wäsche nicht überstehen würde. Auf das Tag kommt genau das, was auch im QR +/// steht – `vorrania://i/`. +struct NFCSchreibenView: View { + let item: Item + + @Environment(\.dismiss) private var dismiss + + @State private var sperren = false + @State private var laeuft = false + @State private var erfolg = false + @State private var fehler: String? + + private var nutzlast: String { einzelstueckLink(fuer: item.uid) } + + var body: some View { + Form { + Section { + if let name = item.productName { + LabeledContent("Artikel", value: name) + } + LabeledContent("UID", value: item.uid) + } footer: { + Text("Auf das Tag wird geschrieben: \(nutzlast)") + } + + Section { + Toggle("Tag dauerhaft schreibschützen", isOn: $sperren) + } footer: { + if sperren { + Text("Das lässt sich nicht rückgängig machen. Das Tag gehört danach " + + "für immer zu diesem Einzelstück und kann nicht neu beschrieben werden.") + .foregroundStyle(.orange) + } else { + Text("Ohne Schreibschutz kannst du das Tag später einem anderen " + + "Einzelstück zuweisen.") + } + } + + Section { + Button(laeuft ? "Tag anhalten…" : "Tag beschreiben") { + Task { await schreiben() } + } + .disabled(laeuft) + } + + if erfolg { + Section { + Label("Tag beschrieben.", systemImage: "checkmark.circle") + .foregroundStyle(.green) + } + } + if let fehler { + Section { + Text(fehler).foregroundStyle(.red).font(.callout) + } + } + } + .navigationTitle("NFC-Tag") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } + } + + private func schreiben() async { + laeuft = true + fehler = nil + erfolg = false + defer { laeuft = false } + do { + try await NFCTag.schreiben(nutzlast, sperren: sperren) + erfolg = true + } catch NFCTag.Fehler.abgebrochen { + // Kein Fehler, sondern eine Entscheidung – nichts anzeigen. + } catch { + fehler = error.localizedDescription + } + } +} + +/// Knopf, der ein NFC-Tag liest und die gefundene Einzelstück-UID zurückgibt. +/// +/// Blendet sich auf Geräten ohne NFC (iPad) selbst aus, damit die umgebenden +/// Ansichten das nicht jedes Mal selbst prüfen müssen. +struct NFCLesenButton: View { + var titel: String = "NFC-Tag lesen" + var onUid: (String) -> Void + var onFehler: (String) -> Void + + @State private var laeuft = false + + var body: some View { + if NFCTag.verfuegbar { + Button { + Task { await lesen() } + } label: { + Label(laeuft ? "Tag anhalten…" : titel, systemImage: "wave.3.right") + } + .disabled(laeuft) + } + } + + private func lesen() async { + laeuft = true + defer { laeuft = false } + do { + let roh = try await NFCTag.lesen() + // Ein selbst beschriebenes Tag darf auch die nackte UID tragen – anders + // als bei der Kamera droht hier keine Verwechslung mit einem Barcode. + guard let uid = einzelstueckCode(aus: roh, nackteUidErlauben: true) else { + onFehler("Auf dem Tag steht kein Einzelstück: \(roh)") + return + } + onUid(uid) + } catch NFCTag.Fehler.abgebrochen { + // Nutzer hat abgebrochen – keine Meldung. + } catch { + onFehler(error.localizedDescription) + } + } +} diff --git a/ios/Sources/Vorrania.entitlements b/ios/Sources/Vorrania.entitlements index 4e3230e..5f7b942 100644 --- a/ios/Sources/Vorrania.entitlements +++ b/ios/Sources/Vorrania.entitlements @@ -2,9 +2,9 @@ - com.apple.developer.associated-domains + com.apple.developer.nfc.readersession.formats - applinks:vorrania-ch.scarriffle.com + NDEF diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 4a186ea..fb39216 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; + 10F43F9BA3CB785DEBBC625F /* NFCViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B6163E03197495FAA1F298 /* NFCViews.swift */; }; 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; 34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */; }; @@ -39,6 +40,7 @@ AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; }; B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */; }; D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; }; + D76F43864F9319FC8036A124 /* NFCTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA87A4690D179F825E67FC0 /* NFCTag.swift */; }; D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; }; DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 31DDA98DEF7F8937A8719C80 /* AppAuth */; }; @@ -67,6 +69,7 @@ 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = ""; }; + 5EA87A4690D179F825E67FC0 /* NFCTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTag.swift; sourceTree = ""; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = ""; }; 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; @@ -84,6 +87,7 @@ AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = ""; }; AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = ""; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = ""; }; + C2B6163E03197495FAA1F298 /* NFCViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCViews.swift; sourceTree = ""; }; C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = ""; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = ""; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = ""; }; @@ -131,6 +135,8 @@ 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 314B1BB7220691A7423E8929 /* Models.swift */, + 5EA87A4690D179F825E67FC0 /* NFCTag.swift */, + C2B6163E03197495FAA1F298 /* NFCViews.swift */, 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */, 3731608B8D48960DC98912BC /* NotificationSettings.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, @@ -267,6 +273,8 @@ B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */, + D76F43864F9319FC8036A124 /* NFCTag.swift in Sources */, + 10F43F9BA3CB785DEBBC625F /* NFCViews.swift in Sources */, 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, diff --git a/ios/project.yml b/ios/project.yml index 2c07bcd..809b5ff 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -50,11 +50,18 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" SWIFT_VERSION: "5.9" GENERATE_INFOPLIST_FILE: NO - # Universal Links: …/i/ öffnet direkt die App. Dafür muss im - # Apple-Developer-Portal die Capability "Associated Domains" für die App-ID - # aktiv sein (einmalig), sonst schlägt das Signieren fehl. + # NFC: eingenaehte Tags an Einzelstuecken lesen und beschreiben – fuer + # Kleidung, wo ein aufgeklebter QR die Waesche nicht uebersteht. + # Dafuer muss im Apple-Developer-Portal die Capability "Near Field + # Communication Tag Reading" fuer die App-ID aktiv sein (einmalig), sonst + # schlaegt das Signieren fehl. + # + # Associated Domains stand hier bis 2026-09 noch drin, obwohl f981f41 die + # Universal Links entfernt hat: XcodeGen erzeugt Sources/Vorrania.entitlements + # aus diesem Block und schrieb den Eintrag bei jedem "generate" zurueck. Die + # Domain existiert laengst nicht mehr, jede Installation fragte sie vergeblich ab. entitlements: path: Sources/Vorrania.entitlements properties: - com.apple.developer.associated-domains: - - applinks:vorrania-ch.scarriffle.com + com.apple.developer.nfc.readersession.formats: + - NDEF