import SwiftUI /// Typing-Karten-View: User tippt Antwort, drückt Submit → Match-Badge /// (correct/close/wrong) + User-Eingabe + erwartete Antwort. /// /// Web-Vorbild: `cards/apps/web/src/lib/components/TypingView.svelte`. /// Match-Logik in `Typing.check(input:answer:aliases:)` portiert aus /// `cards/packages/cards-domain/src/typing.ts`. /// /// Beim Flip vom Parent setzt sich `submitted` synthetisch — falls /// User nicht selbst tippt und nur "Antwort anzeigen" benutzt, kommen /// trotzdem korrekte Antwort und manuelle Bewertung über die RatingBar. struct TypingCardView: View { let card: ReviewCard let isFlipped: Bool @State private var input: String = "" @State private var submitted: Bool = false @State private var result: TypingMatch? @FocusState private var inputFocused: Bool private var answer: String { card.fields["answer"] ?? "" } private var aliases: String? { card.fields["aliases"] } var body: some View { VStack(alignment: .leading, spacing: 16) { text(card.fields["front"] ?? "") .font(.title3) .foregroundStyle(CardsTheme.foreground) if submitted || isFlipped { resultView } else { inputRow } } .frame(maxWidth: .infinity, alignment: .leading) .onChange(of: isFlipped) { _, flipped in // Falls der User über die generische RatingBar auf "Antwort // anzeigen" tippt ohne zu raten, springen wir trotzdem in // den Result-Modus mit "wrong" als Default-Match (nicht // bewertet). if flipped, !submitted { result = nil submitted = true } } .onChange(of: card.id) { _, _ in input = "" submitted = false result = nil } } // MARK: - Input private var inputRow: some View { HStack(spacing: 8) { TextField("Antwort eingeben …", text: $input) .textFieldStyle(.plain) .focused($inputFocused) .padding(.vertical, 10) .padding(.horizontal, 12) .background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(inputFocused ? CardsTheme.primary : CardsTheme.border, lineWidth: 1) ) .autocorrectionDisabled() #if os(iOS) .textInputAutocapitalization(.never) #endif .onSubmit { submit() } Button { submit() } label: { Image(systemName: "return") .font(.title3) .frame(width: 44, height: 44) .background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .foregroundStyle(CardsTheme.primaryForeground) } .buttonStyle(.plain) .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty) } .onAppear { // SwiftUI's Focus-State braucht einen Tick nach onAppear DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { inputFocused = true } } } // MARK: - Result @ViewBuilder private var resultView: some View { if let result { HStack(spacing: 8) { Text(badgeLabel(for: result)) .font(.caption.weight(.semibold)) .padding(.horizontal, 10) .padding(.vertical, 4) .background(badgeBackground(for: result), in: Capsule()) .foregroundStyle(badgeForeground(for: result)) if !input.isEmpty { Text("„\(input)“") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) .lineLimit(1) } Spacer(minLength: 0) } } Divider().background(CardsTheme.border) Text(answer) .font(.title3.weight(.medium)) .foregroundStyle(CardsTheme.foreground) if result == nil, !submitted { // unwahrscheinlich erreicht, aber als Sicherheits-Branch EmptyView() } } // MARK: - Logic private func submit() { guard !submitted else { return } let trimmed = input.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return } inputFocused = false result = Typing.check(input: trimmed, answer: answer, aliases: aliases) submitted = true triggerHaptic() } private func triggerHaptic() { #if canImport(UIKit) let style: UIImpactFeedbackGenerator.FeedbackStyle = result == .correct ? .heavy : .light UIImpactFeedbackGenerator(style: style).impactOccurred() #endif } private func badgeLabel(for result: TypingMatch) -> String { switch result { case .correct: "✓ Richtig" case .close: "≈ Fast" case .wrong: "✗ Falsch" } } private func badgeBackground(for result: TypingMatch) -> Color { switch result { case .correct: CardsTheme.success.opacity(0.18) case .close: CardsTheme.warning.opacity(0.18) case .wrong: CardsTheme.error.opacity(0.18) } } private func badgeForeground(for result: TypingMatch) -> Color { switch result { case .correct: CardsTheme.success case .close: CardsTheme.warning case .wrong: CardsTheme.error } } private func text(_ markdown: String) -> some View { let attributed = (try? AttributedString( markdown: markdown, options: AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace ) )) ?? AttributedString(markdown) return Text(attributed) .multilineTextAlignment(.leading) } } #if canImport(UIKit) import UIKit #endif