From 505aa9db19669be08b5840e0e3b22d914e602521 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 13 May 2026 17:39:39 +0200 Subject: [PATCH] feat(study): Typing-Karten + Levenshtein-Match-Logik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardRenderer für typing ist nicht mehr Placeholder. Web-Vorbild: TypingView.svelte + cards-domain/typing.ts. Typing.swift (Sources/Core/Domain/): - check(input:answer:aliases:) → TypingMatch (correct/close/wrong) - Normalisierung: trim + lowercase + NFD-Decomp + Combining-Marks strippen (Diakritika: ä → a) - Aliases-Support (Komma-getrennt aus card.fields["aliases"]) - Levenshtein-Threshold max(1, floor(len * 0.2)) → "close" TypingCardView (Features/Study/): - TextField mit Auto-Focus 0.15s nach onAppear, Return = Submit - Submit-Button mit Return-Symbol + primary background - Nach Submit: Badge (✓ Richtig / ≈ Fast / ✗ Falsch) + User- Eingabe in „…" Quotes + Divider + erwartete Antwort - Haptic-Feedback: heavy bei correct, light bei close/wrong - Reset on card.id change TypingTests: 8 Tests für check() — exact, case+whitespace, NFD-Umlauts, aliases, Levenshtein-close (Berln → Berlin), empty-input, sowie Levenshtein-Helper-Sanity. Build 8 → 9. 43 Tests grün (war 35). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Core/Domain/Typing.swift | 77 ++++++++ Sources/Features/Study/CardRenderer.swift | 2 +- Sources/Features/Study/TypingCardView.swift | 187 ++++++++++++++++++++ Tests/UnitTests/TypingTests.swift | 55 ++++++ project.yml | 6 +- 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 Sources/Core/Domain/Typing.swift create mode 100644 Sources/Features/Study/TypingCardView.swift create mode 100644 Tests/UnitTests/TypingTests.swift diff --git a/Sources/Core/Domain/Typing.swift b/Sources/Core/Domain/Typing.swift new file mode 100644 index 0000000..25aa7b4 --- /dev/null +++ b/Sources/Core/Domain/Typing.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Vergleich einer getippten User-Antwort gegen die erwartete Antwort. +/// 1:1-Port aus `cards/packages/cards-domain/src/typing.ts`: +/// Normalisierung (lowercase, trim, NFD-Diakritika-Stripping), +/// dann exact-match → `correct`. Sonst Levenshtein-Distanz mit +/// Threshold `max(1, floor(answer.length * 0.2))` → `close`. +enum TypingMatch: Sendable, Equatable { + case correct + case close + case wrong +} + +enum Typing { + /// `aliases` ist ein Komma-getrennter String aus dem `aliases`-Feld + /// der Karte (optional). Jeder Alias zählt als gültige Antwort. + static func check(input: String, answer: String, aliases: String? = nil) -> TypingMatch { + let normInput = normalize(input) + guard !normInput.isEmpty else { return .wrong } + + var candidates = [answer] + if let aliases { + candidates.append(contentsOf: aliases.split(separator: ",").map(String.init)) + } + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return .wrong } + + if normalizedCandidates.contains(normInput) { + return .correct + } + + let shortestLen = normalizedCandidates.map(\.count).min() ?? normInput.count + let threshold = max(1, Int(Double(shortestLen) * 0.2)) + for candidate in normalizedCandidates where levenshtein(normInput, candidate) <= threshold { + return .close + } + return .wrong + } + + private static func normalize(_ string: String) -> String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let lowered = trimmed.lowercased() + // NFD-Dekomposition + Combining-Marks entfernen (z.B. ä → a) + let decomposed = lowered.decomposedStringWithCanonicalMapping + let stripped = decomposed.unicodeScalars.filter { scalar in + !(0x0300 ... 0x036F).contains(scalar.value) + } + return String(String.UnicodeScalarView(stripped)) + } + + static func levenshtein(_ a: String, _ b: String) -> Int { + let aChars = Array(a) + let bChars = Array(b) + let m = aChars.count + let n = bChars.count + if m == 0 { return n } + if n == 0 { return m } + + var row = Array(0 ... n) + for i in 1 ... m { + var prev = row[0] + row[0] = i + for j in 1 ... n { + let tmp = row[j] + if aChars[i - 1] == bChars[j - 1] { + row[j] = prev + } else { + row[j] = 1 + Swift.min(prev, row[j], row[j - 1]) + } + prev = tmp + } + } + return row[n] + } +} diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index e3c5d14..192a294 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -31,7 +31,7 @@ struct CardRenderer: View { case .multipleChoice: MultipleChoiceCardView(card: card, isFlipped: isFlipped) case .typing: - placeholderView + TypingCardView(card: card, isFlipped: isFlipped) } } .padding(24) diff --git a/Sources/Features/Study/TypingCardView.swift b/Sources/Features/Study/TypingCardView.swift new file mode 100644 index 0000000..8e5b75e --- /dev/null +++ b/Sources/Features/Study/TypingCardView.swift @@ -0,0 +1,187 @@ +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 diff --git a/Tests/UnitTests/TypingTests.swift b/Tests/UnitTests/TypingTests.swift new file mode 100644 index 0000000..86f0265 --- /dev/null +++ b/Tests/UnitTests/TypingTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CardsNative + +@Suite("Typing-Match") +struct TypingTests { + @Test("Exact match → correct") + func exactMatch() { + #expect(Typing.check(input: "Berlin", answer: "Berlin") == .correct) + } + + @Test("Case + Whitespace normalisiert") + func caseAndWhitespace() { + #expect(Typing.check(input: " berlin ", answer: "Berlin") == .correct) + #expect(Typing.check(input: "BERLIN", answer: "berlin") == .correct) + } + + @Test("Umlaute via NFD-Diakritika-Stripping") + func umlauts() { + #expect(Typing.check(input: "Munchen", answer: "München") == .correct) + #expect(Typing.check(input: "muenchen", answer: "München") != .correct) + // muenchen != munchen via NFD: ä → a, aber ue ≠ ü + } + + @Test("Aliases akzeptiert als correct") + func aliasesCorrect() { + let aliases = "Frankfurt am Main,Frankfurt/Main" + #expect(Typing.check(input: "Frankfurt/Main", answer: "Frankfurt", aliases: aliases) == .correct) + } + + @Test("Levenshtein-1 bei 5+ Zeichen → close") + func closeMatch() { + #expect(Typing.check(input: "Berln", answer: "Berlin") == .close) + #expect(Typing.check(input: "Berlim", answer: "Berlin") == .close) + } + + @Test("Großer Unterschied → wrong") + func wrongMatch() { + #expect(Typing.check(input: "Madrid", answer: "Berlin") == .wrong) + } + + @Test("Leer-Input → wrong") + func emptyInput() { + #expect(Typing.check(input: "", answer: "Berlin") == .wrong) + #expect(Typing.check(input: " ", answer: "Berlin") == .wrong) + } + + @Test("Levenshtein-Helper") + func levenshteinSanity() { + #expect(Typing.levenshtein("", "") == 0) + #expect(Typing.levenshtein("abc", "abc") == 0) + #expect(Typing.levenshtein("abc", "abd") == 1) + #expect(Typing.levenshtein("kitten", "sitting") == 3) + } +} diff --git a/project.yml b/project.yml index 6e20f26..552841b 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" NSExtension: NSExtensionPointIdentifier: com.apple.share-services NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).ShareViewController @@ -144,7 +144,7 @@ targets: properties: CFBundleDisplayName: Cardecky Widget CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "8" + CFBundleVersion: "9" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: