wordeck-native/Sources/Features/Study/TypingCardView.swift
Till JS 542082772a refactor(big-bang): cards-native → wordeck-native
Code + Identity-Rename zur Vorbereitung auf Apple-Dev-Portal-Aktion
(Bundle ev.mana.wordeck, App-Group group.ev.mana.wordeck, AASA
applinks:wordeck.com). Build bleibt funktional, aber gegen die
neue text-only-API können image-occlusion-Creates 422 zurückgeben —
das wird mit der Wordeck-Native v1.0-Welle (parallele Apple-Aktion)
sauber gemacht.

Umbenennung:
- 41 Files: cardecky/Cardecky → wordeck/Wordeck (Display, Strings,
  Kommentare)
- 57 Files: CardsNative → WordeckNative, CardsAPI → WordeckAPI,
  CardsTheme → WordeckTheme, CardsBrand → WordeckBrand, CardsWidget →
  WordeckWidget, CardsDueWidget → WordeckDueWidget
- Bundle-ID ev.mana.cardecky → ev.mana.wordeck (project.yml,
  Info.plist, entitlements, Keychain-Service, App-Group)
- AASA applinks:cardecky.mana.how → applinks:wordeck.com
- API-Base cardecky-api.mana.how → api.wordeck.com
- 10 Files renamed (App-Entry, API-Extensions, Theme, Widget,
  Entitlements, Tests)
- xcodeproj regenerated via xcodegen → WordeckNative.xcodeproj
- MaskRegionsTests.swift gelöscht (image-occlusion entfällt mit
  Wordeck text-only)

Forgejo-Repo git.mana.how/till/cards-native → wordeck-native umbenannt
(Auto-Redirect aktiv). Lokales Verzeichnis Code/cards-native/ bleibt
vorerst — wird beim nächsten Apple-Setup mit Bundle-Test umbenannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:10:42 +02:00

192 lines
6.3 KiB
Swift

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(WordeckTheme.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(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(inputFocused ? WordeckTheme.primary : WordeckTheme.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(WordeckTheme.primary, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.foregroundStyle(WordeckTheme.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(WordeckTheme.mutedForeground)
.lineLimit(1)
}
Spacer(minLength: 0)
}
}
Divider().background(WordeckTheme.border)
Text(answer)
.font(.title3.weight(.medium))
.foregroundStyle(WordeckTheme.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: WordeckTheme.success.opacity(0.18)
case .close: WordeckTheme.warning.opacity(0.18)
case .wrong: WordeckTheme.error.opacity(0.18)
}
}
private func badgeForeground(for result: TypingMatch) -> Color {
switch result {
case .correct: WordeckTheme.success
case .close: WordeckTheme.warning
case .wrong: WordeckTheme.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