import ManaCore import SwiftUI /// Multiple-Choice-Karten-View: zeigt 4 shuffled Optionen (1 richtige /// + 3 Distractors vom Server). User-Tap markiert Wahl, beim Flip /// werden richtige Antwort + Wahl hervorgehoben. /// /// Web-Vorbild: `cards/apps/web/src/lib/components/MultipleChoiceView.svelte`. struct MultipleChoiceCardView: View { let card: ReviewCard let isFlipped: Bool @Environment(AuthClient.self) private var auth @State private var options: [String] = [] @State private var selected: String? @State private var phase: LoadPhase = .loading enum LoadPhase: Sendable { case loading case ready case tooFew // < 1 Distractor → manueller Modus case failed } var body: some View { VStack(alignment: .leading, spacing: 16) { text(card.fields["front"] ?? "") .font(.title3) .foregroundStyle(CardsTheme.foreground) switch phase { case .loading: ProgressView() .tint(CardsTheme.primary) .frame(maxWidth: .infinity) .padding(.top, 12) case .ready: ForEach(options, id: \.self) { option in optionRow(option) } case .tooFew: if isFlipped { answerOnlyView } else { Text("Nicht genug andere Karten im Deck für Multiple-Choice — tippe auf »Antwort anzeigen«.") .font(.caption) .foregroundStyle(CardsTheme.mutedForeground) } case .failed: Text("Distractors konnten nicht geladen werden.") .font(.caption) .foregroundStyle(CardsTheme.error) } } .frame(maxWidth: .infinity, alignment: .leading) .task(id: card.id) { await loadOptions() } } /// Option-Row mit dynamischem Highlight: vor Flip nur Selected-Hint, /// nach Flip wird richtige Antwort grün, falsche-aber-gewählte rot. @ViewBuilder private func optionRow(_ option: String) -> some View { let isCorrect = option == card.fields["answer"] let isSelected = option == selected Button { if selected == nil { selected = option } } label: { HStack(alignment: .top, spacing: 12) { statusIcon(isCorrect: isCorrect, isSelected: isSelected) .frame(width: 22) Text(option) .font(.subheadline) .foregroundStyle(CardsTheme.foreground) .multilineTextAlignment(.leading) Spacer(minLength: 0) } .padding(.vertical, 12) .padding(.horizontal, 14) .background(background(isCorrect: isCorrect, isSelected: isSelected), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(border(isCorrect: isCorrect, isSelected: isSelected), lineWidth: 1) ) } .buttonStyle(.plain) .disabled(isFlipped || selected != nil) } @ViewBuilder private func statusIcon(isCorrect: Bool, isSelected: Bool) -> some View { if isFlipped { if isCorrect { Image(systemName: "checkmark.circle.fill") .foregroundStyle(CardsTheme.success) } else if isSelected { Image(systemName: "xmark.circle.fill") .foregroundStyle(CardsTheme.error) } else { Image(systemName: "circle") .foregroundStyle(CardsTheme.mutedForeground.opacity(0.4)) } } else if isSelected { Image(systemName: "largecircle.fill.circle") .foregroundStyle(CardsTheme.primary) } else { Image(systemName: "circle") .foregroundStyle(CardsTheme.mutedForeground.opacity(0.4)) } } private func background(isCorrect: Bool, isSelected: Bool) -> Color { if isFlipped { if isCorrect { return CardsTheme.success.opacity(0.12) } if isSelected { return CardsTheme.error.opacity(0.10) } return CardsTheme.surfaceHover } return isSelected ? CardsTheme.primary.opacity(0.10) : CardsTheme.surface } private func border(isCorrect: Bool, isSelected: Bool) -> Color { if isFlipped { if isCorrect { return CardsTheme.success.opacity(0.55) } if isSelected { return CardsTheme.error.opacity(0.55) } return CardsTheme.border } return isSelected ? CardsTheme.primary.opacity(0.5) : CardsTheme.border } /// Fallback wenn nicht genug Distractors: zeigt die Antwort /// direkt nach Flip, ohne Auswahl-Spiel. private var answerOnlyView: some View { VStack(alignment: .leading, spacing: 6) { Divider().background(CardsTheme.border) Text(card.fields["answer"] ?? "") .font(.title3) .foregroundStyle(CardsTheme.primary) .padding(.top, 4) } } 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) } private func loadOptions() async { phase = .loading selected = nil let api = CardsAPI(auth: auth) let answer = card.fields["answer"] ?? "" var distractors: [String] = [] // Erst answer-Feld versuchen, dann back-Feld als Fallback // (Decks mit basic/basic-reverse-Karten daneben). for field in ["answer", "back"] { if distractors.count >= 3 { break } if let result = try? await api.distractors( deckId: card.deckId, cardId: card.id, field: field, count: 3 ) { let filtered = result.filter { $0 != answer && !distractors.contains($0) } distractors.append(contentsOf: filtered) } } // Fallback aus statischem distractor_pool-Field (Web-Pattern) if distractors.count < 3, let pool = card.fields["distractor_pool"] { let poolItems = pool .split(separator: "\n") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty && $0 != answer && !distractors.contains($0) } distractors.append(contentsOf: poolItems) } if distractors.isEmpty { phase = .tooFew return } let finalDistractors = Array(distractors.prefix(3)) options = ([answer] + finalDistractors).shuffled() phase = .ready } }