Add Vocab Practice — English-first flashcards + multiple choice, with AI illustrations

Practice tab restructured into three sections:
- Conjugation: the 6 conjugation modes + the Common Tenses / Weak
  Verbs / Irregularity Drills focus buttons.
- Vocabulary: new "Vocab Practice" entry (flashcard or multiple
  choice, deck-picker on entry) + the existing Vocab Review.
- Reading: Stories, Books, Lyrics, Conversation, Listening, Cloze
  (moved here from the flat list).

VocabPracticeEntryView lets the user pick any course/textbook deck
(or "All decks") and a mode (Flashcard / Multiple Choice). Last-used
choice is remembered via @AppStorage.

VocabFlashcardPracticeView:
  Front shows the English meaning. Tap to reveal the Spanish word,
  example sentences from the card, an AI-generated illustration of
  the concept, and Again/Hard/Good/Easy rating buttons. SRS updates
  via the existing CourseReviewStore.rate() path.

VocabMultipleChoicePracticeView:
  English prompt, 4 Spanish options. Distractors come from the same
  deck and prefer matching part-of-speech (via
  DictionaryService.lookup) — falls back to random when POS is
  unknown or the deck has fewer than 4 cards. After answer: reveal
  correct/incorrect, the Spanish word, examples, illustration, and
  the same rating buttons.

VocabImageService wraps Apple Intelligence's ImageCreator
(iOS 18.2+) for on-device illustration generation. Caches PNG
results to disk under Caches/VocabImages keyed by
SHA256(deck+ES+EN). In-flight dedup keeps concurrent requests for
the same key sharing one task. Falls back to a placeholder UI when
Apple Intelligence isn't available (older devices / disabled in
Settings) — detected lazily on the first failed ImageCreator init.

EN-first direction is enforced regardless of the underlying deck's
isReversed flag, so the user sees the English-to-Spanish recall
direction they asked for even when practising a reversed course
deck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-13 23:02:02 -05:00
parent 9aa4d0836d
commit 0b7d4a73ad
7 changed files with 1013 additions and 167 deletions
+25
View File
@@ -13,10 +13,12 @@
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; }; 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; }; 0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; }; 0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; }; 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; }; 14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; }; 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; }; 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
200E933E672F8B011DC16769 /* VocabImageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */; };
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; }; 20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; }; 218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; }; 261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
@@ -35,7 +37,9 @@
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; }; 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.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 */; }; 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 */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; }; 48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
@@ -155,6 +159,7 @@
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; }; 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
@@ -174,6 +179,7 @@
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabImageService.swift; sourceTree = "<group>"; };
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; }; 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; }; 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; }; 3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
@@ -206,6 +212,7 @@
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; }; 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; }; 6658C35E454C137B53FC05A4 /* youtube_videos.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = youtube_videos.json; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; }; 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; }; 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
@@ -247,6 +254,7 @@
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
@@ -360,10 +368,22 @@
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */, AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */, 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
A661ADF1141176EE96774138 /* BookSpeechController.swift */, A661ADF1141176EE96774138 /* BookSpeechController.swift */,
3148F06ABA1BFCA36CB21E15 /* VocabImageService.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
isa = PBXGroup;
children = (
D641100DC020AD02EE2B6C9C /* VocabPracticeEntryView.swift */,
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
);
name = Vocab;
path = Vocab;
sourceTree = "<group>";
};
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = { 29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -455,6 +475,7 @@
895E547BEFB5D0FBF676BE33 /* Lyrics */, 895E547BEFB5D0FBF676BE33 /* Lyrics */,
43E4D263B0AF47E401A51601 /* Stories */, 43E4D263B0AF47E401A51601 /* Stories */,
74AC8A0D381958D2A14316C3 /* Books */, 74AC8A0D381958D2A14316C3 /* Books */,
1ECAF79E2138DF73BB1F6403 /* Vocab */,
); );
path = Practice; path = Practice;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -778,6 +799,10 @@
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */, 33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */, 2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.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 */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+2
View File
@@ -43,6 +43,7 @@ struct ConjugaApp: App {
@State private var verbExampleCache = VerbExampleCache() @State private var verbExampleCache = VerbExampleCache()
@State private var reflexiveStore = ReflexiveVerbStore() @State private var reflexiveStore = ReflexiveVerbStore()
@State private var youtubeVideoStore = YouTubeVideoStore() @State private var youtubeVideoStore = YouTubeVideoStore()
@State private var vocabImageService = VocabImageService()
let localContainer: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer let cloudContainer: ModelContainer
@@ -119,6 +120,7 @@ struct ConjugaApp: App {
.environment(verbExampleCache) .environment(verbExampleCache)
.environment(reflexiveStore) .environment(reflexiveStore)
.environment(youtubeVideoStore) .environment(youtubeVideoStore)
.environment(vocabImageService)
.task { .task {
let needsSeed = await DataLoader.needsSeeding(container: localContainer) let needsSeed = await DataLoader.needsSeeding(container: localContainer)
if needsSeed { if needsSeed {
@@ -0,0 +1,134 @@
import CryptoKit
import Foundation
import ImagePlayground
import SwiftUI
import UIKit
/// On-device illustrative images for vocab cards, via Apple Intelligence
/// Image Playground (iOS 18.2+). Generated once per (deck, ES, EN) tuple,
/// cached to disk in the app's Caches directory.
///
/// On devices/versions without Image Playground the service still works
/// it just always returns nil and the calling view falls back to a placeholder.
@MainActor
@Observable
final class VocabImageService {
private let cacheRoot: URL = {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let dir = caches.appendingPathComponent("VocabImages", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}()
/// In-flight generations keyed by cache key so callers asking for the same
/// image while it's being produced share the same task.
private var inFlight: [String: Task<UIImage?, Never>] = [:]
/// Look up a cached image synchronously. Returns nil if not yet generated.
func cachedImage(forKey key: String) -> UIImage? {
let url = cacheFileURL(forKey: key)
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return UIImage(contentsOfFile: url.path)
}
/// Generate (or fetch cached) image for the given English concept.
/// Returns nil on unsupported devices or if generation fails.
func image(
forKey key: String,
concept englishConcept: String
) async -> UIImage? {
if let cached = cachedImage(forKey: key) {
return cached
}
if let existing = inFlight[key] {
return await existing.value
}
let task = Task<UIImage?, Never> { [cacheRoot] in
await Self.generate(concept: englishConcept, savingTo: Self.fileURL(in: cacheRoot, forKey: key))
}
inFlight[key] = task
let result = await task.value
inFlight[key] = nil
return result
}
/// Stable cache key for a vocab card. Hash so it survives weird characters
/// in the front/back text.
static func cacheKey(deckId: String, spanish: String, english: String) -> String {
let raw = "\(deckId)|\(spanish)|\(english)"
let digest = SHA256.hash(data: Data(raw.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Cached result of a one-time probe true if `ImageCreator()` succeeds
/// on this device. Apple Intelligence only lights up on compatible
/// hardware + when the user has it enabled in Settings; `ImageCreator()`
/// throws otherwise.
nonisolated(unsafe) private static var probedAvailability: Bool? = nil
nonisolated(unsafe) private static let probeLock = NSLock()
/// Whether on-device image generation is available. Cached after first call.
static var isAvailable: Bool {
probeLock.lock()
defer { probeLock.unlock() }
if let cached = probedAvailability { return cached }
// Synchronous probe via Task isn't possible here; default to true on
// iOS 18.2+ and let the first actual generation succeed-or-fail
// produce the real signal. Older OS = never available.
guard #available(iOS 18.2, *) else {
probedAvailability = false
return false
}
return true
}
/// Mark the service as unavailable based on a real failure (called from
/// `generate(...)` when ImageCreator() init throws).
private static func markUnavailable() {
probeLock.lock()
probedAvailability = false
probeLock.unlock()
}
// MARK: - Internal
private func cacheFileURL(forKey key: String) -> URL {
Self.fileURL(in: cacheRoot, forKey: key)
}
private static func fileURL(in root: URL, forKey key: String) -> URL {
root.appendingPathComponent("\(key).png")
}
private static func generate(concept: String, savingTo destination: URL) async -> UIImage? {
guard #available(iOS 18.2, *) else { return nil }
let creator: ImageCreator
do {
creator = try await ImageCreator()
} catch {
print("[VocabImageService] ImageCreator unavailable: \(error)")
markUnavailable()
return nil
}
let sequence = creator.images(
for: [.text(concept)],
style: .illustration,
limit: 1
)
do {
for try await result in sequence {
let cgImage = result.cgImage
let uiImage = UIImage(cgImage: cgImage)
if let png = uiImage.pngData() {
try? png.write(to: destination, options: .atomic)
}
return uiImage
}
} catch {
print("[VocabImageService] generation failed: \(error)")
}
return nil
}
}
+135 -146
View File
@@ -74,12 +74,10 @@ struct PracticeView: View {
.padding(.top, 8) .padding(.top, 8)
} }
// Mode selection // === Section: Conjugation ===
VStack(spacing: 12) { sectionHeader("Conjugation")
Text("Choose a Mode")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 12) {
ForEach(PracticeMode.allCases) { mode in ForEach(PracticeMode.allCases) { mode in
ModeButton(mode: mode) { ModeButton(mode: mode) {
viewModel.practiceMode = mode viewModel.practiceMode = mode
@@ -98,6 +96,15 @@ struct PracticeView: View {
} }
.padding(.horizontal) .padding(.horizontal)
conjugationFocusButtons
// === Section: Vocabulary ===
sectionHeader("Vocabulary")
vocabSection
// === Section: Reading ===
sectionHeader("Reading")
// Lyrics // Lyrics
NavigationLink { NavigationLink {
LyricsLibraryView() LyricsLibraryView()
@@ -284,13 +291,110 @@ struct PracticeView: View {
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal) .padding(.horizontal)
// Quick Actions // Session stats summary
VStack(spacing: 12) { if viewModel.sessionTotal > 0 && !isPracticing {
Text("Quick Actions") VStack(spacing: 8) {
Text("Last Session")
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Vocab review HStack(spacing: 20) {
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
StatItem(
label: "Accuracy",
value: "\(Int(viewModel.sessionAccuracy * 100))%"
)
}
.padding()
.frame(maxWidth: .infinity)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
}
.padding(.horizontal)
}
}
.padding(.vertical)
.adaptiveContainer()
}
}
// MARK: - Section header
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
private var conjugationFocusButtons: some View {
VStack(spacing: 12) {
// Common Tenses
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "star.fill", color: .orange,
title: "Common Tenses",
subtitle: "Practice the 6 most essential tenses")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak Verbs
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true }
} label: {
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
title: "Weak Verbs",
subtitle: "Focus on verbs you struggle with")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
} label: {
practiceRowLabel(icon: "wand.and.stars", color: .purple,
title: "Irregularity Drills",
subtitle: "Practice by irregularity type")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
}
// MARK: - Vocabulary section
private var vocabSection: some View {
VStack(spacing: 12) {
// NEW: Vocab Practice entry (flashcard + MC)
NavigationLink {
VocabPracticeEntryView()
} label: {
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
title: "Vocab Practice",
subtitle: "Flashcards or multiple choice, pick a deck")
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Existing: Vocab Review (due cards)
NavigationLink { NavigationLink {
VocabReviewView() VocabReviewView()
} label: { } label: {
@@ -329,148 +433,33 @@ struct PracticeView: View {
} }
.tint(.primary) .tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14)) .glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Common tenses focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .commonTenses
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.orange)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Common Tenses")
.font(.subheadline.weight(.semibold))
Text("Practice the 6 most essential tenses")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Weak verbs focus
Button {
viewModel.practiceMode = .flashcard
viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true }
} label: {
HStack(spacing: 14) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundStyle(.red)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Weak Verbs")
.font(.subheadline.weight(.semibold))
Text("Focus on verbs you struggle with")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
// Irregularity drills
Menu {
Button("Spelling Changes (c→qu, z→c, ...)") {
startIrregularityDrill(.spelling)
}
Button("Stem Changes (o→ue, e→ie, ...)") {
startIrregularityDrill(.stemChange)
}
Button("Unique Irregulars (ser, ir, ...)") {
startIrregularityDrill(.uniqueIrregular)
}
} label: {
HStack(spacing: 14) {
Image(systemName: "wand.and.stars")
.font(.title3)
.foregroundStyle(.purple)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Irregularity Drills")
.font(.subheadline.weight(.semibold))
Text("Practice by irregularity type")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal)
// Session stats summary
if viewModel.sessionTotal > 0 && !isPracticing {
VStack(spacing: 8) {
Text("Last Session")
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 20) {
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
StatItem(
label: "Accuracy",
value: "\(Int(viewModel.sessionAccuracy * 100))%"
)
}
.padding()
.frame(maxWidth: .infinity)
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
} }
.padding(.horizontal) .padding(.horizontal)
} }
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
} }
.padding(.vertical)
.adaptiveContainer() Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
} }
.padding(.horizontal, 16)
.padding(.vertical, 12)
} }
// MARK: - Practice Session View // MARK: - Practice Session View
@@ -0,0 +1,293 @@
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.
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(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var index: Int = 0
@State private var revealed: Bool = false
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)
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
progressBar
if let sides {
cardBody(sides)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Vocab Flashcards")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: revealed)
.animation(.smooth, value: index)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count))
.tint(.purple)
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Card
@ViewBuilder
private func cardBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
// Front (always visible)
Text(sides.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if revealed {
revealedContent(sides)
} else {
tapToReveal
}
}
private var tapToReveal: some View {
VStack(spacing: 8) {
Image(systemName: "hand.tap")
.font(.title)
.foregroundStyle(.secondary)
Text("Tap to reveal")
.font(.headline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.frame(minHeight: 200)
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.smooth) { revealed = true }
}
}
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
VStack(spacing: 18) {
Text(sides.spanish)
.font(.title.weight(.semibold))
.multilineTextAlignment(.center)
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))
}
ratingButtons
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rateAndAdvance(quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Session Complete")
.font(.title2.bold())
Text("\(cards.count) cards reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
}
.padding(.top, 60)
}
// MARK: - Logic
private func loadIfNeeded() {
guard cards.isEmpty else { return }
let pool = fetchPool()
cards = pool.shuffled()
deckLookup = Dictionary(uniqueKeysWithValues: fetchDecks().map { ($0.id, $0) })
}
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 fetchDecks() -> [CourseDeck] {
(try? localContext.fetch(FetchDescriptor<CourseDeck>())) ?? []
}
private func rateAndAdvance(_ quality: ReviewQuality) {
guard let card = currentCard else { return }
CourseReviewStore(context: cloudContext).rate(card: card, quality: quality)
withAnimation(.smooth) {
revealed = false
index += 1
}
}
}
// MARK: - Illustration (shared)
/// AI-generated image for the card'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]
@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)
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.purple.opacity(0.08))
.frame(height: 200)
if let image {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 16))
} else if isGenerating {
VStack(spacing: 6) {
ProgressView()
Text("Generating illustration…")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else if !VocabImageService.isAvailable {
placeholder
} else {
ProgressView()
}
}
.frame(maxWidth: .infinity)
.task(id: cacheKey, loadImage)
}
private var placeholder: some View {
VStack(spacing: 6) {
Image(systemName: "photo")
.font(.title2)
.foregroundStyle(.purple.opacity(0.55))
Text("Image generation unavailable on this device")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
}
@Sendable
private func loadImage() async {
if let cached = service.cachedImage(forKey: cacheKey) {
image = cached
return
}
guard VocabImageService.isAvailable else { return }
isGenerating = true
let result = await service.image(forKey: cacheKey, concept: englishConcept)
isGenerating = false
if let result {
image = result
}
}
}
@@ -0,0 +1,262 @@
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.
struct VocabMultipleChoicePracticeView: View {
let deckId: String?
@Environment(\.modelContext) private var localContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(DictionaryService.self) private var dictionary
@Environment(\.dismiss) private var dismiss
@State private var cards: [VocabCard] = []
@State private var distractorPool: [VocabCard] = []
@State private var deckLookup: [String: CourseDeck] = [:]
@State private var index: Int = 0
@State private var options: [VocabCard] = []
@State private var selectedOption: VocabCard? = 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)
}
var body: some View {
ScrollView {
VStack(spacing: 22) {
progressBar
if let sides {
questionBody(sides)
} else {
completionView
}
}
.padding()
.adaptiveContainer(maxWidth: 720)
}
.navigationTitle("Vocab Multiple Choice")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadIfNeeded)
.animation(.smooth, value: selectedOption?.id)
.animation(.smooth, value: index)
}
// MARK: - Progress
private var progressBar: some View {
VStack(spacing: 6) {
ProgressView(value: cards.isEmpty ? 0 : Double(index) / Double(cards.count))
.tint(.purple)
Text(cards.isEmpty ? "No cards in this deck" : "\(min(index + 1, cards.count)) of \(cards.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
// MARK: - Question
@ViewBuilder
private func questionBody(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
Text(sides.english)
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.top, 12)
if selectedOption == nil {
optionGrid
} else {
revealedContent(sides)
}
}
private var optionGrid: some View {
VStack(spacing: 10) {
ForEach(options, id: \.id) { option in
Button {
selectedOption = option
} label: {
Text(spanishSide(of: option))
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.tint(.primary)
}
}
}
private func revealedContent(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> 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))
}
ratingButtons
}
}
private func answerFeedback(_ sides: (english: String, spanish: String, examplesES: [String], examplesEN: [String])) -> some View {
let correct = (selectedOption?.id == currentCard?.id)
return VStack(spacing: 6) {
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
.font(.system(size: 36))
.foregroundStyle(correct ? .green : .red)
Text(correct ? "Correct!" : "Not quite")
.font(.headline)
.foregroundStyle(correct ? .green : .red)
Text(sides.spanish)
.font(.title2.weight(.semibold))
.padding(.top, 4)
}
}
private var ratingButtons: some View {
VStack(spacing: 10) {
Text("How well did you know it?")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 10) {
ratingButton("Again", color: .red, quality: .again)
ratingButton("Hard", color: .orange, quality: .hard)
ratingButton("Good", color: .green, quality: .good)
ratingButton("Easy", color: .blue, quality: .easy)
}
}
}
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
Button {
rateAndAdvance(quality)
} label: {
Text(label)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.tint(color)
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
}
// MARK: - Completion
private var completionView: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.green)
Text("Session Complete")
.font(.title2.bold())
Text("\(cards.count) cards reviewed")
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(.purple)
.padding(.top, 12)
}
.padding(.top, 60)
}
// 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()
}
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>())) ?? []
}
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()
}
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 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)
index += 1
selectedOption = nil
prepareOptions()
}
}
@@ -0,0 +1,141 @@
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
}
}