import AppKit import SwiftUI struct LoginView: View { @State private var digits: [String] = Array(repeating: "", count: 6) @FocusState private var focusedField: Int? @State private var isLoading = false @State private var errorMessage: String? let onSuccess: (AuthSession) -> Void private var enteredCode: String { digits.joined() } private var isComplete: Bool { enteredCode.count == 6 && enteredCode.allSatisfy(\.isNumber) } var body: some View { VStack(spacing: 28) { Spacer().frame(height: 20) // App-Icon + Header VStack(spacing: 14) { appIconView .frame(width: 84, height: 84) Text("Rebreak Magic") .font(.title.bold()) VStack(spacing: 4) { Text("Mit der Rebreak-App verbinden") .font(.subheadline) .foregroundStyle(.secondary) Text("Öffne in der App: Einstellungen → Rebreak Magic") .font(.caption) .foregroundStyle(.tertiary) } } // 6-stelliger Code-Input VStack(spacing: 14) { HStack(spacing: 10) { ForEach(0..<6, id: \.self) { index in digitField(index: index) } } if let error = errorMessage { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.red) Text(error) .font(.caption) .foregroundStyle(.red) } } Button(action: handleSubmit) { HStack(spacing: 8) { if isLoading { ProgressView() .controlSize(.small) .tint(.white) } Text(isLoading ? "Verbinde…" : "Verbinden") .fontWeight(.medium) } .frame(maxWidth: .infinity) .padding(.vertical, 6) } .buttonStyle(.borderedProminent) .keyboardShortcut(.return, modifiers: []) .disabled(!isComplete || isLoading) } .frame(maxWidth: 380) Spacer() Text("Noch keine Rebreak-App? Lade sie im App Store / Play Store.") .font(.caption2) .foregroundStyle(.tertiary) .padding(.bottom, 18) } .padding(.horizontal, 32) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { focusedField = 0 } } // MARK: - Components @ViewBuilder private var appIconView: some View { if let icon = NSApplication.shared.applicationIconImage, icon.size.width > 2 { Image(nsImage: icon) .resizable() .interpolation(.high) .frame(width: 84, height: 84) } else { // Fallback: gefärbtes RoundedRect (entspricht macOS-Stil) RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(LinearGradient(colors: [.blue, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay( Image(systemName: "wand.and.stars") .font(.system(size: 40, weight: .semibold)) .foregroundStyle(.white) ) } } @ViewBuilder private func digitField(index: Int) -> some View { TextField("", text: Binding( get: { digits[index] }, set: { newValue in handleDigitInput(newValue, at: index) } )) .textFieldStyle(.plain) .multilineTextAlignment(.center) .font(.system(size: 28, weight: .semibold, design: .rounded)) .frame(width: 48, height: 60) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder( focusedField == index ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: focusedField == index ? 2 : 1 ) ) .focused($focusedField, equals: index) } // MARK: - Logic private func handleDigitInput(_ raw: String, at index: Int) { // Erlaubt: 0–9. Mehrere Zeichen → kann Paste sein ODER User tippt in // ein bereits gefülltes Feld (newValue = "alt+neu"). let onlyDigits = raw.filter(\.isNumber) let previous = digits[index] // Paste-Heuristik: 2+ Ziffern UND keine davon ist die alte Ziffer am Anfang, // oder Länge > 2. Sonst: User hat in ein gefülltes Feld eine neue Ziffer // getippt → letzte Ziffer als neuen Wert nehmen. let isPaste: Bool = { if onlyDigits.count >= 3 { return true } if onlyDigits.count == 2 { // Wenn beide Ziffern unterschiedlich sind und das erste Zeichen // dem bisherigen Wert entspricht → Replace-Tipp, kein Paste. if !previous.isEmpty && onlyDigits.first.map(String.init) == previous { return false } return true } return false }() if isPaste { // Paste / Auto-Fill: über Felder ab `index` verteilen let chars = Array(onlyDigits.prefix(6 - index)) for (offset, ch) in chars.enumerated() { let target = index + offset if target < 6 { digits[target] = String(ch) } } let nextFocus = min(index + chars.count, 5) advanceFocus(to: nextFocus) if digits.allSatisfy({ !$0.isEmpty }) && !isLoading { handleSubmit() } return } if onlyDigits.isEmpty { // Backspace digits[index] = "" if index > 0 { advanceFocus(to: index - 1) } return } // Single-digit Eingabe (oder Replace in gefülltes Feld → letzte Ziffer nehmen) let newDigit = String(onlyDigits.suffix(1)) digits[index] = newDigit if index < 5 { advanceFocus(to: index + 1) } else if isComplete && !isLoading { // Letztes Feld gefüllt → automatisch absenden handleSubmit() } } /// Focus-Wechsel muss async passieren, sonst kollidiert er mit dem laufenden /// TextField-Edit-Cycle und der Focus springt nicht zuverlässig. private func advanceFocus(to target: Int) { DispatchQueue.main.async { focusedField = target } } private func handleSubmit() { guard isComplete, !isLoading else { return } let code = enteredCode Task { isLoading = true errorMessage = nil do { let session = try await AuthService.shared.signInWithPairingCode(code) onSuccess(session) } catch { errorMessage = error.localizedDescription // Felder leeren bei Fehler digits = Array(repeating: "", count: 6) focusedField = 0 } isLoading = false } } } #Preview { LoginView { session in print("Magic-Session: \(session.sessionId)") } .frame(width: 720, height: 600) }