import AppKit import SwiftUI struct CardsView: View { @EnvironmentObject private var app: AppModel @State private var route = CardRoute.tonuino @State private var folder = 1 @State private var mode: PlaybackMode = .album @State private var category: CardEditorCategory = .playback @State private var modifier: TonuinoModifier = .sleepTimer @State private var special = 1 @State private var special2 = 1 @State private var sleepTimerMinutes = 30 @State private var stopAfterCurrentTrack = false @State private var danceDifficulty = 1 @State private var busy = false @State private var tonuinoWritePending = false @State private var lastRead: TonuinoCard? @State private var lastUID = "" @AppStorage("reader.route") private var defaultRoute = "tonuino" var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 5) { Text("NFC-Karten").font(.largeTitle.bold()) Text("Ordnerkarten, Wiedergabemodi und Admin-Karten programmieren oder auslesen.").foregroundStyle(.secondary) }.padding() Divider() Form { Section("Programmierweg") { Picker("Kartenleser", selection: $route) { ForEach(CardRoute.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) if route == .tonuino { serialControls } else { readerControls } } Section("Karteninhalt") { Picker("Kategorie", selection: $category) { ForEach(CardEditorCategory.allCases) { Text($0.title).tag($0) } } if category == .playback { Picker("Kartenart", selection: $mode) { ForEach(PlaybackMode.allCases) { Text($0.title).tag($0) } } if mode.needsFolder && mode != .admin { if let library = app.library.folders.first(where: { $0.number == folder }) { Picker("Ordner", selection: $folder) { ForEach(app.library.folders) { item in Text("\(String(format: "%02d", item.number)) — \(item.title)").tag(item.number) } } .onAppear { if !app.library.folders.contains(where: { $0.number == folder }) { folder = library.number } } } else { Stepper("Ordner \(String(format: "%02d", folder))", value: $folder, in: 1...99) } } if mode.needsSpecial && mode != .quiz { Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255) } if mode.needsSpecial2 { if mode == .quiz { Picker("Fragen", selection: $special) { Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4) } Picker("Antworten pro Frage", selection: $special2) { Text("2").tag(0); Text("3").tag(1) } } else { Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255) } } Text("\(mode.title)" + (mode.needsFolder ? " · Ordner \(String(format: "%02d", folder))" : "")) .font(.caption).foregroundStyle(.secondary) } else { Picker("Sonderkarte", selection: $modifier) { ForEach(TonuinoModifier.allCases) { Text($0.title).tag($0) } } switch modifier { case .sleepTimer: Stepper("Laufzeit: \(sleepTimerMinutes) Minuten", value: $sleepTimerMinutes, in: 1...(stopAfterCurrentTrack ? 127 : 255)) Toggle("Nach dem aktuellen Track stoppen", isOn: $stopAfterCurrentTrack) case .freezeDance, .fireWaterAir: Picker("Pausenstufe", selection: $danceDifficulty) { Text("1").tag(0); Text("2").tag(1); Text("3").tag(2) } default: EmptyView() } if let note = modifier.firmwareNote { Label(note, systemImage: "info.circle") .font(.caption).foregroundStyle(.secondary) } Text(modifier.title + " · Ordnernummer 00") .font(.caption).foregroundStyle(.secondary) } } Section("Karte") { HStack { Label(lastUID.isEmpty ? "Keine Karte gelesen" : "UID \(lastUID)", systemImage: "wave.3.right") Spacer() if !lastUID.isEmpty { Text(lastRead.map { $0.displayTitle + ($0.folder > 0 ? " · Ordner \(String(format: "%02d", $0.folder))" : "") } ?? "Unprogrammierte Karte") .foregroundStyle(.secondary) } } HStack { Button("Karte lesen", systemImage: "doc.text.magnifyingglass") { Task { await readCard() } }.disabled(busy || route != .acr122u) Button("Programmieren", systemImage: "wave.3.right", role: .none) { Task { await writeCard() } } .buttonStyle(.borderedProminent).disabled(busy || tonuinoWritePending || !isRouteReady || (category == .modifier && route == .tonuino)) Button("Dump exportieren…", systemImage: "square.and.arrow.up") { exportDump() } .disabled(busy || tonuinoWritePending) if tonuinoWritePending { Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite(); tonuinoWritePending = false } catch { app.report(error) } } Button("Fertig") { do { try app.cards.cancelWrite(); tonuinoWritePending = false } catch { app.report(error) } } } if busy && route == .tonuino { Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite() } catch { app.report(error) } } } if route == .acr122u { Button("Karte löschen", systemImage: "eraser", role: .destructive) { Task { await eraseCard() } }.disabled(busy || !isRouteReady) } if busy { ProgressView().controlSize(.small); Text("TonUINO-Menü wird vorbereitet…").foregroundStyle(.secondary) } else if tonuinoWritePending { Label("Karte auf den TonUINO-Leser legen", systemImage: "wave.3.right").foregroundStyle(.secondary) } } if category == .modifier && route == .tonuino { Label("Der serielle TonUINO-Menüassistent kann diese Sonderkarte nicht anlegen. Nutze einen ACR122U-Reader oder exportiere einen Dump.", systemImage: "info.circle") .font(.caption).foregroundStyle(.secondary) } } }.formStyle(.grouped).scrollContentBackground(.hidden) Spacer(minLength: 0) Text(route == .tonuino ? "TonUINO muss im Leerlauf sein. Für USB-Steuerung muss SerialInputAsCommand in der Firmware aktiviert sein. Folge den Audioansagen und lege die Karte auf, wenn TonUINO dazu auffordert." : "ACR122U unterstützt MIFARE Mini, Classic 1K/4K und Ultralight/NTAG21x.") .font(.caption).foregroundStyle(.secondary).padding() } .onAppear { route = defaultRoute == "acr122u" ? .acr122u : .tonuino app.cards.refreshReaders(); app.cards.refreshSerialPorts() } .onChange(of: special) { newValue in if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue } } .onChange(of: stopAfterCurrentTrack) { enabled in if enabled { sleepTimerMinutes = min(sleepTimerMinutes, 127) } } .task(id: route) { guard route == .acr122u else { return } while !Task.isCancelled { if !busy { do { let (uid, card) = try await app.cards.readViaReader(); lastUID = uid; lastRead = card } catch { lastUID = ""; lastRead = nil } } try? await Task.sleep(nanoseconds: 1_500_000_000) } } } private var isRouteReady: Bool { route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty } private var specialLabel: String { mode == .single ? "Tracknummer: \(special)" : mode == .audiobookSingle ? "Zusatztracks: \(special)" : "Starttrack: \(special)" } private var serialControls: some View { HStack { Picker("Port", selection: $app.cards.selectedSerialPort) { ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } } Button("Aktualisieren", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() } if app.cards.serialConnected { Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) Button("Trennen") { app.cards.disconnectSerial() } } else { Button("Verbinden") { Task { do { try await app.cards.connectSerial() } catch { app.report(error) } } }.disabled(app.cards.selectedSerialPort.isEmpty) } } } private var readerControls: some View { HStack { Picker("Reader", selection: $app.cards.selectedReader) { ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } } Button("Reader suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() } Text(app.cards.readerNames.isEmpty ? "Kein Smartcard-Reader gefunden" : "Reader bereit") .foregroundStyle(app.cards.readerNames.isEmpty ? Color.secondary : Color.green) } } private func readCard() async { busy = true; defer { busy = false } do { let (uid, card) = try await app.cards.readViaReader() lastUID = uid lastRead = card if let card { folder = Int(card.folder) mode = card.mode special = Int(card.special) special2 = Int(card.special2) if let cardModifier = card.modifier { category = .modifier modifier = cardModifier if cardModifier == .sleepTimer { stopAfterCurrentTrack = card.special > 128 sleepTimerMinutes = Int(card.special) - (stopAfterCurrentTrack ? 128 : 0) } else if cardModifier == .freezeDance || cardModifier == .fireWaterAir { danceDifficulty = min(Int(card.special), 2) } } else { category = .playback } } app.status = card == nil ? "Unprogrammierte Karte" : "Karte ausgelesen" } catch { app.report(error) } } private func writeCard() async { busy = true; defer { busy = false } do { let card = try makeCard() if route == .tonuino { try await app.cards.writeViaTonuino(card) tonuinoWritePending = true app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen." } else { try await app.cards.writeViaReader(card) lastRead = card app.status = "Karte erfolgreich programmiert" } } catch { app.report(error) } } private func eraseCard() async { busy = true; defer { busy = false } do { try await app.cards.eraseViaReader(); lastRead = nil; app.status = "Karte gelöscht" } catch { app.report(error) } } private func makeCard() throws -> TonuinoCard { if category == .modifier { let parameter: UInt8 switch modifier { case .sleepTimer: parameter = UInt8(sleepTimerMinutes + (stopAfterCurrentTrack ? 128 : 0)) case .freezeDance, .fireWaterAir: parameter = UInt8(danceDifficulty) default: parameter = 0 } let value = TonuinoCard.makeModifier(modifier, special: parameter) try value.validate() return value } let value = TonuinoCard(folder: mode == .admin ? 0 : UInt8(folder), mode: mode, special: UInt8(special), special2: UInt8(special2)) try value.validate() return value } private func exportDump() { do { let card = try makeCard() let panel = NSSavePanel() panel.nameFieldStringValue = dumpFilename(for: card) panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator." panel.prompt = "Speichern" panel.canCreateDirectories = true guard panel.runModal() == .OK, let url = panel.url else { return } try MifareClassicDump.make1K(for: card).write(to: url, options: .atomic) app.status = "Kartendump exportiert: \(card.displayTitle)" } catch { app.report(error) } } private func dumpFilename(for card: TonuinoCard) -> String { let title = card.displayTitle .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased() .map { $0.isLetter || $0.isNumber ? $0 : "-" } .split(separator: "-") .joined(separator: "-") if card.modifier != nil { return "Tonuino-Sonderkarte-\(title).bin" } return "Tonuino-\(title)-Ordner-\(String(format: "%02d", card.folder)).bin" } } private enum CardEditorCategory: String, CaseIterable, Identifiable { case playback, modifier var id: String { rawValue } var title: String { self == .playback ? "Wiedergabekarte" : "Sonderkarte" } } enum CardRoute: String, CaseIterable, Identifiable { case tonuino, acr122u var id: String { rawValue } var title: String { self == .tonuino ? "Über TonUINO" : "ACR122U Reader" } }