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/<UID>) - 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/<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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 13:37:24 +02:00
co-authored by Claude Opus 5
parent 9be772557e
commit 09a763a823
11 changed files with 508 additions and 56 deletions
+17 -1
View File
@@ -63,6 +63,21 @@ Es gibt zwei Wege direkt in den Scan-Bildschirm:
Beide Wege öffnen die App **direkt in der Kamera**. 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/<UID>`).
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 ## Aufbau der Quellen
| Datei | Inhalt | | Datei | Inhalt |
|---|---| |---|---|
@@ -79,7 +94,8 @@ Beide Wege öffnen die App **direkt in der Kamera**.
| `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren | | `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren |
| `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) | | `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) |
| `ItemViews.swift` / `ItemListView.swift` | Einzelstücke anlegen, bearbeiten und auflisten | | `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 | | `ListViews.swift` | Produktlisten, Einkaufsliste, „bald ablaufend", Mindestbestände |
| `DashboardView.swift` / `DashboardCards.swift` / `ChartCards.swift` | Übersicht mit Karten und Diagrammen | | `DashboardView.swift` / `DashboardCards.swift` / `ChartCards.swift` | Übersicht mit Karten und Diagrammen |
| `NotificationScheduler.swift` / `NotificationSettings*.swift` | Push-Benachrichtigungen bei Ablauf | | `NotificationScheduler.swift` / `NotificationSettings*.swift` | Push-Benachrichtigungen bei Ablauf |
+33 -9
View File
@@ -38,7 +38,19 @@ struct AssignScanView: View {
VStack(spacing: 8) { VStack(spacing: 8) {
Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:") Text("Schritt 1: Einzelstück-QR scannen – oder ohne Code:")
.font(.caption).foregroundStyle(.secondary) .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: { Button { showPicker = true } label: {
Label("Einzelstück aus Liste wählen", systemImage: "list.bullet") 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 error = "Erst ein Einzelstück scannen."; status = nil; return
} }
do { 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( _ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
locationId: locCode, shopId: it.shopId, 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." status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
error = nil error = nil
pending = nil pending = nil
@@ -89,16 +106,23 @@ struct AssignScanView: View {
} }
} }
// Einzelstück-QR (…/i/<UID>)? // Einzelstück-QR (…/i/<UID>)?
if let r = code.range(of: "/i/") { if let uid = einzelstueckCode(aus: code), await waehle(uid: uid) {
let uid = String(code[r.upperBound...]) return
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) {
pending = it; status = nil; error = nil
return
}
} }
error = "Unbekannter Code – bitte einen Einzelstück- oder Lagerort-QR scannen." 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. /// Einzelstück aus einer durchsuchbaren Liste wählen – für Stücke ohne eigenen QR.
+5 -9
View File
@@ -311,15 +311,11 @@ struct CheckInView: View {
unknownCode = nil unknownCode = nil
do { do {
// Einzelstück-QR (…/i/<UID>) → direkt das Stück öffnen statt Barcode-Suche. // Einzelstück-QR (…/i/<UID>) → direkt das Stück öffnen statt Barcode-Suche.
if let r = code.range(of: "/i/") { if let uid = einzelstueckCode(aus: code),
let uid = String(code[r.upperBound...]) let item = try? await APIClient.shared.itemByUid(uid: uid) {
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) scannedItem = item
.uppercased() scanShown = false
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { return
scannedItem = item
scanShown = false
return
}
} }
let result = try await APIClient.shared.lookup(barcode: code) let result = try await APIClient.shared.lookup(barcode: code)
suggestedGroupId = result.groupId suggestedGroupId = result.groupId
+4
View File
@@ -65,6 +65,10 @@
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string> <string>Die Kamera wird zum Scannen von Barcodes beim Ein- und Auslagern verwendet.</string>
<!-- NFC für die eingenähten Tags an Einzelstücken -->
<key>NFCReaderUsageDescription</key>
<string>NFC wird zum Lesen und Beschreiben der Tags an Einzelstücken verwendet.</string>
<!-- Lokaler Server ohne HTTPS (Heimnetz) --> <!-- Lokaler Server ohne HTTPS (Heimnetz) -->
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
+11 -23
View File
@@ -1,31 +1,8 @@
import SwiftUI import SwiftUI
import CoreImage
import PhotosUI import PhotosUI
import QuickLook import QuickLook
import UniformTypeIdentifiers 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 { private func dateToString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d) 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 pickerItem: PhotosPickerItem?
@State private var previewURL: URL? @State private var previewURL: URL?
@State private var locScanShown = false @State private var locScanShown = false
@State private var nfcShown = false
var body: some View { var body: some View {
Form { Form {
@@ -234,6 +212,13 @@ struct ItemEditView: View {
LabeledContent("Marke", value: brand) LabeledContent("Marke", value: brand)
} }
LabeledContent("UID", value: item.uid) 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 { Section {
@@ -337,6 +322,9 @@ struct ItemEditView: View {
LocationScannerView(locations: locations) { loc in locationId = loc.id } LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea() .ignoresSafeArea()
} }
.sheet(isPresented: $nfcShown) {
NavigationStack { NFCSchreibenView(item: item) }
}
.sheet(isPresented: $showRemove) { .sheet(isPresented: $showRemove) {
NavigationStack { removeSheet } NavigationStack { removeSheet }
} }
+19 -7
View File
@@ -27,6 +27,21 @@ struct LookupScanView: View {
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center) .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 { if let error {
Text(error).font(.callout).foregroundStyle(.red) Text(error).font(.callout).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -58,13 +73,10 @@ struct LookupScanView: View {
paused = true paused = true
error = nil error = nil
// Einzelstück-QR (…/i/<UID>)? // Einzelstück-QR (…/i/<UID>)?
if let r = code.range(of: "/i/") { if let uid = einzelstueckCode(aus: code),
let uid = String(code[r.upperBound...]) let found = try? await APIClient.shared.itemByUid(uid: uid) {
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased() item = found
if !uid.isEmpty, let found = try? await APIClient.shared.itemByUid(uid: uid) { return
item = found
return
}
} }
// Lagerort-QR (…/l/<Code>)? → alles zeigen, was dort (inkl. Unterorte) liegt. // Lagerort-QR (…/l/<Code>)? → alles zeigen, was dort (inkl. Unterorte) liegt.
if let r = code.range(of: "/l/") { if let r = code.range(of: "/l/") {
+273
View File
@@ -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/<UID>` und die frühere Form `https://server/i/<UID>`,
/// 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<String, Error>?
/// 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<String, Error>, 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)))
}
}
+124
View File
@@ -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/<UID>`.
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)
}
}
}
+2 -2
View File
@@ -2,9 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.associated-domains</key> <key>com.apple.developer.nfc.readersession.formats</key>
<array> <array>
<string>applinks:vorrania-ch.scarriffle.com</string> <string>NDEF</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+8
View File
@@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.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 */; }; 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.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 */; }; AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */; }; B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */; };
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.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 */; }; D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 31DDA98DEF7F8937A8719C80 /* AppAuth */; }; 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 = "<group>"; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = "<group>"; };
5EA87A4690D179F825E67FC0 /* NFCTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTag.swift; sourceTree = "<group>"; };
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; }; 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
@@ -84,6 +87,7 @@
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; }; AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; }; AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
C2B6163E03197495FAA1F298 /* NFCViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCViews.swift; sourceTree = "<group>"; };
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = "<group>"; }; C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = "<group>"; };
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
@@ -131,6 +135,8 @@
18F22598772AD8B5EB6394F8 /* LookupScanView.swift */, 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */,
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
314B1BB7220691A7423E8929 /* Models.swift */, 314B1BB7220691A7423E8929 /* Models.swift */,
5EA87A4690D179F825E67FC0 /* NFCTag.swift */,
C2B6163E03197495FAA1F298 /* NFCViews.swift */,
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */, 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
3731608B8D48960DC98912BC /* NotificationSettings.swift */, 3731608B8D48960DC98912BC /* NotificationSettings.swift */,
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
@@ -267,6 +273,8 @@
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */, B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */,
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
6816C33381DF52A96E5BB303 /* Models.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
D76F43864F9319FC8036A124 /* NFCTag.swift in Sources */,
10F43F9BA3CB785DEBBC625F /* NFCViews.swift in Sources */,
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
+12 -5
View File
@@ -50,11 +50,18 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "5.9" SWIFT_VERSION: "5.9"
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
# Universal Links: …/i/<UID> öffnet direkt die App. Dafür muss im # NFC: eingenaehte Tags an Einzelstuecken lesen und beschreiben – fuer
# Apple-Developer-Portal die Capability "Associated Domains" für die App-ID # Kleidung, wo ein aufgeklebter QR die Waesche nicht uebersteht.
# aktiv sein (einmalig), sonst schlägt das Signieren fehl. # 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: entitlements:
path: Sources/Vorrania.entitlements path: Sources/Vorrania.entitlements
properties: properties:
com.apple.developer.associated-domains: com.apple.developer.nfc.readersession.formats:
- applinks:vorrania-ch.scarriffle.com - NDEF