Vocab Practice — verb pool, Settings level filter, per-verb SRS
Vocab Practice used a deck picker over VocabCard rows. That meant it
ignored the Settings level toggles entirely and operated on a totally
separate vocabulary universe than the conjugation modes. Rewired
end-to-end:
Pool source
Replaced VocabCard with the Verb table. The pool is now
ReferenceStore.fetchVerbs(selectedLevels: UserProgress.selectedVerbLevels)
— the same call PracticeSessionService uses for conjugation. Changes
to level toggles in Settings (or the Verbs tab, which also writes to
this field) immediately affect Vocab Practice.
Entry flow
Deleted VocabPracticeEntryView. Practice → Vocabulary now has two
direct entries:
• Vocab Flashcards — verb.english → tap → verb.infinitive
• Vocab Multiple Choice — verb.english → pick from 4 infinitives
Both pull from the same level-filtered pool, shuffled.
Per-verb SRS
New VerbReviewCard @Model (cloud-synced, mirrors CourseReviewCard's
SM-2 fields but keyed by verbId). VerbReviewStore.rate(verbId:quality:)
applies the existing SRSEngine. Registered in cloud container schema
in ConjugaApp.swift.
Example sentences
Lazy-generated via VerbExampleGenerator on first reveal, cached
through VerbExampleCache (same path VerbDetailView uses). Empty until
the example arrives — block hides itself if Apple Intelligence isn't
available.
AI illustration
VerbIllustration replaces VocabIllustration; same Image Playground
pipeline. Cache key uses ("verb", infinitive, english) so verbs and
course-deck vocab never collide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,6 @@
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
|
||||
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.swift */; };
|
||||
3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */; };
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
|
||||
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */; };
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
||||
@@ -50,6 +49,7 @@
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||
@@ -238,6 +238,7 @@
|
||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||
@@ -254,7 +255,6 @@
|
||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabPracticeEntryView.swift; sourceTree = "<group>"; };
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
||||
@@ -369,6 +369,7 @@
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
||||
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -376,7 +377,6 @@
|
||||
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */,
|
||||
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
|
||||
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
|
||||
);
|
||||
@@ -800,9 +800,9 @@
|
||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
|
||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
|
||||
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */,
|
||||
3F7C308425743919FC4407A8 /* VocabPracticeEntryView.swift in Sources */,
|
||||
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
|
||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -71,14 +71,14 @@ struct ConjugaApp: App {
|
||||
let cloudConfig = ModelConfiguration(
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self,
|
||||
configurations: cloudConfig
|
||||
|
||||
@@ -55,3 +55,30 @@ final class CourseReviewCard {
|
||||
self.back = back
|
||||
}
|
||||
}
|
||||
|
||||
/// SRS record for verb-level vocab practice (EN ↔ ES infinitive recall),
|
||||
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
|
||||
/// collide with conjugation form mastery.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||
@Model
|
||||
final class VerbReviewCard {
|
||||
var id: String = ""
|
||||
var verbId: Int = 0
|
||||
|
||||
var easeFactor: Double = 2.5
|
||||
var interval: Int = 0
|
||||
var repetitions: Int = 0
|
||||
var dueDate: Date = Date()
|
||||
var lastReviewDate: Date?
|
||||
|
||||
init(verbId: Int) {
|
||||
self.id = Self.makeId(verbId: verbId)
|
||||
self.verbId = verbId
|
||||
}
|
||||
|
||||
static func makeId(verbId: Int) -> String {
|
||||
"verb-\(verbId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
|
||||
/// keyed by `verbId` (the integer primary key on `Verb`).
|
||||
struct VerbReviewStore {
|
||||
let context: ModelContext
|
||||
|
||||
@discardableResult
|
||||
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
|
||||
let id = VerbReviewCard.makeId(verbId: verbId)
|
||||
let descriptor = FetchDescriptor<VerbReviewCard>(
|
||||
predicate: #Predicate<VerbReviewCard> { $0.id == id }
|
||||
)
|
||||
if let existing = (try? context.fetch(descriptor))?.first {
|
||||
return existing
|
||||
}
|
||||
let card = VerbReviewCard(verbId: verbId)
|
||||
context.insert(card)
|
||||
return card
|
||||
}
|
||||
|
||||
func rate(verbId: Int, quality: ReviewQuality) {
|
||||
let card = fetchOrCreateReviewCard(verbId: verbId)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -383,13 +383,24 @@ struct PracticeView: View {
|
||||
|
||||
private var vocabSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// NEW: Vocab Practice entry (flashcard + MC)
|
||||
// Vocab Flashcards (verb pool, filtered by Settings levels)
|
||||
NavigationLink {
|
||||
VocabPracticeEntryView()
|
||||
VocabFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Practice",
|
||||
subtitle: "Flashcards or multiple choice, pick a deck")
|
||||
title: "Vocab Flashcards",
|
||||
subtitle: "Verb meaning → infinitive recall")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Vocab Multiple Choice (same verb pool)
|
||||
NavigationLink {
|
||||
VocabMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
subtitle: "Pick the Spanish infinitive from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
@@ -2,44 +2,38 @@ import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first flashcard. Front shows the English meaning; tap to reveal
|
||||
/// the Spanish word with example sentences, an AI-generated illustrative
|
||||
/// image, and SRS rating buttons.
|
||||
/// English-first verb flashcard. Pool = all verbs whose `level` is enabled
|
||||
/// in Settings (UserProgress.selectedVerbLevels) — the same filter that
|
||||
/// drives the conjugation practice modes.
|
||||
///
|
||||
/// Front: verb.english (e.g. "to run"). Tap to reveal verb.infinitive,
|
||||
/// an AI-generated illustration, a lazy-generated example sentence,
|
||||
/// and SRS rating buttons.
|
||||
struct VocabFlashcardPracticeView: View {
|
||||
/// nil = all decks
|
||||
let deckId: String?
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VocabImageService.self) private var imageService
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var deckLookup: [String: CourseDeck] = [:]
|
||||
@State private var verbs: [Verb] = []
|
||||
@State private var index: Int = 0
|
||||
@State private var revealed: Bool = false
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingExampleForVerbId: Int? = nil
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentCard: VocabCard? {
|
||||
guard index < cards.count else { return nil }
|
||||
return cards[index]
|
||||
}
|
||||
|
||||
private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? {
|
||||
guard let card = currentCard else { return nil }
|
||||
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
|
||||
let english = isReversed ? card.front : card.back
|
||||
let spanish = isReversed ? card.back : card.front
|
||||
return (english, spanish, card.examplesES, card.examplesEN)
|
||||
private var currentVerb: Verb? {
|
||||
guard index < verbs.count else { return nil }
|
||||
return verbs[index]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
progressBar
|
||||
if let sides {
|
||||
cardBody(sides)
|
||||
if let verb = currentVerb {
|
||||
cardBody(verb)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
@@ -58,26 +52,29 @@ struct VocabFlashcardPracticeView: View {
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count))
|
||||
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
|
||||
.tint(.purple)
|
||||
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)")
|
||||
Text(verbs.isEmpty ? noPoolMessage : "\(min(index + 1, verbs.count)) of \(verbs.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var noPoolMessage: String {
|
||||
"No verbs match the levels enabled in Settings"
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
@ViewBuilder
|
||||
private func cardBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
|
||||
// Front (always visible)
|
||||
Text(sides.english)
|
||||
private func cardBody(_ verb: Verb) -> some View {
|
||||
Text("to \(verb.english)")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
revealedContent(sides)
|
||||
revealedContent(verb)
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
@@ -101,31 +98,40 @@ struct VocabFlashcardPracticeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
|
||||
private func revealedContent(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
Text(sides.spanish)
|
||||
Text(verb.infinitive)
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let card = currentCard {
|
||||
VocabIllustration(card: card, deckLookup: deckLookup)
|
||||
VerbIllustration(verb: verb)
|
||||
|
||||
exampleBlock(for: verb)
|
||||
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
if !sides.examplesES.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pair.0).font(.subheadline).italic()
|
||||
Text(pair.1).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingExampleForVerbId == verb.id {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ratingButtons
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +171,7 @@ struct VocabFlashcardPracticeView: View {
|
||||
.foregroundStyle(.green)
|
||||
Text("Session Complete")
|
||||
.font(.title2.bold())
|
||||
Text("\(cards.count) cards reviewed")
|
||||
Text("\(verbs.count) verbs reviewed")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Done") { dismiss() }
|
||||
@@ -179,59 +185,99 @@ struct VocabFlashcardPracticeView: View {
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard cards.isEmpty else { return }
|
||||
let pool = fetchPool()
|
||||
cards = pool.shuffled()
|
||||
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
|
||||
guard verbs.isEmpty else { return }
|
||||
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func fetchPool() -> [VocabCard] {
|
||||
var descriptor = FetchDescriptor<VocabCard>()
|
||||
if let deckId {
|
||||
descriptor.predicate = #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
}
|
||||
return (try? localContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
if exampleByVerbId[verb.id] != nil { return }
|
||||
|
||||
private func fetchDecks() -> [CourseDeck] {
|
||||
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
|
||||
// Cache hit?
|
||||
if let cached = exampleCache.examples(for: verb.id)?.first {
|
||||
exampleByVerbId[verb.id] = cached
|
||||
return
|
||||
}
|
||||
// Otherwise lazy-generate (no blocking on tap-to-reveal).
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingExampleForVerbId = verb.id
|
||||
let verbId = verb.id
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: ["ind_presente"]
|
||||
)
|
||||
if let first = examples.first {
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
if currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = first
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silent — the example block just stays hidden.
|
||||
}
|
||||
if generatingExampleForVerbId == verbId {
|
||||
generatingExampleForVerbId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func rateAndAdvance(_ quality: ReviewQuality) {
|
||||
guard let card = currentCard else { return }
|
||||
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality)
|
||||
guard let verb = currentVerb else { return }
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
|
||||
withAnimation(.smooth) {
|
||||
revealed = false
|
||||
index += 1
|
||||
}
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Illustration (shared)
|
||||
// MARK: - Pool helper
|
||||
|
||||
/// AI-generated image for the card's English concept. Generates on first
|
||||
/// Shared verb-pool fetch used by both vocab flashcard and vocab MC.
|
||||
/// Reads `UserProgress.selectedVerbLevels` from the cloud context and
|
||||
/// filters the local Verb table by those levels — the exact same path
|
||||
/// `PracticeSessionService` already uses.
|
||||
enum VocabVerbPool {
|
||||
static func fetch(localContext: ModelContext, cloudContext: ModelContext) -> [Verb] {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let levels = progress.selectedVerbLevels.map(\.rawValue)
|
||||
let levelSet = Set(levels)
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let pool: [Verb]
|
||||
if levelSet.isEmpty {
|
||||
pool = store.fetchVerbs()
|
||||
} else {
|
||||
pool = store.fetchVerbs(selectedLevels: levelSet)
|
||||
}
|
||||
return pool.shuffled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verb illustration
|
||||
|
||||
/// AI-generated image for the verb's English concept. Generates on first
|
||||
/// reveal and caches to disk. Falls back to a styled SF Symbol when Image
|
||||
/// Playground is unavailable.
|
||||
struct VocabIllustration: View {
|
||||
let card: VocabCard
|
||||
let deckLookup: [String: CourseDeck]
|
||||
struct VerbIllustration: View {
|
||||
let verb: Verb
|
||||
|
||||
@Environment(VocabImageService.self) private var service
|
||||
@State private var image: UIImage?
|
||||
@State private var isGenerating: Bool = false
|
||||
|
||||
private var englishConcept: String {
|
||||
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
|
||||
return isReversed ? card.front : card.back
|
||||
}
|
||||
|
||||
private var spanish: String {
|
||||
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
|
||||
return isReversed ? card.back : card.front
|
||||
}
|
||||
|
||||
private var cacheKey: String {
|
||||
VocabImageService.cacheKey(deckId: card.deckId, spanish: spanish, english: englishConcept)
|
||||
VocabImageService.cacheKey(
|
||||
deckId: "verb",
|
||||
spanish: verb.infinitive,
|
||||
english: verb.english
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -284,7 +330,7 @@ struct VocabIllustration: View {
|
||||
}
|
||||
guard VocabImageService.isAvailable else { return }
|
||||
isGenerating = true
|
||||
let result = await service.image(forKey: cacheKey, concept: englishConcept)
|
||||
let result = await service.image(forKey: cacheKey, concept: "to \(verb.english)")
|
||||
isGenerating = false
|
||||
if let result {
|
||||
image = result
|
||||
|
||||
@@ -2,46 +2,37 @@ import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first multiple choice. Prompt shows the English meaning; the user
|
||||
/// picks the correct Spanish word from 4 options (3 distractors drawn from the
|
||||
/// same deck, preferring matching part-of-speech via DictionaryService).
|
||||
/// After answer: reveal correct/incorrect, show examples + image, rate SRS.
|
||||
/// English-first verb multiple choice. Pool = verbs whose `level` is enabled
|
||||
/// in Settings (UserProgress.selectedVerbLevels). 4 options shown, 1 correct
|
||||
/// + 3 random distractors from the same pool. After answer: reveal correct/
|
||||
/// incorrect, the verb infinitive, an AI illustration, an example sentence,
|
||||
/// and SRS rating buttons.
|
||||
struct VocabMultipleChoicePracticeView: View {
|
||||
let deckId: String?
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var distractorPool: [VocabCard] = []
|
||||
@State private var deckLookup: [String: CourseDeck] = [:]
|
||||
@State private var verbs: [Verb] = []
|
||||
@State private var index: Int = 0
|
||||
@State private var options: [VocabCard] = []
|
||||
@State private var selectedOption: VocabCard? = nil
|
||||
@State private var options: [Verb] = []
|
||||
@State private var selectedOption: Verb? = nil
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingExampleForVerbId: Int? = nil
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentCard: VocabCard? {
|
||||
guard index < cards.count else { return nil }
|
||||
return cards[index]
|
||||
}
|
||||
|
||||
private var sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])? {
|
||||
guard let card = currentCard else { return nil }
|
||||
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
|
||||
let english = isReversed ? card.front : card.back
|
||||
let spanish = isReversed ? card.back : card.front
|
||||
return (english, spanish, card.examplesES, card.examplesEN)
|
||||
private var currentVerb: Verb? {
|
||||
guard index < verbs.count else { return nil }
|
||||
return verbs[index]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let sides {
|
||||
questionBody(sides)
|
||||
if let verb = currentVerb {
|
||||
questionBody(verb)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
@@ -60,9 +51,9 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count))
|
||||
ProgressView(value: verbs.isEmpty ? 0 : Double(index) / Double(verbs.count))
|
||||
.tint(.purple)
|
||||
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)")
|
||||
Text(verbs.isEmpty ? "No verbs match the levels enabled in Settings" : "\(min(index + 1, verbs.count)) of \(verbs.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -71,8 +62,8 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
// MARK: - Question
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
|
||||
Text(sides.english)
|
||||
private func questionBody(_ verb: Verb) -> some View {
|
||||
Text("to \(verb.english)")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
@@ -80,7 +71,7 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(sides)
|
||||
revealedContent(verb)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +81,7 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(spanishSide(of: option))
|
||||
Text(option.infinitive)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
@@ -101,34 +92,17 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
|
||||
private func revealedContent(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(sides)
|
||||
|
||||
if let card = currentCard {
|
||||
VocabIllustration(card: card, deckLookup: deckLookup)
|
||||
}
|
||||
|
||||
if !sides.examplesES.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(zip(sides.examplesES, sides.examplesEN).enumerated()), id: \.offset) { _, pair in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pair.0).font(.subheadline).italic()
|
||||
Text(pair.1).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
answerFeedback(verb)
|
||||
VerbIllustration(verb: verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
|
||||
let correct = (selectedOption?.id == currentCard?.id)
|
||||
private func answerFeedback(_ verb: Verb) -> some View {
|
||||
let correct = (selectedOption?.id == verb.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
@@ -136,12 +110,35 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(sides.spanish)
|
||||
Text(verb.infinitive)
|
||||
.font(.title2.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingExampleForVerbId == verb.id {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
@@ -178,7 +175,7 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
.foregroundStyle(.green)
|
||||
Text("Session Complete")
|
||||
.font(.title2.bold())
|
||||
Text("\(cards.count) cards reviewed")
|
||||
Text("\(verbs.count) verbs reviewed")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Done") { dismiss() }
|
||||
@@ -192,71 +189,57 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard cards.isEmpty else { return }
|
||||
let pool = fetchPool()
|
||||
cards = pool.shuffled()
|
||||
distractorPool = pool
|
||||
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
|
||||
if cards.count < 4 {
|
||||
distractorPool = fetchAllCards()
|
||||
}
|
||||
guard verbs.isEmpty else { return }
|
||||
verbs = VocabVerbPool.fetch(localContext: localContext, cloudContext: cloudContext)
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func fetchPool() -> [VocabCard] {
|
||||
var descriptor = FetchDescriptor<VocabCard>()
|
||||
if let deckId {
|
||||
descriptor.predicate = #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
}
|
||||
return (try? localContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func fetchAllCards() -> [VocabCard] {
|
||||
(try? localContext.fetch(FetchDescriptor<VocabCard>())) ?? []
|
||||
}
|
||||
|
||||
private func fetchDecks() -> [CourseDeck] {
|
||||
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let card = currentCard else { options = []; return }
|
||||
let correctPOS = partOfSpeech(for: card)
|
||||
let candidates = distractorPool.filter { $0.id != card.id }
|
||||
|
||||
let posMatches = correctPOS.flatMap { pos in
|
||||
candidates.filter { partOfSpeech(for: $0) == pos }
|
||||
} ?? []
|
||||
let pickedDistractors: [VocabCard]
|
||||
if posMatches.count >= 3 {
|
||||
pickedDistractors = Array(posMatches.shuffled().prefix(3))
|
||||
} else {
|
||||
// Fill with random others
|
||||
var pool = posMatches
|
||||
let remaining = candidates.filter { c in !pool.contains(where: { $0.id == c.id }) }
|
||||
pool.append(contentsOf: remaining.shuffled())
|
||||
pickedDistractors = Array(pool.prefix(3))
|
||||
}
|
||||
options = ([card] + pickedDistractors).shuffled()
|
||||
guard let verb = currentVerb else { options = []; return }
|
||||
let candidates = verbs.filter { $0.id != verb.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([verb] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func partOfSpeech(for card: VocabCard) -> String? {
|
||||
let spanish = spanishSide(of: card).lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
return dictionary.lookup(spanish)?.partOfSpeech
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
if exampleByVerbId[verb.id] != nil { return }
|
||||
if let cached = exampleCache.examples(for: verb.id)?.first {
|
||||
exampleByVerbId[verb.id] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingExampleForVerbId = verb.id
|
||||
let verbId = verb.id
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: ["ind_presente"]
|
||||
)
|
||||
if let first = examples.first {
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
if currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = first
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
if generatingExampleForVerbId == verbId {
|
||||
generatingExampleForVerbId = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func spanishSide(of card: VocabCard) -> String {
|
||||
let isReversed = deckLookup[card.deckId]?.isReversed ?? false
|
||||
return isReversed ? card.back : card.front
|
||||
}
|
||||
|
||||
private func rateAndAdvance(_ quality: ReviewQuality) {
|
||||
guard let card = currentCard else { return }
|
||||
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality)
|
||||
guard let verb = currentVerb else { return }
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verb.id, quality: quality)
|
||||
index += 1
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Entry screen for vocabulary practice. User picks a deck (or "All") and a
|
||||
/// mode (Flashcard / Multiple Choice), then taps Start. The session view
|
||||
/// fetches the pool, shuffles, and runs the cards.
|
||||
struct VocabPracticeEntryView: View {
|
||||
enum Mode: String, CaseIterable, Identifiable {
|
||||
case flashcard
|
||||
case multipleChoice
|
||||
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .flashcard: return "Flashcard"
|
||||
case .multipleChoice: return "Multiple Choice"
|
||||
}
|
||||
}
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .flashcard: return "rectangle.on.rectangle"
|
||||
case .multipleChoice: return "checklist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Query(sort: [SortDescriptor(\CourseDeck.courseName), SortDescriptor(\CourseDeck.weekNumber)])
|
||||
private var decks: [CourseDeck]
|
||||
|
||||
@AppStorage("vocabPracticeLastDeckId") private var lastDeckId: String = ""
|
||||
@AppStorage("vocabPracticeLastMode") private var lastModeRaw: String = Mode.flashcard.rawValue
|
||||
|
||||
@State private var selectedDeckId: String? = nil
|
||||
@State private var mode: Mode = .flashcard
|
||||
@State private var startedSession: SessionConfig? = nil
|
||||
|
||||
/// Sentinel for "All decks" in the picker.
|
||||
private let allDecksTag = "__all__"
|
||||
|
||||
private struct SessionConfig: Hashable {
|
||||
let deckId: String? // nil = all decks
|
||||
let mode: Mode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Mode") {
|
||||
Picker("Mode", selection: $mode) {
|
||||
ForEach(Mode.allCases) { m in
|
||||
Label(m.label, systemImage: m.systemImage).tag(m)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section("Deck") {
|
||||
if decks.isEmpty {
|
||||
Text("No course decks available yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("Deck", selection: Binding(
|
||||
get: { selectedDeckId ?? allDecksTag },
|
||||
set: { selectedDeckId = ($0 == allDecksTag ? nil : $0) }
|
||||
)) {
|
||||
Text("All decks").tag(allDecksTag)
|
||||
ForEach(grouped, id: \.course) { group in
|
||||
Section(group.course) {
|
||||
ForEach(group.decks) { deck in
|
||||
Text(deckLabel(deck)).tag(deck.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.navigationLink)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
persistChoice()
|
||||
startedSession = SessionConfig(deckId: selectedDeckId, mode: mode)
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
.disabled(decks.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Vocab Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(item: $startedSession) { config in
|
||||
switch config.mode {
|
||||
case .flashcard:
|
||||
VocabFlashcardPracticeView(deckId: config.deckId)
|
||||
case .multipleChoice:
|
||||
VocabMultipleChoicePracticeView(deckId: config.deckId)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: restoreChoice)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private struct DeckGroup {
|
||||
let course: String
|
||||
let decks: [CourseDeck]
|
||||
}
|
||||
|
||||
private var grouped: [DeckGroup] {
|
||||
let byCourse = Dictionary(grouping: decks, by: \.courseName)
|
||||
return byCourse.keys.sorted().map { name in
|
||||
DeckGroup(course: name, decks: (byCourse[name] ?? []).sorted {
|
||||
if $0.weekNumber != $1.weekNumber { return $0.weekNumber < $1.weekNumber }
|
||||
return $0.title < $1.title
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func deckLabel(_ deck: CourseDeck) -> String {
|
||||
"W\(deck.weekNumber) — \(deck.title)"
|
||||
}
|
||||
|
||||
private func restoreChoice() {
|
||||
if !lastDeckId.isEmpty, decks.contains(where: { $0.id == lastDeckId }) {
|
||||
selectedDeckId = lastDeckId
|
||||
}
|
||||
if let m = Mode(rawValue: lastModeRaw) {
|
||||
mode = m
|
||||
}
|
||||
}
|
||||
|
||||
private func persistChoice() {
|
||||
lastDeckId = selectedDeckId ?? ""
|
||||
lastModeRaw = mode.rawValue
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user