import AppKit import SwiftUI struct FolderCardWriterView: View { let folder: MusicFolder @EnvironmentObject private var app: AppModel @Environment(\.dismiss) private var dismiss @AppStorage("reader.route") private var savedRoute = "tonuino" @State private var route: CardRoute = .tonuino @State private var mode: PlaybackMode = .album @State private var special = 1 @State private var special2 = 1 @State private var isWriting = false @State private var tonuinoWritePending = false @State private var resultMessage: String? @State private var writeSucceeded = false private var isRouteReady: Bool { route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty } var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("NFC-Karte programmieren").font(.title2.bold()) Text("\(folder.title) · Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks") .foregroundStyle(.secondary) } Form { Section("Wiedergabe") { Picker("Modus", selection: $mode) { ForEach(PlaybackMode.allCases) { item in Text(item.title).tag(item) } } 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.needsFolder && mode != .admin ? "Die Karte verweist auf Ordner \(String(format: "%02d", folder.number))." : "Dieser Modus verwendet keine Ordnernummer.") .font(.caption).foregroundStyle(.secondary) } Section("Kartenleser") { Picker("Schreibweg", selection: $route) { ForEach(CardRoute.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) if route == .tonuino { HStack { Picker("Serieller Port", selection: $app.cards.selectedSerialPort) { ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } } Button("Suchen", 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 { await connectSerial() } } .disabled(app.cards.selectedSerialPort.isEmpty || isWriting) } } } else { HStack { Picker("Reader", selection: $app.cards.selectedReader) { ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } } Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() } } Text(app.cards.readerNames.isEmpty ? "Kein PC/SC-Reader gefunden." : "Karte auf den ausgewählten Reader legen, wenn die App wartet.") .font(.caption).foregroundStyle(.secondary) } } } .formStyle(.grouped) .scrollContentBackground(.hidden) if let resultMessage { Label(resultMessage, systemImage: writeSucceeded ? "checkmark.circle.fill" : (tonuinoWritePending ? "info.circle.fill" : "exclamationmark.triangle.fill")) .foregroundStyle(writeSucceeded ? Color.green : (tonuinoWritePending ? Color.secondary : Color.red)) .fixedSize(horizontal: false, vertical: true) } HStack { Text(route == .tonuino ? "TonUINO muss im Leerlauf sein. Lege die Karte auf, sobald die Firmware dazu auffordert." : "Lege eine unterstützte MIFARE- oder NTAG-Karte auf den PC/SC-Reader.") .font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) Button("Schließen") { if tonuinoWritePending { try? app.cards.cancelWrite() } dismiss() }.keyboardShortcut(.cancelAction) if tonuinoWritePending { Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite(); tonuinoWritePending = false; resultMessage = "Schreibvorgang abgebrochen." } catch { resultMessage = error.localizedDescription } } Button("Fertig") { do { try app.cards.cancelWrite(); tonuinoWritePending = false; dismiss() } catch { resultMessage = error.localizedDescription } } } Button("Dump exportieren…", systemImage: "square.and.arrow.up") { exportDump() } .disabled(isWriting || tonuinoWritePending) Button(isWriting ? "Menü wird vorbereitet…" : (tonuinoWritePending ? "Warte auf Karte…" : "Karte programmieren"), systemImage: "wave.3.right") { Task { await writeCard() } } .buttonStyle(.borderedProminent) .disabled(isWriting || tonuinoWritePending || !isRouteReady) } } .padding(22) .frame(width: 700, height: 610) .onAppear { route = savedRoute == "acr122u" ? .acr122u : .tonuino app.cards.refreshReaders() app.cards.refreshSerialPorts() } .onChange(of: route) { newValue in savedRoute = newValue.rawValue } .onChange(of: special) { newValue in if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue } } } private var specialLabel: String { switch mode { case .single: "Tracknummer: \(special)" case .audiobookSingle: "Zusatztracks: \(special)" default: "Starttrack: \(special)" } } private func connectSerial() async { do { try await app.cards.connectSerial() } catch { resultMessage = error.localizedDescription; writeSucceeded = false } } private func makeCard() throws -> TonuinoCard { let value = TonuinoCard( folder: mode == .admin ? 0 : UInt8(folder.number), mode: mode, special: UInt8(special), special2: UInt8(special2) ) try value.validate() return value } private func exportDump() { do { let dump = MifareClassicDump.make1K(for: try makeCard()) let panel = NSSavePanel() panel.nameFieldStringValue = dumpFilename 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 dump.write(to: url, options: .atomic) resultMessage = "Kartendump erfolgreich exportiert (1.024 Byte)." writeSucceeded = true app.status = "Kartendump für Ordner \(String(format: "%02d", folder.number)) exportiert" } catch { resultMessage = error.localizedDescription writeSucceeded = false } } private var dumpFilename: String { let normalizedMode = mode.title .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) .lowercased() .map { $0.isLetter || $0.isNumber ? $0 : "-" } let modeName = String(normalizedMode) .split(separator: "-") .joined(separator: "-") return "Tonuino-Ordner-\(String(format: "%02d", folder.number))-\(modeName).bin" } private func writeCard() async { isWriting = true resultMessage = nil defer { isWriting = false } do { let value = try makeCard() if route == .tonuino { try await app.cards.writeViaTonuino(value) tonuinoWritePending = true resultMessage = "TonUINO-Schreibassistent läuft. Folge den Audioansagen, lege die Karte auf und klicke nach der Bestätigung auf „Fertig“." writeSucceeded = false app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen." } else { try await app.cards.writeViaReader(value) resultMessage = "Karte erfolgreich programmiert." writeSucceeded = true app.status = "Karte für Ordner \(String(format: "%02d", folder.number)) programmiert" } } catch { resultMessage = error.localizedDescription writeSucceeded = false } } }