diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 764d54c..90ecba6 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -52,6 +52,21 @@ actor CardsAPI { return try decoder.decode(CardListResponse.self, from: data).cards } + /// `GET /api/v1/decks/:deckId/distractors` — N zufällige Feldwerte + /// aus anderen Karten desselben Decks. Server-Schema erlaubt nur + /// `front`, `back`, `answer`, `question` als field. + func distractors( + deckId: String, + cardId: String, + field: String = "answer", + count: Int = 3 + ) async throws -> [String] { + let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)" + let (data, http) = try await transport.request(path: path) + try ensureOK(http, data: data) + return try decoder.decode(DistractorsResponse.self, from: data).distractors + } + /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { diff --git a/Sources/Core/Domain/Deck.swift b/Sources/Core/Domain/Deck.swift index cb3d007..6e7dd34 100644 --- a/Sources/Core/Domain/Deck.swift +++ b/Sources/Core/Domain/Deck.swift @@ -129,3 +129,8 @@ struct CardListResponse: Decodable, Sendable { struct DueReviewsResponse: Decodable, Sendable { let total: Int } + +/// Server-Response von `GET /api/v1/decks/:deckId/distractors`. +struct DistractorsResponse: Decodable, Sendable { + let distractors: [String] +} diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index ebf3908..e3c5d14 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -28,7 +28,9 @@ struct CardRenderer: View { imageOcclusionView case .audioFront: audioFrontView - case .typing, .multipleChoice: + case .multipleChoice: + MultipleChoiceCardView(card: card, isFlipped: isFlipped) + case .typing: placeholderView } } diff --git a/Sources/Features/Study/MultipleChoiceCardView.swift b/Sources/Features/Study/MultipleChoiceCardView.swift new file mode 100644 index 0000000..051b4f6 --- /dev/null +++ b/Sources/Features/Study/MultipleChoiceCardView.swift @@ -0,0 +1,199 @@ +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 + } +} diff --git a/project.yml b/project.yml index 971834e..6e20f26 100644 --- a/project.yml +++ b/project.yml @@ -55,7 +55,7 @@ targets: path: Sources/Resources/Info.plist properties: CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "7" + CFBundleVersion: "8" CFBundleDevelopmentRegion: de CFBundleDisplayName: Cardecky LSApplicationCategoryType: "public.app-category.education" @@ -111,7 +111,7 @@ targets: properties: CFBundleDisplayName: Als Karte speichern CFBundleShortVersionString: "0.1.0" - CFBundleVersion: "7" + CFBundleVersion: "8" 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: "7" + CFBundleVersion: "8" NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension entitlements: