Noun & adjective practice — Multiple Choice, Review Learned, Review
Mirror the four-entry Vocabulary section for nouns and adjectives, so each POS gets the same set of practice modes the verb flow already had: - Noun/Adjective Flashcards (existing) — English → Spanish reveal with article for nouns. Now accepts `kind:` to share the view with the Review-Learned cram pass. - Noun/Adjective Multiple Choice — English prompt, 4 Spanish options drawn from the current session pool (1 correct + 3 random distractors). Same SRS rating writes as Flashcards. - Review Learned — `NounFlashcardPracticeView(kind: .reviewLearned)` and the adjective equivalent. Cycles through already-studied lexemes with no schedule changes; mirrors `VocabFlashcardPracticeView`'s reviewLearned kind. - Noun/Adjective Review — fetches due `LexemeReviewCard` rows by POS, Spanish-front / English-reveal flashcards rated directly against the SRS schedule. Each exposes a static `dueCount(context:)` used by the practice-row badge. Wiring: - New `LexemeSessionKind` enum (standard / reviewLearned) in LexemeSessionQueue.swift, mirroring `VocabSessionKind`. - Noun + Adjective Flashcard views branch load/persist/answer on `kind` so Review Learned doesn't touch the persisted study group or reschedule cross-session SRS. - Practice screen gets dedicated "Nouns" and "Adjectives" sections (between Vocabulary and Reading), each with 4 NavigationLinks shaped exactly like the Vocabulary section. The previous single-link Noun and Adjective entries in the Reading section are removed. - PracticeView caches `nounDueCount` / `adjectiveDueCount` in @State and refreshes on appear + after sessions end, so the badge doesn't trigger LexemeReviewCard fetchCount on every body re-evaluation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
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 */; };
|
||||||
|
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E5143541E221C7E28B939E /* AdjectiveReviewView.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 */; };
|
||||||
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
|
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
|
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.swift */; };
|
||||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
|
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */; };
|
||||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
|
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
|
||||||
|
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */; };
|
||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
||||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
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 */; };
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */; };
|
||||||
|
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.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 */; };
|
||||||
@@ -62,6 +65,7 @@
|
|||||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
|
||||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
||||||
|
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */; };
|
||||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
|
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
|
||||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||||
@@ -164,7 +168,9 @@
|
|||||||
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
|
0313D24F96E6A0039C34341F /* DailyLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyLog.swift; sourceTree = "<group>"; };
|
||||||
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
|
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
|
||||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||||
|
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveReviewView.swift; sourceTree = "<group>"; };
|
||||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
|
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
|
||||||
|
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
@@ -182,6 +188,7 @@
|
|||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
||||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||||
|
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
@@ -276,6 +283,7 @@
|
|||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||||
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
||||||
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
|
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounReviewView.swift; sourceTree = "<group>"; };
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
||||||
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
||||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||||
@@ -480,6 +488,7 @@
|
|||||||
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
|
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
|
||||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||||
@@ -487,6 +496,7 @@
|
|||||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||||
|
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
|
||||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||||
@@ -514,7 +524,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
|
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
|
||||||
|
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */,
|
||||||
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
|
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
|
||||||
|
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */,
|
||||||
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
|
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
|
||||||
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
|
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
|
||||||
);
|
);
|
||||||
@@ -763,6 +775,8 @@
|
|||||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
||||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
||||||
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
|
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
|
||||||
|
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */,
|
||||||
|
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */,
|
||||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||||
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
|
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
|
||||||
@@ -814,6 +828,8 @@
|
|||||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||||
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
|
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
|
||||||
|
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */,
|
||||||
|
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */,
|
||||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
||||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import Foundation
|
|||||||
import SharedModels
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
/// Which pool a `LexemeSessionQueue` draws from. Mirrors `VocabSessionKind`.
|
||||||
|
enum LexemeSessionKind {
|
||||||
|
/// Due-first + new lexemes from enabled CEFR levels, capped — the
|
||||||
|
/// standard SRS session. Ratings update the long-term schedule.
|
||||||
|
case standard
|
||||||
|
/// Lexemes already studied at least once, most-recent first, uncapped
|
||||||
|
/// and unfiltered — a consolidation cram. Ratings drive the in-session
|
||||||
|
/// queue only and do NOT reschedule (long-term SM-2 due dates left
|
||||||
|
/// untouched, parallel to `VocabSessionKind.reviewLearned`).
|
||||||
|
case reviewLearned
|
||||||
|
}
|
||||||
|
|
||||||
/// In-session learning-step queue for `Lexeme`-based vocab practice — the
|
/// In-session learning-step queue for `Lexeme`-based vocab practice — the
|
||||||
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
|
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
|
||||||
/// requeue: Again/Hard requeue close, Good advances state then graduates on
|
/// requeue: Again/Hard requeue close, Good advances state then graduates on
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Due-card review for the adjective flashcard SRS — non-verb analog of
|
||||||
|
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
|
||||||
|
/// `partOfSpeech == "adjective"` whose `dueDate` is in the past, shows the
|
||||||
|
/// Spanish base form on the front, reveals the English, then rates via the
|
||||||
|
/// SRS so the schedule moves forward.
|
||||||
|
struct AdjectiveReviewView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var dueCards: [LexemeReviewCard] = []
|
||||||
|
@State private var lexemesByID: [String: Lexeme] = [:]
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var isRevealed = false
|
||||||
|
@State private var sessionCorrect = 0
|
||||||
|
@State private var sessionTotal = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isFinished || dueCards.isEmpty {
|
||||||
|
finishedView
|
||||||
|
} else if let card = dueCards[safe: currentIndex] {
|
||||||
|
cardView(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Adjective Review")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadDueCards)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardView(_ card: LexemeReviewCard) -> some View {
|
||||||
|
let lexeme = lexemesByID[card.lexemeId]
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||||
|
.tint(.pink)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(lexeme?.baseForm ?? "")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if isRevealed {
|
||||||
|
Text(lexeme?.english ?? "")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ratingButton("Again", color: .red, quality: .again)
|
||||||
|
ratingButton("Hard", color: .orange, quality: .hard)
|
||||||
|
ratingButton("Good", color: .green, quality: .good)
|
||||||
|
ratingButton("Easy", color: .blue, quality: .easy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { isRevealed = true }
|
||||||
|
} label: {
|
||||||
|
Text("Show Answer")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||||
|
|
||||||
|
if dueCards.isEmpty {
|
||||||
|
Text("All caught up!").font(.title2.bold())
|
||||||
|
Text("No adjective cards are due for review.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||||
|
Button {
|
||||||
|
rate(quality: quality)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rate(quality: ReviewQuality) {
|
||||||
|
guard let card = dueCards[safe: currentIndex] else { return }
|
||||||
|
|
||||||
|
ReviewStore.recordActivity(context: cloudContext)
|
||||||
|
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? cloudContext.save()
|
||||||
|
|
||||||
|
sessionTotal += 1
|
||||||
|
if quality != .again { sessionCorrect += 1 }
|
||||||
|
|
||||||
|
isRevealed = false
|
||||||
|
if currentIndex + 1 < dueCards.count {
|
||||||
|
currentIndex += 1
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDueCards() {
|
||||||
|
let now = Date()
|
||||||
|
let pos = "adjective"
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||||
|
},
|
||||||
|
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
|
||||||
|
)
|
||||||
|
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
|
||||||
|
let ids = Set(dueCards.map(\.lexemeId))
|
||||||
|
let lexDesc = FetchDescriptor<Lexeme>(
|
||||||
|
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||||
|
)
|
||||||
|
let all = (try? localContext.fetch(lexDesc)) ?? []
|
||||||
|
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dueCount(context: ModelContext) -> Int {
|
||||||
|
let now = Date()
|
||||||
|
let pos = "adjective"
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return (try? context.fetchCount(descriptor)) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Due-card review for the noun flashcard SRS — the non-verb analog of
|
||||||
|
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
|
||||||
|
/// `partOfSpeech == "noun"` whose `dueDate` is in the past, shows the
|
||||||
|
/// Spanish word with its article on the front, reveals the English, then
|
||||||
|
/// rates via the SRS so the schedule moves forward.
|
||||||
|
struct NounReviewView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var dueCards: [LexemeReviewCard] = []
|
||||||
|
@State private var lexemesByID: [String: Lexeme] = [:]
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
@State private var isRevealed = false
|
||||||
|
@State private var sessionCorrect = 0
|
||||||
|
@State private var sessionTotal = 0
|
||||||
|
@State private var isFinished = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
if isFinished || dueCards.isEmpty {
|
||||||
|
finishedView
|
||||||
|
} else if let card = dueCards[safe: currentIndex] {
|
||||||
|
cardView(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 600)
|
||||||
|
.navigationTitle("Noun Review")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadDueCards)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardView(_ card: LexemeReviewCard) -> some View {
|
||||||
|
let lexeme = lexemesByID[card.lexemeId]
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||||
|
.tint(.teal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(spanishFront(lexeme))
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if isRevealed {
|
||||||
|
Text(lexeme?.english ?? "")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ratingButton("Again", color: .red, quality: .again)
|
||||||
|
ratingButton("Hard", color: .orange, quality: .hard)
|
||||||
|
ratingButton("Good", color: .green, quality: .good)
|
||||||
|
ratingButton("Easy", color: .blue, quality: .easy)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { isRevealed = true }
|
||||||
|
} label: {
|
||||||
|
Text("Show Answer")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finishedView: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||||
|
|
||||||
|
if dueCards.isEmpty {
|
||||||
|
Text("All caught up!").font(.title2.bold())
|
||||||
|
Text("No noun cards are due for review.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||||
|
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||||
|
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||||
|
Button {
|
||||||
|
rate(quality: quality)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rate(quality: ReviewQuality) {
|
||||||
|
guard let card = dueCards[safe: currentIndex] else { return }
|
||||||
|
|
||||||
|
ReviewStore.recordActivity(context: cloudContext)
|
||||||
|
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? cloudContext.save()
|
||||||
|
|
||||||
|
sessionTotal += 1
|
||||||
|
if quality != .again { sessionCorrect += 1 }
|
||||||
|
|
||||||
|
isRevealed = false
|
||||||
|
if currentIndex + 1 < dueCards.count {
|
||||||
|
currentIndex += 1
|
||||||
|
} else {
|
||||||
|
withAnimation { isFinished = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDueCards() {
|
||||||
|
let now = Date()
|
||||||
|
let pos = "noun"
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||||
|
},
|
||||||
|
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
|
||||||
|
)
|
||||||
|
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
|
||||||
|
let ids = Set(dueCards.map(\.lexemeId))
|
||||||
|
let lexDesc = FetchDescriptor<Lexeme>(
|
||||||
|
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||||
|
)
|
||||||
|
let all = (try? localContext.fetch(lexDesc)) ?? []
|
||||||
|
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dueCount(context: ModelContext) -> Int {
|
||||||
|
let now = Date()
|
||||||
|
let pos = "noun"
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return (try? context.fetchCount(descriptor)) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spanishFront(_ lexeme: Lexeme?) -> String {
|
||||||
|
guard let lexeme else { return "" }
|
||||||
|
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
|
||||||
|
let article: String
|
||||||
|
switch g {
|
||||||
|
case "f": article = "la"
|
||||||
|
case "m/f": article = "el/la"
|
||||||
|
default: article = "el"
|
||||||
|
}
|
||||||
|
return "\(article) \(lexeme.baseForm)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ struct PracticeView: View {
|
|||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
@State private var isPracticing = false
|
@State private var isPracticing = false
|
||||||
@State private var userProgress: UserProgress?
|
@State private var userProgress: UserProgress?
|
||||||
|
/// Cached due counts for the noun + adjective Review rows. Refreshed on
|
||||||
|
/// appear, on session end (`isPracticing` change), and after the user
|
||||||
|
/// returns from a Review screen. Avoids running `fetchCount` against the
|
||||||
|
/// cloud context on every `body` re-evaluation.
|
||||||
|
@State private var nounDueCount: Int = 0
|
||||||
|
@State private var adjectiveDueCount: Int = 0
|
||||||
|
|
||||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
@@ -36,10 +42,14 @@ struct PracticeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Practice")
|
.navigationTitle("Practice")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadProgress)
|
.onAppear {
|
||||||
|
loadProgress()
|
||||||
|
refreshLexemeDueCounts()
|
||||||
|
}
|
||||||
.onChange(of: isPracticing) { _, practicing in
|
.onChange(of: isPracticing) { _, practicing in
|
||||||
if !practicing {
|
if !practicing {
|
||||||
loadProgress()
|
loadProgress()
|
||||||
|
refreshLexemeDueCounts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -115,6 +125,14 @@ struct PracticeView: View {
|
|||||||
sectionHeader("Vocabulary")
|
sectionHeader("Vocabulary")
|
||||||
vocabSection
|
vocabSection
|
||||||
|
|
||||||
|
// === Section: Nouns ===
|
||||||
|
sectionHeader("Nouns")
|
||||||
|
nounsSection
|
||||||
|
|
||||||
|
// === Section: Adjectives ===
|
||||||
|
sectionHeader("Adjectives")
|
||||||
|
adjectivesSection
|
||||||
|
|
||||||
// === Section: Reading ===
|
// === Section: Reading ===
|
||||||
sectionHeader("Reading")
|
sectionHeader("Reading")
|
||||||
|
|
||||||
@@ -302,68 +320,6 @@ struct PracticeView: View {
|
|||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Nouns
|
|
||||||
NavigationLink {
|
|
||||||
NounFlashcardPracticeView()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Image(systemName: "n.circle.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.frame(width: 36)
|
|
||||||
.foregroundStyle(.teal)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Nouns")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Text("Flashcards — English ↔ Spanish")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.tint(.primary)
|
|
||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Adjectives
|
|
||||||
NavigationLink {
|
|
||||||
AdjectiveFlashcardPracticeView()
|
|
||||||
} label: {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Image(systemName: "a.circle.fill")
|
|
||||||
.font(.title3)
|
|
||||||
.frame(width: 36)
|
|
||||||
.foregroundStyle(.pink)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Adjectives")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
Text("Flashcards — English ↔ Spanish")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.tint(.primary)
|
|
||||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// Session stats summary
|
// Session stats summary
|
||||||
if viewModel.sessionTotal > 0 && !isPracticing {
|
if viewModel.sessionTotal > 0 && !isPracticing {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@@ -532,6 +488,156 @@ struct PracticeView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Nouns section
|
||||||
|
|
||||||
|
private var nounsSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
NavigationLink {
|
||||||
|
NounFlashcardPracticeView()
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .teal,
|
||||||
|
title: "Noun Flashcards",
|
||||||
|
subtitle: "English → Spanish noun (with article)")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
NounMultipleChoicePracticeView()
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "checklist", color: .teal,
|
||||||
|
title: "Noun Multiple Choice",
|
||||||
|
subtitle: "Pick the Spanish noun from 4 options")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
NounFlashcardPracticeView(kind: .reviewLearned)
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
|
||||||
|
title: "Review Learned",
|
||||||
|
subtitle: "Re-review nouns you've studied — schedule unchanged")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
NounReviewView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "rectangle.stack.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Noun Review")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Review due noun cards")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if nounDueCount > 0 {
|
||||||
|
Text("\(nounDueCount)")
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.teal, in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Adjectives section
|
||||||
|
|
||||||
|
private var adjectivesSection: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
NavigationLink {
|
||||||
|
AdjectiveFlashcardPracticeView()
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .pink,
|
||||||
|
title: "Adjective Flashcards",
|
||||||
|
subtitle: "English → Spanish adjective base form")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
AdjectiveMultipleChoicePracticeView()
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "checklist", color: .pink,
|
||||||
|
title: "Adjective Multiple Choice",
|
||||||
|
subtitle: "Pick the Spanish adjective from 4 options")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
AdjectiveFlashcardPracticeView(kind: .reviewLearned)
|
||||||
|
} label: {
|
||||||
|
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
|
||||||
|
title: "Review Learned",
|
||||||
|
subtitle: "Re-review adjectives you've studied — schedule unchanged")
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
AdjectiveReviewView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "rectangle.stack.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Adjective Review")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("Review due adjective cards")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if adjectiveDueCount > 0 {
|
||||||
|
Text("\(adjectiveDueCount)")
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.pink, in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.tint(.primary)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
|
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
@@ -675,6 +781,11 @@ extension PracticeView {
|
|||||||
withAnimation { isPracticing = true }
|
withAnimation { isPracticing = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshLexemeDueCounts() {
|
||||||
|
nounDueCount = NounReviewView.dueCount(context: cloudModelContext)
|
||||||
|
adjectiveDueCount = AdjectiveReviewView.dueCount(context: cloudModelContext)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadProgress() {
|
private func loadProgress() {
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
userProgress = progress
|
userProgress = progress
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import SwiftData
|
|||||||
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader`.
|
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader`.
|
||||||
struct AdjectiveFlashcardPracticeView: View {
|
struct AdjectiveFlashcardPracticeView: View {
|
||||||
|
|
||||||
|
var kind: LexemeSessionKind = .standard
|
||||||
|
|
||||||
@Environment(\.modelContext) private var localContext
|
@Environment(\.modelContext) private var localContext
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -36,7 +38,7 @@ struct AdjectiveFlashcardPracticeView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.adaptiveContainer(maxWidth: 720)
|
.adaptiveContainer(maxWidth: 720)
|
||||||
}
|
}
|
||||||
.navigationTitle("Adjectives")
|
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjectives")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadIfNeeded)
|
.onAppear(perform: loadIfNeeded)
|
||||||
.animation(.smooth, value: revealed)
|
.animation(.smooth, value: revealed)
|
||||||
@@ -142,7 +144,7 @@ struct AdjectiveFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||||
let graduation = session?.answer(rating)
|
let graduation = session?.answer(rating)
|
||||||
if let graduation {
|
if let graduation, kind == .standard {
|
||||||
LexemeReviewStore(context: cloudContext).rate(
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
lexemeId: lexeme.id,
|
lexemeId: lexeme.id,
|
||||||
partOfSpeech: "adjective",
|
partOfSpeech: "adjective",
|
||||||
@@ -209,39 +211,52 @@ struct AdjectiveFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private func loadIfNeeded() {
|
private func loadIfNeeded() {
|
||||||
guard session == nil else { return }
|
guard session == nil else { return }
|
||||||
let store = LexemeStudyGroupStore(
|
switch kind {
|
||||||
context: cloudContext,
|
case .reviewLearned:
|
||||||
partOfSpeech: "adjective",
|
let lexemes = LexemePool.reviewLearnedLexemes(
|
||||||
drillMode: Self.drillMode
|
partOfSpeech: "adjective",
|
||||||
)
|
drillMode: Self.drillMode,
|
||||||
if let group = store.activeGroup() {
|
localContext: localContext,
|
||||||
let stored = group.entries
|
cloudContext: cloudContext
|
||||||
if !stored.isEmpty {
|
)
|
||||||
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
return
|
||||||
guard let lex = byId[e.lexemeId] else { return nil }
|
|
||||||
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
case .standard:
|
||||||
}
|
let store = LexemeStudyGroupStore(
|
||||||
if entries.count == stored.count {
|
context: cloudContext,
|
||||||
session = LexemeSessionQueue(
|
partOfSpeech: "adjective",
|
||||||
entries: entries,
|
drillMode: Self.drillMode
|
||||||
drillMode: Self.drillMode,
|
)
|
||||||
learnedCount: group.learnedCount
|
if let group = store.activeGroup() {
|
||||||
)
|
let stored = group.entries
|
||||||
return
|
if !stored.isEmpty {
|
||||||
|
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
||||||
|
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
||||||
|
guard let lex = byId[e.lexemeId] else { return nil }
|
||||||
|
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||||
|
}
|
||||||
|
if entries.count == stored.count {
|
||||||
|
session = LexemeSessionQueue(
|
||||||
|
entries: entries,
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
learnedCount: group.learnedCount
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
persistGroup()
|
||||||
}
|
}
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
|
||||||
let lexemes = LexemePool.sessionLexemes(
|
|
||||||
partOfSpeech: "adjective",
|
|
||||||
drillMode: Self.drillMode,
|
|
||||||
enabledLevels: progress.selectedLexemeLevels,
|
|
||||||
localContext: localContext,
|
|
||||||
cloudContext: cloudContext
|
|
||||||
)
|
|
||||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
|
||||||
persistGroup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||||
@@ -253,7 +268,7 @@ struct AdjectiveFlashcardPracticeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func persistGroup() {
|
private func persistGroup() {
|
||||||
guard let session else { return }
|
guard kind == .standard, let session else { return }
|
||||||
let store = LexemeStudyGroupStore(
|
let store = LexemeStudyGroupStore(
|
||||||
context: cloudContext,
|
context: cloudContext,
|
||||||
partOfSpeech: "adjective",
|
partOfSpeech: "adjective",
|
||||||
@@ -270,21 +285,26 @@ struct AdjectiveFlashcardPracticeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func studyAgain() {
|
private func studyAgain() {
|
||||||
LexemeStudyGroupStore(
|
switch kind {
|
||||||
context: cloudContext,
|
case .reviewLearned:
|
||||||
partOfSpeech: "adjective",
|
session?.restart()
|
||||||
drillMode: Self.drillMode
|
case .standard:
|
||||||
).clear()
|
LexemeStudyGroupStore(
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
context: cloudContext,
|
||||||
let lexemes = LexemePool.sessionLexemes(
|
partOfSpeech: "adjective",
|
||||||
partOfSpeech: "adjective",
|
drillMode: Self.drillMode
|
||||||
drillMode: Self.drillMode,
|
).clear()
|
||||||
enabledLevels: progress.selectedLexemeLevels,
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
localContext: localContext,
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
cloudContext: cloudContext
|
partOfSpeech: "adjective",
|
||||||
)
|
drillMode: Self.drillMode,
|
||||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
persistGroup()
|
||||||
|
}
|
||||||
revealed = false
|
revealed = false
|
||||||
persistGroup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// English-first adjective multiple choice — non-verb analog of
|
||||||
|
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
|
||||||
|
/// adjective pool; 4 options (1 correct + 3 random distractors from the
|
||||||
|
/// session). Options are bare base forms — agreement isn't drilled here.
|
||||||
|
struct AdjectiveMultipleChoicePracticeView: View {
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var session: LexemeSessionQueue?
|
||||||
|
@State private var distractorPool: [Lexeme] = []
|
||||||
|
@State private var options: [Lexeme] = []
|
||||||
|
@State private var selectedOption: Lexeme? = nil
|
||||||
|
|
||||||
|
private static let drillMode = "recall"
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 22) {
|
||||||
|
progressBar
|
||||||
|
if let lexeme = currentLexeme {
|
||||||
|
questionBody(lexeme)
|
||||||
|
} else {
|
||||||
|
completionView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 720)
|
||||||
|
}
|
||||||
|
.navigationTitle("Adjective Multiple Choice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadIfNeeded)
|
||||||
|
.animation(.smooth, value: selectedOption?.id)
|
||||||
|
.animation(.smooth, value: currentLexeme?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressBar: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ProgressView(value: session?.progress ?? 0).tint(.pink)
|
||||||
|
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressLabel: String {
|
||||||
|
guard let session else { return "Loading…" }
|
||||||
|
if session.isComplete { return "Done" }
|
||||||
|
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func questionBody(_ lexeme: Lexeme) -> some View {
|
||||||
|
Text(lexeme.english)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if selectedOption == nil {
|
||||||
|
optionGrid
|
||||||
|
} else {
|
||||||
|
revealedContent(lexeme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var optionGrid: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(options, id: \.id) { option in
|
||||||
|
Button {
|
||||||
|
selectedOption = option
|
||||||
|
} label: {
|
||||||
|
Text(option.baseForm)
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealedContent(_ lexeme: Lexeme) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
answerFeedback(lexeme)
|
||||||
|
exampleBlock(for: lexeme)
|
||||||
|
ratingButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answerFeedback(_ lexeme: Lexeme) -> some View {
|
||||||
|
let correct = (selectedOption?.id == lexeme.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(lexeme.baseForm)
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||||
|
if let es = lexeme.exampleES, !es.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(es).font(.subheadline).italic()
|
||||||
|
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||||
|
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, rating: .again)
|
||||||
|
ratingButton("Hard", color: .orange, rating: .hard)
|
||||||
|
ratingButton("Good", color: .green, rating: .good)
|
||||||
|
ratingButton("Easy", color: .blue, rating: .easy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
|
||||||
|
Button {
|
||||||
|
answer(rating)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.tint(color)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 56))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(completionDetail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { studyAgain() } label: {
|
||||||
|
Label("Study Again", systemImage: "arrow.clockwise")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.pink)
|
||||||
|
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
.padding(.top, 60)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionDetail: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
if learned > 0 { return "\(learned) adjective\(learned == 1 ? "" : "s") learned" }
|
||||||
|
return "No adjectives are due right now. Study Again to review anyway."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIfNeeded() {
|
||||||
|
guard session == nil else { return }
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
distractorPool = lexemes
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func studyAgain() {
|
||||||
|
session?.restart()
|
||||||
|
selectedOption = nil
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareOptions() {
|
||||||
|
guard let lexeme = currentLexeme else { options = []; return }
|
||||||
|
let candidates = distractorPool.filter { $0.id != lexeme.id }
|
||||||
|
let distractors = Array(candidates.shuffled().prefix(3))
|
||||||
|
options = ([lexeme] + distractors).shuffled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answer(_ rating: LexemeSessionQueue.Rating) {
|
||||||
|
guard let lexeme = currentLexeme else { return }
|
||||||
|
let graduation = session?.answer(rating)
|
||||||
|
if let graduation {
|
||||||
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
|
lexemeId: lexeme.id,
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
quality: graduation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedOption = nil
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import SwiftData
|
|||||||
/// it out of the books-reader layout-loop class of bug).
|
/// it out of the books-reader layout-loop class of bug).
|
||||||
struct NounFlashcardPracticeView: View {
|
struct NounFlashcardPracticeView: View {
|
||||||
|
|
||||||
|
var kind: LexemeSessionKind = .standard
|
||||||
|
|
||||||
@Environment(\.modelContext) private var localContext
|
@Environment(\.modelContext) private var localContext
|
||||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -40,7 +42,7 @@ struct NounFlashcardPracticeView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.adaptiveContainer(maxWidth: 720)
|
.adaptiveContainer(maxWidth: 720)
|
||||||
}
|
}
|
||||||
.navigationTitle("Nouns")
|
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Nouns")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear(perform: loadIfNeeded)
|
.onAppear(perform: loadIfNeeded)
|
||||||
.animation(.smooth, value: revealed)
|
.animation(.smooth, value: revealed)
|
||||||
@@ -158,7 +160,10 @@ struct NounFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||||
let graduation = session?.answer(rating)
|
let graduation = session?.answer(rating)
|
||||||
if let graduation {
|
// Review Learned is a cram — graduation drives the in-session queue
|
||||||
|
// only; the cross-session SM-2 schedule is left alone (mirrors the
|
||||||
|
// verb VocabFlashcardPracticeView reviewLearned behavior).
|
||||||
|
if let graduation, kind == .standard {
|
||||||
LexemeReviewStore(context: cloudContext).rate(
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
lexemeId: lexeme.id,
|
lexemeId: lexeme.id,
|
||||||
partOfSpeech: "noun",
|
partOfSpeech: "noun",
|
||||||
@@ -225,39 +230,54 @@ struct NounFlashcardPracticeView: View {
|
|||||||
|
|
||||||
private func loadIfNeeded() {
|
private func loadIfNeeded() {
|
||||||
guard session == nil else { return }
|
guard session == nil else { return }
|
||||||
let store = LexemeStudyGroupStore(
|
switch kind {
|
||||||
context: cloudContext,
|
case .reviewLearned:
|
||||||
partOfSpeech: "noun",
|
// Cram pass over previously-studied lexemes. No study-group
|
||||||
drillMode: Self.drillMode
|
// persistence — restart-fresh each time it opens.
|
||||||
)
|
let lexemes = LexemePool.reviewLearnedLexemes(
|
||||||
if let group = store.activeGroup() {
|
partOfSpeech: "noun",
|
||||||
let stored = group.entries
|
drillMode: Self.drillMode,
|
||||||
if !stored.isEmpty {
|
localContext: localContext,
|
||||||
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
cloudContext: cloudContext
|
||||||
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
)
|
||||||
guard let lex = byId[e.lexemeId] else { return nil }
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
return
|
||||||
}
|
|
||||||
if entries.count == stored.count {
|
case .standard:
|
||||||
session = LexemeSessionQueue(
|
let store = LexemeStudyGroupStore(
|
||||||
entries: entries,
|
context: cloudContext,
|
||||||
drillMode: Self.drillMode,
|
partOfSpeech: "noun",
|
||||||
learnedCount: group.learnedCount
|
drillMode: Self.drillMode
|
||||||
)
|
)
|
||||||
return
|
if let group = store.activeGroup() {
|
||||||
|
let stored = group.entries
|
||||||
|
if !stored.isEmpty {
|
||||||
|
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
||||||
|
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
||||||
|
guard let lex = byId[e.lexemeId] else { return nil }
|
||||||
|
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||||
|
}
|
||||||
|
if entries.count == stored.count {
|
||||||
|
session = LexemeSessionQueue(
|
||||||
|
entries: entries,
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
learnedCount: group.learnedCount
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
persistGroup()
|
||||||
}
|
}
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
|
||||||
let lexemes = LexemePool.sessionLexemes(
|
|
||||||
partOfSpeech: "noun",
|
|
||||||
drillMode: Self.drillMode,
|
|
||||||
enabledLevels: progress.selectedLexemeLevels,
|
|
||||||
localContext: localContext,
|
|
||||||
cloudContext: cloudContext
|
|
||||||
)
|
|
||||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
|
||||||
persistGroup()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||||
@@ -269,7 +289,8 @@ struct NounFlashcardPracticeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func persistGroup() {
|
private func persistGroup() {
|
||||||
guard let session else { return }
|
// Review Learned is a transient cram; don't write a study group.
|
||||||
|
guard kind == .standard, let session else { return }
|
||||||
let store = LexemeStudyGroupStore(
|
let store = LexemeStudyGroupStore(
|
||||||
context: cloudContext,
|
context: cloudContext,
|
||||||
partOfSpeech: "noun",
|
partOfSpeech: "noun",
|
||||||
@@ -286,21 +307,26 @@ struct NounFlashcardPracticeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func studyAgain() {
|
private func studyAgain() {
|
||||||
LexemeStudyGroupStore(
|
switch kind {
|
||||||
context: cloudContext,
|
case .reviewLearned:
|
||||||
partOfSpeech: "noun",
|
session?.restart()
|
||||||
drillMode: Self.drillMode
|
case .standard:
|
||||||
).clear()
|
LexemeStudyGroupStore(
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
context: cloudContext,
|
||||||
let lexemes = LexemePool.sessionLexemes(
|
partOfSpeech: "noun",
|
||||||
partOfSpeech: "noun",
|
drillMode: Self.drillMode
|
||||||
drillMode: Self.drillMode,
|
).clear()
|
||||||
enabledLevels: progress.selectedLexemeLevels,
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
localContext: localContext,
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
cloudContext: cloudContext
|
partOfSpeech: "noun",
|
||||||
)
|
drillMode: Self.drillMode,
|
||||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
persistGroup()
|
||||||
|
}
|
||||||
revealed = false
|
revealed = false
|
||||||
persistGroup()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// English-first noun multiple choice — non-verb analog of
|
||||||
|
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
|
||||||
|
/// noun pool; 4 options (1 correct + 3 random distractors from the session).
|
||||||
|
/// After answering: reveal feedback, the answer with its article (la taza /
|
||||||
|
/// el problema), example sentence when present, and Again/Hard/Good/Easy
|
||||||
|
/// rating which drives the `LexemeReviewStore` schedule.
|
||||||
|
struct NounMultipleChoicePracticeView: View {
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var session: LexemeSessionQueue?
|
||||||
|
@State private var distractorPool: [Lexeme] = []
|
||||||
|
@State private var options: [Lexeme] = []
|
||||||
|
@State private var selectedOption: Lexeme? = nil
|
||||||
|
|
||||||
|
private static let drillMode = "recall"
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 22) {
|
||||||
|
progressBar
|
||||||
|
if let lexeme = currentLexeme {
|
||||||
|
questionBody(lexeme)
|
||||||
|
} else {
|
||||||
|
completionView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 720)
|
||||||
|
}
|
||||||
|
.navigationTitle("Noun Multiple Choice")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadIfNeeded)
|
||||||
|
.animation(.smooth, value: selectedOption?.id)
|
||||||
|
.animation(.smooth, value: currentLexeme?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
private var progressBar: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ProgressView(value: session?.progress ?? 0).tint(.teal)
|
||||||
|
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressLabel: String {
|
||||||
|
guard let session else { return "Loading…" }
|
||||||
|
if session.isComplete { return "Done" }
|
||||||
|
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Question
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func questionBody(_ lexeme: Lexeme) -> some View {
|
||||||
|
Text(lexeme.english)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if selectedOption == nil {
|
||||||
|
optionGrid
|
||||||
|
} else {
|
||||||
|
revealedContent(lexeme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var optionGrid: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(options, id: \.id) { option in
|
||||||
|
Button {
|
||||||
|
selectedOption = option
|
||||||
|
} label: {
|
||||||
|
Text(formattedSpanish(option))
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.tint(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revealedContent(_ lexeme: Lexeme) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
answerFeedback(lexeme)
|
||||||
|
exampleBlock(for: lexeme)
|
||||||
|
ratingButtons
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answerFeedback(_ lexeme: Lexeme) -> some View {
|
||||||
|
let correct = (selectedOption?.id == lexeme.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(formattedSpanish(lexeme))
|
||||||
|
.font(.title2.weight(.semibold))
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||||
|
if let es = lexeme.exampleES, !es.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(es).font(.subheadline).italic()
|
||||||
|
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||||
|
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, rating: .again)
|
||||||
|
ratingButton("Hard", color: .orange, rating: .hard)
|
||||||
|
ratingButton("Good", color: .green, rating: .good)
|
||||||
|
ratingButton("Easy", color: .blue, rating: .easy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
|
||||||
|
Button {
|
||||||
|
answer(rating)
|
||||||
|
} 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?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(completionDetail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { studyAgain() } label: {
|
||||||
|
Label("Study Again", systemImage: "arrow.clockwise")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.teal)
|
||||||
|
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
.padding(.top, 60)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionDetail: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
if learned > 0 { return "\(learned) noun\(learned == 1 ? "" : "s") learned" }
|
||||||
|
return "No nouns are due right now. Study Again to review anyway."
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logic
|
||||||
|
|
||||||
|
private func loadIfNeeded() {
|
||||||
|
guard session == nil else { return }
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
let lexemes = LexemePool.sessionLexemes(
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
enabledLevels: progress.selectedLexemeLevels,
|
||||||
|
localContext: localContext,
|
||||||
|
cloudContext: cloudContext
|
||||||
|
)
|
||||||
|
distractorPool = lexemes
|
||||||
|
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func studyAgain() {
|
||||||
|
session?.restart()
|
||||||
|
selectedOption = nil
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareOptions() {
|
||||||
|
guard let lexeme = currentLexeme else { options = []; return }
|
||||||
|
let candidates = distractorPool.filter { $0.id != lexeme.id }
|
||||||
|
let distractors = Array(candidates.shuffled().prefix(3))
|
||||||
|
options = ([lexeme] + distractors).shuffled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answer(_ rating: LexemeSessionQueue.Rating) {
|
||||||
|
guard let lexeme = currentLexeme else { return }
|
||||||
|
let graduation = session?.answer(rating)
|
||||||
|
if let graduation {
|
||||||
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
|
lexemeId: lexeme.id,
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
quality: graduation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedOption = nil
|
||||||
|
prepareOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedSpanish(_ lexeme: Lexeme) -> String {
|
||||||
|
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
|
||||||
|
let article: String
|
||||||
|
switch g {
|
||||||
|
case "f": article = "la"
|
||||||
|
case "m/f": article = "el/la"
|
||||||
|
default: article = "el"
|
||||||
|
}
|
||||||
|
return "\(article) \(lexeme.baseForm)"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user