Vocab study — noun & adjective flashcards with CEFR level toggles
Add SRS-driven noun and adjective flashcards modeled on the existing verb flashcard flow: - SharedModels/Lexeme — catalog of non-verb vocab, frequency-ranked, with gender for nouns and optional example sentences. Seeded from a bundled vocab_lexemes.json built by Scripts/vocab/build_lexemes.py, which joins frequency.csv + es-en.data from a pinned doozan/spanish_data commit (CC-BY-SA: hermitdave/FrequencyWords + Wiktionary). 1,449 nouns and 600 adjectives, each with Wiktionary-sourced gender and (where available) an example sentence with English translation. - LexemeReviewCard + LexemeReviewStore — cloud-synced SM-2 SRS, keyed by partOfSpeech + lexemeId + drillMode so future drill modes can coexist. - LexemeSessionQueue + LexemePool — parallel to VocabSessionQueue; fresh cards sort by frequency rank. - LexemeStudyGroup — cloud-synced resumable session per (partOfSpeech, drillMode). - NounFlashcardPracticeView + AdjectiveFlashcardPracticeView — same flow as VocabFlashcardPracticeView: English prompt → tap to reveal Spanish → Again/Hard/Good/Easy. Nouns reveal with their article (la taza, el problema) so gender is taught alongside meaning, not as a separate quiz. Example sentence shown when present. CEFR-style level toggles: - LexemeLevel enum (A1/A2/B1/B2/C1+) derived from frequencyRank with standard Spanish-frequency-dictionary cutoffs (250/500/1000/2000). - UserProgress.selectedLexemeLevels — cloud-synced multi-select, defaults to A1+A2 on first launch. - SettingsView gains a "Vocabulary Levels" section with five toggles; the existing "Levels" section is renamed "Verb Levels" for clarity. - Due SRS cards always surface regardless of toggles. Disabling a level only stops new cards from that band entering the pool. PracticeView gets "Nouns" and "Adjectives" rows under "Books". DataLoader: new lexemeDataVersion gate that re-seeds the Lexeme table from vocab_lexemes.json independent of book seeding. project.yml lists the new JSON resource and the existing book_olly-vol2.json (which the previous build was silently excluding because xcodegen rewrote the project from project.yml). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,24 +10,24 @@
|
|||||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
||||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
||||||
|
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */; };
|
||||||
|
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */ = {isa = PBXBuildFile; fileRef = 7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */; };
|
||||||
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 */; };
|
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */; };
|
||||||
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.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 */; };
|
||||||
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 */; };
|
||||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
||||||
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 */; };
|
||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
||||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */; };
|
|
||||||
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661ADF1141176EE96774138 /* BookSpeechController.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 */; };
|
||||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
||||||
@@ -35,33 +35,32 @@
|
|||||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
|
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
|
||||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||||
|
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.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 */; };
|
||||||
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 */; };
|
||||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
|
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */; };
|
||||||
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3475931F1AD16054741E65 /* BookChapterListView.swift */; };
|
|
||||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
|
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
|
||||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
|
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
|
||||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
||||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||||
|
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */; };
|
||||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
|
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */; };
|
||||||
|
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */; };
|
||||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
|
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
|
||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||||
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
|
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
|
||||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||||
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.swift */; };
|
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
|
||||||
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */; };
|
|
||||||
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */; };
|
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||||
|
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA4750E84A7FA51532407CF /* BookLibraryView.swift */; };
|
||||||
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 */; };
|
||||||
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */; };
|
|
||||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.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 */; };
|
||||||
@@ -69,13 +68,18 @@
|
|||||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
|
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||||
|
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */; };
|
||||||
|
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */; };
|
||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||||
|
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = 03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */; };
|
||||||
|
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20423155763A77A050727EC /* BookReaderView.swift */; };
|
||||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
||||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
||||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
|
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
|
||||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
|
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
||||||
|
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */; };
|
||||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
|
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
|
||||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
|
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
|
||||||
@@ -89,7 +93,7 @@
|
|||||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
|
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */; };
|
||||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
|
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
|
||||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
||||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||||
@@ -100,21 +104,24 @@
|
|||||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
||||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
||||||
|
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */; };
|
||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
|
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */; };
|
||||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
||||||
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
|
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A48474D969CEF5F573DF09B /* StoryReaderView.swift */; };
|
||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
||||||
|
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */; };
|
||||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
||||||
|
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */; };
|
||||||
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
||||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
|
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168499F60BC7AFE5100C572 /* BookChapterListView.swift */; };
|
||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||||
@@ -155,12 +162,12 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
|
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleGenerator.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
@@ -171,16 +178,13 @@
|
|||||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
||||||
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
||||||
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
|
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.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>"; };
|
||||||
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>"; };
|
||||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
|
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
|
||||||
@@ -200,7 +204,9 @@
|
|||||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
||||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||||
|
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
|
||||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
|
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
|
||||||
|
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
||||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
||||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
@@ -212,7 +218,6 @@
|
|||||||
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>"; };
|
||||||
@@ -223,58 +228,67 @@
|
|||||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||||
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
||||||
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||||
|
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vocab_lexemes.json; sourceTree = "<group>"; };
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
||||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
||||||
|
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
|
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoActionsView.swift; sourceTree = "<group>"; };
|
||||||
|
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
||||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
|
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
||||||
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
||||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
|
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
|
||||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
|
||||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
|
|
||||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
|
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
|
||||||
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||||
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeStudyGroup.swift; sourceTree = "<group>"; };
|
||||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
|
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
|
||||||
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
|
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
|
||||||
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewCard.swift; sourceTree = "<group>"; };
|
||||||
|
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
|
||||||
|
C20423155763A77A050727EC /* BookReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
|
||||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
|
||||||
|
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
|
||||||
|
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||||
|
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||||
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>"; };
|
||||||
|
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewStore.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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.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>"; };
|
||||||
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>"; };
|
||||||
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>"; };
|
||||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
||||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
|
|
||||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
|
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
|
||||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
|
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
|
||||||
|
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeSessionQueue.swift; sourceTree = "<group>"; };
|
||||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||||
|
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
|
||||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
|
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
|
||||||
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
|
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadedVideosView.swift; sourceTree = "<group>"; };
|
||||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
|
||||||
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
|
|
||||||
FF3475931F1AD16054741E65 /* BookChapterListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -302,6 +316,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
|
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
|
||||||
|
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
|
||||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
|
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
|
||||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||||
@@ -310,13 +325,14 @@
|
|||||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
|
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
|
||||||
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
|
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
|
||||||
|
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */,
|
||||||
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
|
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
|
||||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||||
|
23B49FBE9B44D8734D96625F /* Scripts */,
|
||||||
1994867BC8E985795A172854 /* Services */,
|
1994867BC8E985795A172854 /* Services */,
|
||||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||||
A81CA75762B08D35D5B7A44D /* Views */,
|
A81CA75762B08D35D5B7A44D /* Views */,
|
||||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
|
|
||||||
);
|
);
|
||||||
path = Conjuga;
|
path = Conjuga;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -345,11 +361,15 @@
|
|||||||
children = (
|
children = (
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||||
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
||||||
|
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */,
|
||||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
||||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
||||||
|
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */,
|
||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
|
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */,
|
||||||
|
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */,
|
||||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||||
@@ -365,25 +385,21 @@
|
|||||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||||
|
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
|
||||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||||
|
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
|
||||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
|
||||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
|
||||||
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
|
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
|
23B49FBE9B44D8734D96625F /* Scripts */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
|
6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
|
||||||
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
|
|
||||||
);
|
);
|
||||||
name = Vocab;
|
path = Scripts;
|
||||||
path = Vocab;
|
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
|
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
|
||||||
@@ -400,14 +416,16 @@
|
|||||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
||||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||||
|
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */,
|
||||||
|
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */,
|
||||||
|
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */,
|
||||||
626873572466403C0288090D /* QuizType.swift */,
|
626873572466403C0288090D /* QuizType.swift */,
|
||||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
||||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
|
||||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
|
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -474,25 +492,40 @@
|
|||||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||||
|
9CD612E55440D22B877EA8FE /* Books */,
|
||||||
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||||
43E4D263B0AF47E401A51601 /* Stories */,
|
43E4D263B0AF47E401A51601 /* Stories */,
|
||||||
74AC8A0D381958D2A14316C3 /* Books */,
|
730BD7F59F4C97D87EF98FB1 /* Vocab */,
|
||||||
1ECAF79E2138DF73BB1F6403 /* Vocab */,
|
|
||||||
);
|
);
|
||||||
path = Practice;
|
path = Practice;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
74AC8A0D381958D2A14316C3 /* Books */ = {
|
6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
|
7DE0F6354CF73BDA0CE728BA /* in */,
|
||||||
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
|
C36A0F3B1A4B759412ADB4E5 /* out */,
|
||||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
|
|
||||||
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
|
|
||||||
);
|
);
|
||||||
name = Books;
|
path = "guide-enrichment";
|
||||||
path = Books;
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
730BD7F59F4C97D87EF98FB1 /* Vocab */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
|
||||||
|
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
|
||||||
|
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
|
||||||
|
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
|
||||||
|
);
|
||||||
|
path = Vocab;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7DE0F6354CF73BDA0CE728BA /* in */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = in;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
||||||
@@ -526,6 +559,17 @@
|
|||||||
path = Chat;
|
path = Chat;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
9CD612E55440D22B877EA8FE /* Books */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */,
|
||||||
|
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */,
|
||||||
|
C20423155763A77A050727EC /* BookReaderView.swift */,
|
||||||
|
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */,
|
||||||
|
);
|
||||||
|
path = Books;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A591A3B6F1F13D23D68D7A9D = {
|
A591A3B6F1F13D23D68D7A9D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -568,17 +612,24 @@
|
|||||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||||
|
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */,
|
||||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
||||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
||||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
||||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Course;
|
path = Course;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
C36A0F3B1A4B759412ADB4E5 /* out */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = out;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -690,14 +741,15 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
||||||
|
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */,
|
||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
||||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
||||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
||||||
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
|
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
|
||||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
|
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
|
||||||
|
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */,
|
||||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
|
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
|
||||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -710,8 +762,14 @@
|
|||||||
files = (
|
files = (
|
||||||
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 */,
|
||||||
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 */,
|
||||||
|
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */,
|
||||||
|
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */,
|
||||||
|
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */,
|
||||||
|
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */,
|
||||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
||||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
|
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
|
||||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
|
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
|
||||||
@@ -728,6 +786,8 @@
|
|||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||||
|
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */,
|
||||||
|
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */,
|
||||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||||
@@ -735,11 +795,16 @@
|
|||||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
||||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
||||||
|
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */,
|
||||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
||||||
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
|
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
|
||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||||
|
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */,
|
||||||
|
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */,
|
||||||
|
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */,
|
||||||
|
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */,
|
||||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
||||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||||
@@ -748,6 +813,7 @@
|
|||||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
||||||
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 */,
|
||||||
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 */,
|
||||||
@@ -787,26 +853,18 @@
|
|||||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
|
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */,
|
||||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||||
|
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */,
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
|
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
||||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||||
|
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */,
|
||||||
|
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */,
|
|
||||||
6F7BE3533FAF4DE1D514AA7C /* ExtraStudyView.swift in Sources */,
|
|
||||||
65382875879BD537F5358381 /* BookLibraryView.swift in Sources */,
|
|
||||||
4E00225D668FDFA3026B7627 /* BookChapterListView.swift in Sources */,
|
|
||||||
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */,
|
|
||||||
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */,
|
|
||||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */,
|
|
||||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */,
|
|
||||||
419DF099F0270E65D53932FD /* VocabFlashcardPracticeView.swift in Sources */,
|
|
||||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
|
||||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */,
|
|
||||||
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */,
|
|
||||||
6951332583F08DA6841F09FC /* VocabStudyGroup.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ struct ConjugaApp: App {
|
|||||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||||
|
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||||
]),
|
]),
|
||||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,7 @@ struct ConjugaApp: App {
|
|||||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||||
|
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||||
configurations: cloudConfig
|
configurations: cloudConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -253,7 +255,7 @@ struct ConjugaApp: App {
|
|||||||
/// Clears accumulated stale schema metadata from previous container configurations.
|
/// Clears accumulated stale schema metadata from previous container configurations.
|
||||||
/// Bump the version number to force another reset if the schema changes again.
|
/// Bump the version number to force another reset if the schema changes again.
|
||||||
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
||||||
let resetVersion = 5 // bump: Book/BookChapter added to local container
|
let resetVersion = 6 // bump: Lexeme added to local container
|
||||||
let key = "localStoreResetVersion"
|
let key = "localStoreResetVersion"
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// SRS record for non-verb vocab cards (nouns, adjectives, …). Keyed by
|
||||||
|
/// `(partOfSpeech, lexemeId, drillMode)` so a noun's gender drill and its
|
||||||
|
/// English-recall drill progress independently. Lives in the cloud container
|
||||||
|
/// alongside `VerbReviewCard` so vocab progress syncs across devices.
|
||||||
|
///
|
||||||
|
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||||
|
/// `LexemeReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||||
|
@Model
|
||||||
|
final class LexemeReviewCard {
|
||||||
|
var id: String = ""
|
||||||
|
var lexemeId: String = ""
|
||||||
|
var partOfSpeech: String = ""
|
||||||
|
var drillMode: String = ""
|
||||||
|
|
||||||
|
var easeFactor: Double = 2.5
|
||||||
|
var interval: Int = 0
|
||||||
|
var repetitions: Int = 0
|
||||||
|
var dueDate: Date = Date()
|
||||||
|
var lastReviewDate: Date?
|
||||||
|
|
||||||
|
init(lexemeId: String, partOfSpeech: String, drillMode: String) {
|
||||||
|
self.id = Self.makeId(
|
||||||
|
lexemeId: lexemeId,
|
||||||
|
partOfSpeech: partOfSpeech,
|
||||||
|
drillMode: drillMode
|
||||||
|
)
|
||||||
|
self.lexemeId = lexemeId
|
||||||
|
self.partOfSpeech = partOfSpeech
|
||||||
|
self.drillMode = drillMode
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeId(lexemeId: String, partOfSpeech: String, drillMode: String) -> String {
|
||||||
|
"\(partOfSpeech)|\(lexemeId)|\(drillMode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// Per-(POS, drillMode) active study group, mirroring `VocabStudyGroup`.
|
||||||
|
/// Keying by drill mode means a noun gender drill and an adjective agreement
|
||||||
|
/// drill can each have their own resumable session at the same time.
|
||||||
|
///
|
||||||
|
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create.
|
||||||
|
@Model
|
||||||
|
final class LexemeStudyGroup {
|
||||||
|
var id: String = ""
|
||||||
|
var partOfSpeech: String = ""
|
||||||
|
var drillMode: String = ""
|
||||||
|
/// JSON-encoded `[StoredLexemeEntry]` — the in-session queue in order.
|
||||||
|
var entriesJSON: Data = Data()
|
||||||
|
var learnedCount: Int = 0
|
||||||
|
var createdAt: Date = Date()
|
||||||
|
|
||||||
|
init(
|
||||||
|
partOfSpeech: String,
|
||||||
|
drillMode: String,
|
||||||
|
entriesJSON: Data,
|
||||||
|
learnedCount: Int
|
||||||
|
) {
|
||||||
|
self.id = Self.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
|
||||||
|
self.partOfSpeech = partOfSpeech
|
||||||
|
self.drillMode = drillMode
|
||||||
|
self.entriesJSON = entriesJSON
|
||||||
|
self.learnedCount = learnedCount
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func activeID(partOfSpeech: String, drillMode: String) -> String {
|
||||||
|
"active-\(partOfSpeech)-\(drillMode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries: [StoredLexemeEntry] {
|
||||||
|
(try? JSONDecoder().decode([StoredLexemeEntry].self, from: entriesJSON)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One lexeme's spot in the persisted study group.
|
||||||
|
struct StoredLexemeEntry: Codable {
|
||||||
|
var lexemeId: String
|
||||||
|
/// Raw value of `LexemeSessionQueue.CardState`.
|
||||||
|
var state: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch / persist / clear the active group for one `(POS, drillMode)` pair.
|
||||||
|
struct LexemeStudyGroupStore {
|
||||||
|
let context: ModelContext
|
||||||
|
let partOfSpeech: String
|
||||||
|
let drillMode: String
|
||||||
|
|
||||||
|
private var activeID: String {
|
||||||
|
LexemeStudyGroup.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeGroup() -> LexemeStudyGroup? {
|
||||||
|
let id = activeID
|
||||||
|
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||||
|
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
|
||||||
|
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
|
||||||
|
)
|
||||||
|
return (try? context.fetch(descriptor))?.first
|
||||||
|
}
|
||||||
|
|
||||||
|
func persist(entries: [StoredLexemeEntry], learnedCount: Int) {
|
||||||
|
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
||||||
|
let id = activeID
|
||||||
|
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||||
|
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
|
||||||
|
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
|
||||||
|
)
|
||||||
|
let existing = (try? context.fetch(descriptor)) ?? []
|
||||||
|
if let newest = existing.first {
|
||||||
|
newest.entriesJSON = data
|
||||||
|
newest.learnedCount = learnedCount
|
||||||
|
for duplicate in existing.dropFirst() { context.delete(duplicate) }
|
||||||
|
} else {
|
||||||
|
context.insert(LexemeStudyGroup(
|
||||||
|
partOfSpeech: partOfSpeech,
|
||||||
|
drillMode: drillMode,
|
||||||
|
entriesJSON: data,
|
||||||
|
learnedCount: learnedCount
|
||||||
|
))
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
let id = activeID
|
||||||
|
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||||
|
predicate: #Predicate<LexemeStudyGroup> { $0.id == id }
|
||||||
|
)
|
||||||
|
for group in (try? context.fetch(descriptor)) ?? [] {
|
||||||
|
context.delete(group)
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,10 @@ final class UserProgress {
|
|||||||
var selectedLevelsBlob: String = ""
|
var selectedLevelsBlob: String = ""
|
||||||
var enabledIrregularCategoriesBlob: String = ""
|
var enabledIrregularCategoriesBlob: String = ""
|
||||||
|
|
||||||
|
// Multi-select CEFR levels for the noun/adjective vocab catalog —
|
||||||
|
// separate from the verb levels above so the two are independent.
|
||||||
|
var selectedLexemeLevelsBlob: String = ""
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
var selectedVerbLevel: VerbLevel {
|
var selectedVerbLevel: VerbLevel {
|
||||||
@@ -107,6 +111,35 @@ final class UserProgress {
|
|||||||
selectedVerbLevels = values
|
selectedVerbLevels = values
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CEFR-style levels currently enabled for noun + adjective flashcards.
|
||||||
|
/// First-ever read (blob empty) defaults to A1+A2 — a beginner-friendly
|
||||||
|
/// starting point. Once the user touches any toggle, the blob is no
|
||||||
|
/// longer empty and exactly reflects their selection (including the
|
||||||
|
/// "all off" state, which shows the empty-state message).
|
||||||
|
var selectedLexemeLevels: Set<LexemeLevel> {
|
||||||
|
get {
|
||||||
|
if selectedLexemeLevelsBlob.isEmpty {
|
||||||
|
return [.a1, .a2]
|
||||||
|
}
|
||||||
|
let raw = decodeStringArray(from: selectedLexemeLevelsBlob, fallback: [])
|
||||||
|
return Set(raw.compactMap(LexemeLevel.init(rawValue:)))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
let sorted = newValue.map(\.rawValue)
|
||||||
|
selectedLexemeLevelsBlob = Self.encodeStringArray(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLexemeLevelEnabled(_ level: LexemeLevel, enabled: Bool) {
|
||||||
|
var values = selectedLexemeLevels
|
||||||
|
if enabled {
|
||||||
|
values.insert(level)
|
||||||
|
} else {
|
||||||
|
values.remove(level)
|
||||||
|
}
|
||||||
|
selectedLexemeLevels = values
|
||||||
|
}
|
||||||
|
|
||||||
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
||||||
var values = enabledIrregularCategories
|
var values = enabledIrregularCategories
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ actor DataLoader {
|
|||||||
static let textbookDataVersion = 14
|
static let textbookDataVersion = 14
|
||||||
static let textbookDataKey = "textbookDataVersion"
|
static let textbookDataKey = "textbookDataVersion"
|
||||||
|
|
||||||
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
static let bookDataVersion = 7 // Lexeme table + WordGloss.gender added
|
||||||
static let bookDataKey = "bookDataVersion"
|
static let bookDataKey = "bookDataVersion"
|
||||||
|
|
||||||
|
static let lexemeDataVersion = 1 // initial — seeded from vocab_lexemes.json
|
||||||
|
static let lexemeDataKey = "lexemeDataVersion"
|
||||||
|
|
||||||
/// Quick check: does the DB need seeding or course data refresh?
|
/// Quick check: does the DB need seeding or course data refresh?
|
||||||
static func needsSeeding(container: ModelContainer) async -> Bool {
|
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
@@ -602,7 +605,8 @@ actor DataLoader {
|
|||||||
glossary[word] = WordGloss(
|
glossary[word] = WordGloss(
|
||||||
baseForm: fields["baseForm"] ?? word,
|
baseForm: fields["baseForm"] ?? word,
|
||||||
english: fields["english"] ?? "",
|
english: fields["english"] ?? "",
|
||||||
partOfSpeech: fields["partOfSpeech"] ?? ""
|
partOfSpeech: fields["partOfSpeech"] ?? "",
|
||||||
|
gender: fields["gender"]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,6 +661,100 @@ actor DataLoader {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lexeme catalog (Phase 3 of vocab study)
|
||||||
|
|
||||||
|
/// Re-seed the `Lexeme` catalog if the version has changed or the rows
|
||||||
|
/// are missing. The catalog is sourced from the bundled
|
||||||
|
/// `vocab_lexemes.json` (built by `Scripts/vocab/build_lexemes.py` from
|
||||||
|
/// doozan/spanish_data) — independent from book seeding so a catalog
|
||||||
|
/// refresh doesn't require touching books.
|
||||||
|
static func refreshLexemesIfNeeded(container: ModelContainer) async {
|
||||||
|
let shared = UserDefaults.standard
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let existingCount = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
|
||||||
|
let storedVersion = shared.integer(forKey: lexemeDataKey)
|
||||||
|
let versionCurrent = storedVersion >= lexemeDataVersion
|
||||||
|
|
||||||
|
print("[DataLoader] refreshLexemesIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(lexemeDataVersion) versionCurrent=\(versionCurrent)")
|
||||||
|
|
||||||
|
if versionCurrent && existingCount > 0 { return }
|
||||||
|
|
||||||
|
if let existing = try? context.fetch(FetchDescriptor<Lexeme>()) {
|
||||||
|
for lexeme in existing { context.delete(lexeme) }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print("[DataLoader] ERROR: lexeme wipe save failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if seedLexemesFromCatalog(context: context) {
|
||||||
|
shared.set(lexemeDataVersion, forKey: lexemeDataKey)
|
||||||
|
print("[DataLoader] Lexeme data re-seeded to version \(lexemeDataVersion)")
|
||||||
|
} else {
|
||||||
|
print("[DataLoader] Lexeme reseed produced no rows — leaving version key untouched")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `vocab_lexemes.json` from the app bundle and insert one `Lexeme`
|
||||||
|
/// per entry. Returns true when at least one row persisted.
|
||||||
|
private static func seedLexemesFromCatalog(context: ModelContext) -> Bool {
|
||||||
|
guard let url = Bundle.main.url(forResource: "vocab_lexemes", withExtension: "json") else {
|
||||||
|
print("[DataLoader] no vocab_lexemes.json bundled — skipping lexeme seed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||||
|
print("[DataLoader] ERROR: vocab_lexemes.json malformed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var inserted = 0
|
||||||
|
// Defensive: the build script already dedupes, but skip any stray
|
||||||
|
// dupes so we never throw on the unique-constraint save.
|
||||||
|
var seen: Set<String> = []
|
||||||
|
for entry in array {
|
||||||
|
guard let baseForm = entry["baseForm"] as? String, !baseForm.isEmpty,
|
||||||
|
let english = entry["english"] as? String, !english.isEmpty,
|
||||||
|
let pos = entry["partOfSpeech"] as? String, !pos.isEmpty else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let dedupKey = "\(pos):\(baseForm)"
|
||||||
|
if seen.contains(dedupKey) { continue }
|
||||||
|
seen.insert(dedupKey)
|
||||||
|
|
||||||
|
let lexeme = Lexeme(
|
||||||
|
id: Lexeme.makeID(sourceBookSlug: "catalog", partOfSpeech: pos, baseForm: baseForm),
|
||||||
|
partOfSpeech: pos,
|
||||||
|
baseForm: baseForm,
|
||||||
|
english: english,
|
||||||
|
gender: entry["gender"] as? String,
|
||||||
|
sourceBookSlug: "catalog",
|
||||||
|
frequencyRank: (entry["frequencyRank"] as? Int) ?? 0,
|
||||||
|
exampleES: entry["exampleES"] as? String,
|
||||||
|
exampleEN: entry["exampleEN"] as? String
|
||||||
|
)
|
||||||
|
context.insert(lexeme)
|
||||||
|
inserted += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print("[DataLoader] ERROR: lexeme save failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let persisted = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
|
||||||
|
guard persisted > 0 else {
|
||||||
|
print("[DataLoader] ERROR: seeded \(inserted) lexemes but persisted count is 0")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
print("Lexeme seeding complete: \(persisted) lexemes from catalog")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// Slugs of books bundled with the app. Kept explicit so device installs
|
/// Slugs of books bundled with the app. Kept explicit so device installs
|
||||||
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
|
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
|
||||||
/// successfully enumerating the bundle — that API has been observed to
|
/// successfully enumerating the bundle — that API has been observed to
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// SRS rating for non-verb vocab cards. Mirrors `VerbReviewStore` but keyed
|
||||||
|
/// by `(partOfSpeech, lexemeId, drillMode)` so independent drills against the
|
||||||
|
/// same lexeme don't fight over one schedule.
|
||||||
|
struct LexemeReviewStore {
|
||||||
|
let context: ModelContext
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func fetchOrCreateReviewCard(
|
||||||
|
lexemeId: String,
|
||||||
|
partOfSpeech: String,
|
||||||
|
drillMode: String
|
||||||
|
) -> LexemeReviewCard {
|
||||||
|
let id = LexemeReviewCard.makeId(
|
||||||
|
lexemeId: lexemeId,
|
||||||
|
partOfSpeech: partOfSpeech,
|
||||||
|
drillMode: drillMode
|
||||||
|
)
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> { $0.id == id }
|
||||||
|
)
|
||||||
|
if let existing = (try? context.fetch(descriptor))?.first {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let card = LexemeReviewCard(
|
||||||
|
lexemeId: lexemeId,
|
||||||
|
partOfSpeech: partOfSpeech,
|
||||||
|
drillMode: drillMode
|
||||||
|
)
|
||||||
|
context.insert(card)
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func rate(
|
||||||
|
lexemeId: String,
|
||||||
|
partOfSpeech: String,
|
||||||
|
drillMode: String,
|
||||||
|
quality: ReviewQuality
|
||||||
|
) {
|
||||||
|
let card = fetchOrCreateReviewCard(
|
||||||
|
lexemeId: lexemeId,
|
||||||
|
partOfSpeech: partOfSpeech,
|
||||||
|
drillMode: drillMode
|
||||||
|
)
|
||||||
|
let result = SRSEngine.review(
|
||||||
|
quality: quality,
|
||||||
|
currentEase: card.easeFactor,
|
||||||
|
currentInterval: card.interval,
|
||||||
|
currentReps: card.repetitions
|
||||||
|
)
|
||||||
|
card.easeFactor = result.easeFactor
|
||||||
|
card.interval = result.interval
|
||||||
|
card.repetitions = result.repetitions
|
||||||
|
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||||
|
card.lastReviewDate = Date()
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// In-session learning-step queue for `Lexeme`-based vocab practice — the
|
||||||
|
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
|
||||||
|
/// requeue: Again/Hard requeue close, Good advances state then graduates on
|
||||||
|
/// the second pass, Easy graduates immediately. `answer` returns a
|
||||||
|
/// `ReviewQuality` only when the card graduates — that's the rating fed to
|
||||||
|
/// the cross-session `LexemeReviewStore`.
|
||||||
|
struct LexemeSessionQueue {
|
||||||
|
|
||||||
|
enum CardState: String {
|
||||||
|
case new
|
||||||
|
case learning
|
||||||
|
case review
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Rating {
|
||||||
|
case again, hard, good, easy
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Entry: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let lexeme: Lexeme
|
||||||
|
var state: CardState
|
||||||
|
}
|
||||||
|
|
||||||
|
let drillMode: String
|
||||||
|
private(set) var queue: [Entry]
|
||||||
|
private(set) var learnedCount: Int = 0
|
||||||
|
private let originalLexemes: [Lexeme]
|
||||||
|
|
||||||
|
init(lexemes: [Lexeme], drillMode: String) {
|
||||||
|
self.drillMode = drillMode
|
||||||
|
self.originalLexemes = lexemes
|
||||||
|
self.queue = lexemes.map { Entry(lexeme: $0, state: .new) }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(entries: [(lexeme: Lexeme, state: CardState)], drillMode: String, learnedCount: Int) {
|
||||||
|
self.drillMode = drillMode
|
||||||
|
self.originalLexemes = entries.map(\.lexeme)
|
||||||
|
self.queue = entries.map { Entry(lexeme: $0.lexeme, state: $0.state) }
|
||||||
|
self.learnedCount = learnedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [(lexemeId: String, state: CardState)] {
|
||||||
|
queue.map { ($0.lexeme.id, $0.state) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Entry? { queue.first }
|
||||||
|
var isComplete: Bool { queue.isEmpty }
|
||||||
|
var remainingCount: Int { queue.count }
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
let total = learnedCount + queue.count
|
||||||
|
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||||||
|
guard !queue.isEmpty else { return nil }
|
||||||
|
var entry = queue.removeFirst()
|
||||||
|
|
||||||
|
switch rating {
|
||||||
|
case .again:
|
||||||
|
entry.state = .learning
|
||||||
|
insert(entry, offset: Int.random(in: 5...8))
|
||||||
|
return nil
|
||||||
|
case .hard:
|
||||||
|
entry.state = .learning
|
||||||
|
insert(entry, offset: Int.random(in: 7...10))
|
||||||
|
return nil
|
||||||
|
case .good:
|
||||||
|
if entry.state == .review {
|
||||||
|
learnedCount += 1
|
||||||
|
return .good
|
||||||
|
}
|
||||||
|
entry.state = .review
|
||||||
|
insert(entry, offset: Int.random(in: 16...24))
|
||||||
|
return nil
|
||||||
|
case .easy:
|
||||||
|
learnedCount += 1
|
||||||
|
return .easy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func restart() {
|
||||||
|
queue = originalLexemes.shuffled().map { Entry(lexeme: $0, state: .new) }
|
||||||
|
learnedCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func insert(_ entry: Entry, offset: Int) {
|
||||||
|
let idx = min(queue.count, offset)
|
||||||
|
queue.insert(entry, at: idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session lexeme pool
|
||||||
|
|
||||||
|
/// Builds a session for a given POS + drill mode: due-first per
|
||||||
|
/// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped.
|
||||||
|
enum LexemePool {
|
||||||
|
|
||||||
|
/// Per-session cap. 0/unset → 20. Mirrors `VocabVerbPool.sessionCardLimit`.
|
||||||
|
static var sessionCardLimit: Int {
|
||||||
|
let stored = UserDefaults.standard.integer(forKey: "lexemeSessionCardLimit")
|
||||||
|
return stored == 0 ? 20 : stored
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sessionLexemes(
|
||||||
|
partOfSpeech: String,
|
||||||
|
drillMode: String,
|
||||||
|
enabledLevels: Set<LexemeLevel>,
|
||||||
|
localContext: ModelContext,
|
||||||
|
cloudContext: ModelContext
|
||||||
|
) -> [Lexeme] {
|
||||||
|
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
|
||||||
|
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
let cardById = Dictionary(
|
||||||
|
reviewCards.map { ($0.lexemeId, $0) },
|
||||||
|
uniquingKeysWith: { existing, _ in existing }
|
||||||
|
)
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
var due: [(lexeme: Lexeme, dueDate: Date)] = []
|
||||||
|
var fresh: [Lexeme] = []
|
||||||
|
for lexeme in pool {
|
||||||
|
if let card = cardById[lexeme.id] {
|
||||||
|
if card.dueDate <= now {
|
||||||
|
// Due cards surface regardless of current level toggles —
|
||||||
|
// SRS isn't level-gated. Already-studied cards keep
|
||||||
|
// coming back on their schedule.
|
||||||
|
due.append((lexeme, card.dueDate))
|
||||||
|
}
|
||||||
|
} else if enabledLevels.contains(LexemeLevel.level(forRank: lexeme.frequencyRank)) {
|
||||||
|
// Fresh (never-studied) cards only enter the pool from
|
||||||
|
// levels the user has on. Disabling a level is the lever
|
||||||
|
// for "don't introduce me to harder/easier words yet."
|
||||||
|
fresh.append(lexeme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
due.sort { $0.dueDate < $1.dueDate }
|
||||||
|
// Fresh cards surface in frequency order — most-useful words first.
|
||||||
|
// Lexemes without a rank (frequencyRank == 0) sort last.
|
||||||
|
fresh.sort { lhs, rhs in
|
||||||
|
let l = lhs.frequencyRank == 0 ? Int.max : lhs.frequencyRank
|
||||||
|
let r = rhs.frequencyRank == 0 ? Int.max : rhs.frequencyRank
|
||||||
|
if l != r { return l < r }
|
||||||
|
return lhs.baseForm < rhs.baseForm
|
||||||
|
}
|
||||||
|
|
||||||
|
let ordered = due.map(\.lexeme) + fresh
|
||||||
|
return Array(ordered.prefix(sessionCardLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lexemes the user has already studied at least once for `(POS, drill)`,
|
||||||
|
/// most-recently-studied first. Mirrors `VocabVerbPool.reviewLearnedVerbs`.
|
||||||
|
static func reviewLearnedLexemes(
|
||||||
|
partOfSpeech: String,
|
||||||
|
drillMode: String,
|
||||||
|
localContext: ModelContext,
|
||||||
|
cloudContext: ModelContext
|
||||||
|
) -> [Lexeme] {
|
||||||
|
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||||
|
predicate: #Predicate<LexemeReviewCard> {
|
||||||
|
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
let sorted = reviewCards.sorted {
|
||||||
|
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
|
||||||
|
let byId = Dictionary(pool.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||||
|
return sorted.compactMap { byId[$0.lexemeId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lexemes for a POS. The catalog (`vocab_lexemes.json`) only emits
|
||||||
|
/// nouns that have a known gender, so no extra filter is needed here.
|
||||||
|
private static func fetchStudyable(partOfSpeech: String, context: ModelContext) -> [Lexeme] {
|
||||||
|
let descriptor = FetchDescriptor<Lexeme>(
|
||||||
|
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
|
||||||
|
)
|
||||||
|
return (try? context.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ enum StartupCoordinator {
|
|||||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||||
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
||||||
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
|
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
|
||||||
|
await DataLoader.refreshLexemesIfNeeded(container: localContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||||
|
|||||||
@@ -302,6 +302,68 @@ 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) {
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// English ↔ Spanish adjective flashcards. Same flow as the noun view and
|
||||||
|
/// the verb flashcards: show the English meaning, tap to reveal the Spanish
|
||||||
|
/// base form, rate Again/Hard/Good/Easy. Agreement (gender + number) is
|
||||||
|
/// taught organically through reading and verb-flashcard examples, not as a
|
||||||
|
/// separate quiz here.
|
||||||
|
///
|
||||||
|
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader`.
|
||||||
|
struct AdjectiveFlashcardPracticeView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var session: LexemeSessionQueue?
|
||||||
|
@State private var revealed: Bool = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||||
|
|
||||||
|
private static let drillMode = "recall"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
headerBar
|
||||||
|
if let lexeme = currentLexeme {
|
||||||
|
cardContent(for: lexeme)
|
||||||
|
} else {
|
||||||
|
completionView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 720)
|
||||||
|
}
|
||||||
|
.navigationTitle("Adjectives")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadIfNeeded)
|
||||||
|
.animation(.smooth, value: revealed)
|
||||||
|
.animation(.smooth, value: currentLexeme?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardContent(for lexeme: Lexeme) -> some View {
|
||||||
|
Text(lexeme.english)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if revealed {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Text(lexeme.baseForm)
|
||||||
|
.font(.title.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
exampleBlock(for: lexeme)
|
||||||
|
ratingButtons(for: lexeme)
|
||||||
|
}
|
||||||
|
} 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, minHeight: 200)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.smooth) { revealed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerBar: 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rating
|
||||||
|
|
||||||
|
private func ratingButtons(for lexeme: Lexeme) -> 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, lexeme: lexeme)
|
||||||
|
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
|
||||||
|
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
|
||||||
|
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
|
||||||
|
Button {
|
||||||
|
answer(rating, for: lexeme)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.tint(color)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||||
|
let graduation = session?.answer(rating)
|
||||||
|
if let graduation {
|
||||||
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
|
lexemeId: lexeme.id,
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
quality: graduation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
persistGroup()
|
||||||
|
withAnimation(.smooth) { revealed = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completion
|
||||||
|
|
||||||
|
private var completionView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 56)).foregroundStyle(.green)
|
||||||
|
Text(completionTitle).font(.title2.bold())
|
||||||
|
Text(completionDetail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { studyAgain() } label: {
|
||||||
|
Label("Next Set", systemImage: "arrow.right")
|
||||||
|
.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 completionTitle: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
return learned > 0 ? "Session Complete" : "Nothing Available"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionDetail: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
if learned > 0 {
|
||||||
|
return "\(learned) adjective\(learned == 1 ? "" : "s") learned"
|
||||||
|
}
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
if progress.selectedLexemeLevels.isEmpty {
|
||||||
|
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
|
||||||
|
}
|
||||||
|
return "No adjectives available at the enabled levels right now."
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session lifecycle
|
||||||
|
|
||||||
|
private func loadIfNeeded() {
|
||||||
|
guard session == nil else { return }
|
||||||
|
let store = LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
)
|
||||||
|
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: "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] {
|
||||||
|
let descriptor = FetchDescriptor<Lexeme>(
|
||||||
|
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||||
|
)
|
||||||
|
let all = (try? localContext.fetch(descriptor)) ?? []
|
||||||
|
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistGroup() {
|
||||||
|
guard let session else { return }
|
||||||
|
let store = LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
)
|
||||||
|
if session.isComplete {
|
||||||
|
store.clear()
|
||||||
|
} else {
|
||||||
|
let entries = session.snapshot().map {
|
||||||
|
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
|
||||||
|
}
|
||||||
|
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func studyAgain() {
|
||||||
|
LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "adjective",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
).clear()
|
||||||
|
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)
|
||||||
|
revealed = false
|
||||||
|
persistGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// English ↔ Spanish noun flashcards. Same flow as `VocabFlashcardPracticeView`
|
||||||
|
/// for verbs: show the English meaning, tap to reveal the Spanish word, rate
|
||||||
|
/// Again/Hard/Good/Easy. The Spanish reveal shows the word with its article
|
||||||
|
/// (`la taza`, `el problema`) so gender is taught alongside meaning instead
|
||||||
|
/// of being a separate "el or la?" quiz.
|
||||||
|
///
|
||||||
|
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader` (keeps
|
||||||
|
/// it out of the books-reader layout-loop class of bug).
|
||||||
|
struct NounFlashcardPracticeView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var localContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var session: LexemeSessionQueue?
|
||||||
|
@State private var revealed: Bool = false
|
||||||
|
|
||||||
|
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||||
|
|
||||||
|
/// Single drill mode for now — meaning recall. The `LexemeReviewCard` /
|
||||||
|
/// `LexemeStudyGroup` IDs are keyed by drillMode so other modes can be
|
||||||
|
/// added later without colliding with this one.
|
||||||
|
private static let drillMode = "recall"
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
headerBar
|
||||||
|
if let lexeme = currentLexeme {
|
||||||
|
cardContent(for: lexeme)
|
||||||
|
} else {
|
||||||
|
completionView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.adaptiveContainer(maxWidth: 720)
|
||||||
|
}
|
||||||
|
.navigationTitle("Nouns")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadIfNeeded)
|
||||||
|
.animation(.smooth, value: revealed)
|
||||||
|
.animation(.smooth, value: currentLexeme?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Card
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func cardContent(for lexeme: Lexeme) -> some View {
|
||||||
|
Text(lexeme.english)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if revealed {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Text(formattedSpanish(lexeme))
|
||||||
|
.font(.title.weight(.semibold))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
exampleBlock(for: lexeme)
|
||||||
|
ratingButtons(for: lexeme)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tapToReveal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the noun with its article so gender comes along free.
|
||||||
|
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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
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, minHeight: 200)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.smooth) { revealed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerBar: 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: - Rating
|
||||||
|
|
||||||
|
private func ratingButtons(for lexeme: Lexeme) -> 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, lexeme: lexeme)
|
||||||
|
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
|
||||||
|
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
|
||||||
|
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
|
||||||
|
Button {
|
||||||
|
answer(rating, for: lexeme)
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.tint(color)
|
||||||
|
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||||
|
let graduation = session?.answer(rating)
|
||||||
|
if let graduation {
|
||||||
|
LexemeReviewStore(context: cloudContext).rate(
|
||||||
|
lexemeId: lexeme.id,
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode,
|
||||||
|
quality: graduation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
persistGroup()
|
||||||
|
withAnimation(.smooth) { revealed = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completion
|
||||||
|
|
||||||
|
private var completionView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 56)).foregroundStyle(.green)
|
||||||
|
Text(completionTitle).font(.title2.bold())
|
||||||
|
Text(completionDetail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button { studyAgain() } label: {
|
||||||
|
Label("Next Set", systemImage: "arrow.right")
|
||||||
|
.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 completionTitle: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
return learned > 0 ? "Session Complete" : "Nothing Available"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var completionDetail: String {
|
||||||
|
let learned = session?.learnedCount ?? 0
|
||||||
|
if learned > 0 {
|
||||||
|
return "\(learned) noun\(learned == 1 ? "" : "s") learned"
|
||||||
|
}
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
|
if progress.selectedLexemeLevels.isEmpty {
|
||||||
|
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
|
||||||
|
}
|
||||||
|
return "No nouns available at the enabled levels right now."
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session lifecycle
|
||||||
|
|
||||||
|
private func loadIfNeeded() {
|
||||||
|
guard session == nil else { return }
|
||||||
|
let store = LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||||
|
let descriptor = FetchDescriptor<Lexeme>(
|
||||||
|
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||||
|
)
|
||||||
|
let all = (try? localContext.fetch(descriptor)) ?? []
|
||||||
|
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistGroup() {
|
||||||
|
guard let session else { return }
|
||||||
|
let store = LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
)
|
||||||
|
if session.isComplete {
|
||||||
|
store.clear()
|
||||||
|
} else {
|
||||||
|
let entries = session.snapshot().map {
|
||||||
|
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
|
||||||
|
}
|
||||||
|
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func studyAgain() {
|
||||||
|
LexemeStudyGroupStore(
|
||||||
|
context: cloudContext,
|
||||||
|
partOfSpeech: "noun",
|
||||||
|
drillMode: Self.drillMode
|
||||||
|
).clear()
|
||||||
|
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)
|
||||||
|
revealed = false
|
||||||
|
persistGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,11 +72,30 @@ struct SettingsView: View {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Levels")
|
Text("Verb Levels")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(LexemeLevel.allCases, id: \.self) { level in
|
||||||
|
Toggle(level.displayName, isOn: Binding(
|
||||||
|
get: {
|
||||||
|
progress?.selectedLexemeLevels.contains(level) ?? false
|
||||||
|
},
|
||||||
|
set: { enabled in
|
||||||
|
guard let progress else { return }
|
||||||
|
progress.setLexemeLevelEnabled(level, enabled: enabled)
|
||||||
|
saveProgress()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Vocabulary Levels")
|
||||||
|
} footer: {
|
||||||
|
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(TenseInfo.all) { tense in
|
ForEach(TenseInfo.all) { tense in
|
||||||
Toggle(tense.english, isOn: Binding(
|
Toggle(tense.english, isOn: Binding(
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -60,10 +60,15 @@ For EACH word, produce one entry:
|
|||||||
dictionary sense.
|
dictionary sense.
|
||||||
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
|
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
|
||||||
conjunction, article, interjection, numeral, proper noun, other.
|
conjunction, article, interjection, numeral, proper noun, other.
|
||||||
|
- gender: ONLY for `partOfSpeech == "noun"`. "m" for masculine, "f" for
|
||||||
|
feminine, "m/f" for nouns that take either article (estudiante, artista).
|
||||||
|
OMIT the field entirely (or use null) for non-nouns and for cases where the
|
||||||
|
gender is genuinely unknowable from context. Don't guess for non-nouns.
|
||||||
|
|
||||||
Write the output file as JSON with this exact shape:
|
Write the output file as JSON with this exact shape:
|
||||||
{{"jobId": "<the jobId from the input>", "entries": [
|
{{"jobId": "<the jobId from the input>", "entries": [
|
||||||
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
|
{{"word": "...", "baseForm": "...", "english": "...",
|
||||||
|
"partOfSpeech": "...", "gender": "m"}}
|
||||||
]}}
|
]}}
|
||||||
|
|
||||||
`entries` MUST contain exactly one object per input word, cover every word, and
|
`entries` MUST contain exactly one object per input word, cover every word, and
|
||||||
|
|||||||
@@ -109,11 +109,15 @@ def main() -> None:
|
|||||||
word = (entry.get("word") or "").strip()
|
word = (entry.get("word") or "").strip()
|
||||||
if not word:
|
if not word:
|
||||||
continue
|
continue
|
||||||
glossary[word] = {
|
gloss_entry: dict = {
|
||||||
"baseForm": entry.get("baseForm") or word,
|
"baseForm": entry.get("baseForm") or word,
|
||||||
"english": entry.get("english") or "",
|
"english": entry.get("english") or "",
|
||||||
"partOfSpeech": entry.get("partOfSpeech") or "",
|
"partOfSpeech": entry.get("partOfSpeech") or "",
|
||||||
}
|
}
|
||||||
|
gender = entry.get("gender")
|
||||||
|
if isinstance(gender, str) and gender.strip():
|
||||||
|
gloss_entry["gender"] = gender.strip()
|
||||||
|
glossary[word] = gloss_entry
|
||||||
if glossary_missing:
|
if glossary_missing:
|
||||||
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
|
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
|
||||||
if args.require_all:
|
if args.require_all:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.cache/
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Vocab catalog build
|
||||||
|
|
||||||
|
`build_lexemes.py` produces `Conjuga/vocab_lexemes.json`, the bundled catalog
|
||||||
|
of frequency-ranked Spanish nouns and adjectives that powers the Noun /
|
||||||
|
Adjective flashcard study modes.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 build_lexemes.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Downloads `frequency.csv` + `es-en.data` from a pinned commit of
|
||||||
|
[`doozan/spanish_data`](https://github.com/doozan/spanish_data), caches them
|
||||||
|
under `.cache/<commit>/`, joins them, and writes the JSON. Re-running is
|
||||||
|
fast — only the join step happens after the first download.
|
||||||
|
|
||||||
|
Override defaults:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 build_lexemes.py --max-nouns 3000 --max-adjectives 1000
|
||||||
|
python3 build_lexemes.py --output /tmp/vocab.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data sources & attribution
|
||||||
|
|
||||||
|
All datasets are CC-licensed; the bundled catalog inherits CC-BY-SA. Credit
|
||||||
|
in the app's About screen must read:
|
||||||
|
|
||||||
|
> Vocabulary data: Wiktionary (CC-BY-SA), OpenSubtitles via FrequencyWords
|
||||||
|
> (CC-BY-SA 3.0).
|
||||||
|
|
||||||
|
- **`frequency.csv`** — derived from
|
||||||
|
[hermitdave/FrequencyWords](https://github.com/hermitdave/FrequencyWords)
|
||||||
|
(OpenSubtitles corpus), packaged by doozan. License: CC-BY-SA 3.0.
|
||||||
|
- **`es-en.data`** — Spanish→English Wiktionary export in the
|
||||||
|
[`enwiktionary_wordlist`](https://github.com/doozan/enwiktionary_wordlist)
|
||||||
|
format. License: CC-BY-SA.
|
||||||
|
|
||||||
|
The pinned doozan commit is at the top of `build_lexemes.py`
|
||||||
|
(`DOOZAN_COMMIT`). Bump it to refresh; the cache key includes the commit so
|
||||||
|
old data is auto-replaced.
|
||||||
|
|
||||||
|
## Output shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"baseForm": "casa",
|
||||||
|
"english": "house",
|
||||||
|
"partOfSpeech": "noun",
|
||||||
|
"gender": "f",
|
||||||
|
"frequencyRank": 142,
|
||||||
|
"exampleES": "La casa es grande",
|
||||||
|
"exampleEN": "The house is big"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Sorted by `frequencyRank` ascending so the fresh-card path in `LexemePool`
|
||||||
|
surfaces the most useful words first.
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build Conjuga/vocab_lexemes.json from doozan/spanish_data.
|
||||||
|
|
||||||
|
Joins doozan's frequency.csv (CC-BY-SA 3.0, OpenSubtitles via FrequencyWords)
|
||||||
|
with es-en.data (CC-BY-SA, Wiktionary) into a single bundled JSON catalog of
|
||||||
|
the highest-frequency Spanish nouns and adjectives — each row carries the
|
||||||
|
lemma, English gloss, gender (for nouns), frequency rank, and an example
|
||||||
|
sentence with translation when Wiktionary has one.
|
||||||
|
|
||||||
|
The app's DataLoader.seedLexemesFromCatalog reads this file at startup to
|
||||||
|
populate the Lexeme table that powers Noun / Adjective flashcard study.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 build_lexemes.py [--max-nouns N] [--max-adjectives N]
|
||||||
|
[--output PATH] [--cache-dir PATH]
|
||||||
|
|
||||||
|
Pinned doozan commit: aeac698949e7b27112056ee8d72f70f853cd1ef9 (2026-05-01)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DOOZAN_COMMIT = "aeac698949e7b27112056ee8d72f70f853cd1ef9"
|
||||||
|
BASE_URL = f"https://raw.githubusercontent.com/doozan/spanish_data/{DOOZAN_COMMIT}"
|
||||||
|
|
||||||
|
FILES = {
|
||||||
|
"frequency.csv": f"{BASE_URL}/frequency.csv",
|
||||||
|
"es-en.data": f"{BASE_URL}/es-en.data",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both frequency.csv and es-en.data use short POS codes (`n`, `adj`); we keep
|
||||||
|
# the same codes for the join. The output JSON uses the longer names the
|
||||||
|
# app's Lexeme model expects.
|
||||||
|
JOIN_POS = {"n", "adj"}
|
||||||
|
OUTPUT_POS = {"n": "noun", "adj": "adjective"}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(name: str, url: str, cache_dir: Path) -> Path:
|
||||||
|
"""Download once; reuse local cache on subsequent runs."""
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out = cache_dir / name
|
||||||
|
if out.exists() and out.stat().st_size > 0:
|
||||||
|
return out
|
||||||
|
print(f" downloading {name} ({url}) ...", file=sys.stderr)
|
||||||
|
with urllib.request.urlopen(url) as resp, open(out, "wb") as fh:
|
||||||
|
fh.write(resp.read())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_frequency(path: Path, *, keep_pos: set[str]) -> list[dict]:
|
||||||
|
"""Read frequency.csv → list of {lemma, pos, rank} for the POSes we care
|
||||||
|
about. Rank is the row index (1-based), which matches frequency-descending
|
||||||
|
order in the source file."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
for i, row in enumerate(reader):
|
||||||
|
pos = (row.get("pos") or "").strip()
|
||||||
|
if pos not in keep_pos:
|
||||||
|
continue
|
||||||
|
flags = (row.get("flags") or "").strip()
|
||||||
|
if "DUPLICATE" in flags or "NOUSAGE" in flags:
|
||||||
|
continue
|
||||||
|
lemma = (row.get("spanish") or "").strip()
|
||||||
|
if not lemma:
|
||||||
|
continue
|
||||||
|
rows.append({"lemma": lemma, "pos": pos, "rank": i + 1})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def load_es_en(path: Path) -> dict[tuple[str, str], dict]:
|
||||||
|
"""Parse es-en.data → {(lemma, pos): {gender, english, exampleES, exampleEN}}.
|
||||||
|
|
||||||
|
A single `_____`-delimited block can hold multiple `pos:` sub-entries
|
||||||
|
for the same lemma (e.g. `rojo` is both an adjective ("red") and a
|
||||||
|
masculine noun ("a red one"); `mano` has two noun senses with different
|
||||||
|
genders). We commit each sub-entry when we see the next `pos:` line, so
|
||||||
|
`(lemma, pos)` pairs don't get clobbered by later same-block sub-entries.
|
||||||
|
First-sense-wins on duplicate keys, which aligns with Wiktionary listing
|
||||||
|
the most-common meaning first.
|
||||||
|
"""
|
||||||
|
entries: dict[tuple[str, str], dict] = {}
|
||||||
|
lemma = pos = gender = english = ex_es = ex_en = None
|
||||||
|
next_is_lemma = False
|
||||||
|
|
||||||
|
def commit_subentry() -> None:
|
||||||
|
nonlocal pos, gender, english, ex_es, ex_en
|
||||||
|
if lemma and pos and english:
|
||||||
|
key = (lemma, pos)
|
||||||
|
if key not in entries:
|
||||||
|
entries[key] = {
|
||||||
|
"gender": gender,
|
||||||
|
"english": english,
|
||||||
|
"exampleES": ex_es,
|
||||||
|
"exampleEN": ex_en,
|
||||||
|
}
|
||||||
|
pos = gender = english = ex_es = ex_en = None
|
||||||
|
|
||||||
|
def reset_entry() -> None:
|
||||||
|
nonlocal lemma
|
||||||
|
commit_subentry()
|
||||||
|
lemma = None
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for raw in fh:
|
||||||
|
line = raw.rstrip("\n")
|
||||||
|
stripped = line.lstrip()
|
||||||
|
if stripped == "_____":
|
||||||
|
reset_entry()
|
||||||
|
next_is_lemma = True
|
||||||
|
continue
|
||||||
|
if next_is_lemma:
|
||||||
|
lemma = stripped
|
||||||
|
next_is_lemma = False
|
||||||
|
continue
|
||||||
|
if stripped.startswith("pos: "):
|
||||||
|
# Starting a new sub-entry for the current lemma; commit the
|
||||||
|
# previous sub-entry's state before resetting.
|
||||||
|
commit_subentry()
|
||||||
|
pos = stripped[5:].strip()
|
||||||
|
elif stripped.startswith("g: "):
|
||||||
|
gender = stripped[3:].strip()
|
||||||
|
elif stripped.startswith("gloss: "):
|
||||||
|
if english is None:
|
||||||
|
english = stripped[7:].strip()
|
||||||
|
elif stripped.startswith("ex: "):
|
||||||
|
if ex_es is None:
|
||||||
|
ex_es = stripped[4:].strip()
|
||||||
|
elif stripped.startswith("eng: "):
|
||||||
|
if ex_en is None:
|
||||||
|
ex_en = stripped[5:].strip()
|
||||||
|
reset_entry()
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_gender(g: str | None) -> str | None:
|
||||||
|
"""Reduce Wiktionary gender codes to {m, f, m/f, None}.
|
||||||
|
|
||||||
|
`mp` (masculine plural) / `fp` (feminine plural) are inherently-plural
|
||||||
|
nouns (gafas, pantalones); they don't fit the singular el/la drill cleanly
|
||||||
|
in v1, so we drop them here and the entry is filtered out upstream.
|
||||||
|
"""
|
||||||
|
if not g:
|
||||||
|
return None
|
||||||
|
g = g.strip()
|
||||||
|
if g in ("m", "f"):
|
||||||
|
return g
|
||||||
|
if g in ("mf", "m/f", "m, f", "f, m"):
|
||||||
|
return "m/f"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build(args) -> None:
|
||||||
|
cache = Path(args.cache_dir).expanduser()
|
||||||
|
paths = {name: fetch(name, url, cache) for name, url in FILES.items()}
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Reading frequency.csv (top {args.max_nouns} nouns, "
|
||||||
|
f"top {args.max_adjectives} adjectives) ...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
rows = load_frequency(paths["frequency.csv"], keep_pos=JOIN_POS)
|
||||||
|
nouns = [r for r in rows if r["pos"] == "n"][: args.max_nouns]
|
||||||
|
adjs = [r for r in rows if r["pos"] == "adj"][: args.max_adjectives]
|
||||||
|
print(f" candidates: {len(nouns)} nouns, {len(adjs)} adjectives", file=sys.stderr)
|
||||||
|
|
||||||
|
print("Parsing es-en.data ...", file=sys.stderr)
|
||||||
|
es_en = load_es_en(paths["es-en.data"])
|
||||||
|
print(f" {len(es_en)} (lemma, pos) entries", file=sys.stderr)
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
skipped_no_entry = 0
|
||||||
|
skipped_no_english = 0
|
||||||
|
skipped_no_gender = 0
|
||||||
|
for source_rows in (nouns, adjs):
|
||||||
|
for r in source_rows:
|
||||||
|
short_pos = r["pos"]
|
||||||
|
output_pos = OUTPUT_POS[short_pos]
|
||||||
|
entry = es_en.get((r["lemma"], short_pos))
|
||||||
|
if not entry:
|
||||||
|
skipped_no_entry += 1
|
||||||
|
continue
|
||||||
|
english = entry.get("english")
|
||||||
|
if not english:
|
||||||
|
skipped_no_english += 1
|
||||||
|
continue
|
||||||
|
gender = normalize_gender(entry.get("gender")) if short_pos == "n" else None
|
||||||
|
if short_pos == "n" and gender is None:
|
||||||
|
# Drill needs gender; if Wiktionary doesn't have it, skip.
|
||||||
|
skipped_no_gender += 1
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"baseForm": r["lemma"],
|
||||||
|
"english": english,
|
||||||
|
"partOfSpeech": output_pos,
|
||||||
|
"gender": gender,
|
||||||
|
"frequencyRank": r["rank"],
|
||||||
|
"exampleES": entry.get("exampleES"),
|
||||||
|
"exampleEN": entry.get("exampleEN"),
|
||||||
|
})
|
||||||
|
|
||||||
|
out.sort(key=lambda e: e["frequencyRank"])
|
||||||
|
|
||||||
|
out_path = Path(args.output).expanduser()
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(out_path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(out, fh, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
fh.write("\n")
|
||||||
|
|
||||||
|
noun_count = sum(1 for e in out if e["partOfSpeech"] == "noun")
|
||||||
|
adj_count = sum(1 for e in out if e["partOfSpeech"] == "adjective")
|
||||||
|
print(
|
||||||
|
f"Wrote {out_path} — {noun_count} nouns, {adj_count} adjectives "
|
||||||
|
f"({len(out)} total, {out_path.stat().st_size:,} bytes)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" skipped: no es-en entry={skipped_no_entry}, "
|
||||||
|
f"no english={skipped_no_english}, "
|
||||||
|
f"no gender={skipped_no_gender}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
parser.add_argument("--max-nouns", type=int, default=1500)
|
||||||
|
parser.add_argument("--max-adjectives", type=int, default=600)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=str(here / ".." / ".." / "Conjuga" / "vocab_lexemes.json"),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cache-dir",
|
||||||
|
default=str(here / ".cache" / DOOZAN_COMMIT[:8]),
|
||||||
|
)
|
||||||
|
build(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -44,15 +44,24 @@ public final class Book {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One glossary entry: a word's dictionary base form, English meaning, and
|
/// One glossary entry: a word's dictionary base form, English meaning, and
|
||||||
/// part of speech, translated in the book's context at import time.
|
/// part of speech, translated in the book's context at import time. `gender`
|
||||||
|
/// is populated by the glossary pipeline for nouns ("m"/"f"/"m/f"); nil for
|
||||||
|
/// non-nouns or when the pipeline hasn't been re-run yet.
|
||||||
public struct WordGloss: Codable, Hashable, Sendable {
|
public struct WordGloss: Codable, Hashable, Sendable {
|
||||||
public let baseForm: String
|
public let baseForm: String
|
||||||
public let english: String
|
public let english: String
|
||||||
public let partOfSpeech: String
|
public let partOfSpeech: String
|
||||||
|
public let gender: String?
|
||||||
|
|
||||||
public init(baseForm: String, english: String, partOfSpeech: String) {
|
public init(
|
||||||
|
baseForm: String,
|
||||||
|
english: String,
|
||||||
|
partOfSpeech: String,
|
||||||
|
gender: String? = nil
|
||||||
|
) {
|
||||||
self.baseForm = baseForm
|
self.baseForm = baseForm
|
||||||
self.english = english
|
self.english = english
|
||||||
self.partOfSpeech = partOfSpeech
|
self.partOfSpeech = partOfSpeech
|
||||||
|
self.gender = gender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// A non-verb vocabulary item harvested from the books pipeline's per-book
|
||||||
|
/// glossary. Verbs keep their own richer `Verb` model — `Lexeme` covers
|
||||||
|
/// nouns, adjectives, etc. so the flashcard study modes can drill the grammar
|
||||||
|
/// that's specific to each part of speech.
|
||||||
|
///
|
||||||
|
/// Identity is `"<sourceBookSlug>:<partOfSpeech>:<baseForm>"`; the seeder
|
||||||
|
/// dedupes on `(partOfSpeech, baseForm)` across books and keeps the first-
|
||||||
|
/// seen source. Lives in the LOCAL reference-data store (same place as
|
||||||
|
/// `Book`/`BookChapter`), not the cloud container.
|
||||||
|
@Model
|
||||||
|
public final class Lexeme {
|
||||||
|
@Attribute(.unique) public var id: String = ""
|
||||||
|
public var partOfSpeech: String = ""
|
||||||
|
public var baseForm: String = ""
|
||||||
|
public var english: String = ""
|
||||||
|
/// For nouns: "m", "f", or "m/f". Nil for non-nouns or when unknown.
|
||||||
|
/// The curated catalog (`vocab_lexemes.json` from doozan/spanish_data)
|
||||||
|
/// emits Wiktionary-sourced gender; `Lexeme.inferGender` provides a
|
||||||
|
/// morphology fallback if a different seeder ever lands a noun without
|
||||||
|
/// one.
|
||||||
|
public var gender: String? = nil
|
||||||
|
/// Source tag — `"catalog"` for entries from `vocab_lexemes.json`, or a
|
||||||
|
/// book slug for legacy book-glossary-derived entries. Used to keep
|
||||||
|
/// catalog refreshes from wiping book-personal additions later.
|
||||||
|
public var sourceBookSlug: String = ""
|
||||||
|
/// 1-based rank in the source frequency list (lower = more common).
|
||||||
|
/// 0 means unknown/unranked. `LexemePool` sorts fresh cards by this so
|
||||||
|
/// the most-useful words surface first.
|
||||||
|
public var frequencyRank: Int = 0
|
||||||
|
/// Optional example sentence pair, shown below the answer in Recall
|
||||||
|
/// mode. Sourced from Wiktionary's `ex:`/`eng:` lines when available.
|
||||||
|
public var exampleES: String? = nil
|
||||||
|
public var exampleEN: String? = nil
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
partOfSpeech: String,
|
||||||
|
baseForm: String,
|
||||||
|
english: String,
|
||||||
|
gender: String? = nil,
|
||||||
|
sourceBookSlug: String = "",
|
||||||
|
frequencyRank: Int = 0,
|
||||||
|
exampleES: String? = nil,
|
||||||
|
exampleEN: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.partOfSpeech = partOfSpeech
|
||||||
|
self.baseForm = baseForm
|
||||||
|
self.english = english
|
||||||
|
self.gender = gender
|
||||||
|
self.sourceBookSlug = sourceBookSlug
|
||||||
|
self.frequencyRank = frequencyRank
|
||||||
|
self.exampleES = exampleES
|
||||||
|
self.exampleEN = exampleEN
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func makeID(sourceBookSlug: String, partOfSpeech: String, baseForm: String) -> String {
|
||||||
|
"\(sourceBookSlug):\(partOfSpeech):\(baseForm)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort gender from Spanish morphology. Used as a fallback when
|
||||||
|
/// the glossary pipeline hasn't emitted a `gender` field yet. Conservative:
|
||||||
|
/// returns nil for ambiguous endings rather than guessing wrong.
|
||||||
|
///
|
||||||
|
/// - `-ción/-sión/-dad/-tad/-tud/-umbre/-ez/-anza` → feminine
|
||||||
|
/// - `-aje/-or` → masculine
|
||||||
|
/// - `-ma/-pa/-ta` → nil (Greek-origin masculines mix with regular -a feminines)
|
||||||
|
/// - `-a` (other) → feminine
|
||||||
|
/// - `-o` → masculine
|
||||||
|
/// - everything else → nil
|
||||||
|
public static func inferGender(forBaseForm baseForm: String) -> String? {
|
||||||
|
let s = baseForm.lowercased()
|
||||||
|
if s.hasSuffix("ción") || s.hasSuffix("sión") || s.hasSuffix("dad") ||
|
||||||
|
s.hasSuffix("tad") || s.hasSuffix("tud") || s.hasSuffix("umbre") ||
|
||||||
|
s.hasSuffix("ez") || s.hasSuffix("anza") {
|
||||||
|
return "f"
|
||||||
|
}
|
||||||
|
if s.hasSuffix("aje") || s.hasSuffix("or") {
|
||||||
|
return "m"
|
||||||
|
}
|
||||||
|
if s.hasSuffix("ma") || s.hasSuffix("pa") || s.hasSuffix("ta") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.hasSuffix("a") { return "f" }
|
||||||
|
if s.hasSuffix("o") { return "m" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CEFR-style level for a `Lexeme`, derived from its `frequencyRank`. Lets
|
||||||
|
/// users gate noun/adjective flashcard sessions by level via a Settings
|
||||||
|
/// toggle. Cutoffs follow the standard Spanish-frequency-dictionary
|
||||||
|
/// convention (Davies; RAE CEFR-aligned lists).
|
||||||
|
///
|
||||||
|
/// Note: SRS is *not* level-gated. Disabling a level only stops *new*
|
||||||
|
/// cards from that band entering the session pool — already-studied cards
|
||||||
|
/// keep coming back on their SM-2 schedule regardless. See
|
||||||
|
/// `LexemePool.sessionLexemes` for where the filter is applied.
|
||||||
|
public enum LexemeLevel: String, Codable, Hashable, CaseIterable, Sendable {
|
||||||
|
case a1, a2, b1, b2, c1
|
||||||
|
|
||||||
|
/// 1-based frequency rank range. `c1` is open-ended on the high end so
|
||||||
|
/// any far-tail entry has a level even if the catalog later expands.
|
||||||
|
public var rankRange: ClosedRange<Int> {
|
||||||
|
switch self {
|
||||||
|
case .a1: return 1...250
|
||||||
|
case .a2: return 251...500
|
||||||
|
case .b1: return 501...1000
|
||||||
|
case .b2: return 1001...2000
|
||||||
|
case .c1: return 2001...Int.max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .a1: return "A1 — Beginner"
|
||||||
|
case .a2: return "A2 — Elementary"
|
||||||
|
case .b1: return "B1 — Intermediate"
|
||||||
|
case .b2: return "B2 — Upper-intermediate"
|
||||||
|
case .c1: return "C1+ — Advanced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The level containing this frequency rank. Rank 0 (unranked) falls
|
||||||
|
/// into `c1` — better to include unknown-rank lexemes when only the
|
||||||
|
/// top end is on than silently drop them.
|
||||||
|
public static func level(forRank rank: Int) -> LexemeLevel {
|
||||||
|
guard rank > 0 else { return .c1 }
|
||||||
|
for level in LexemeLevel.allCases where level.rankRange.contains(rank) {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
return .c1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ public enum SharedStore {
|
|||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
TextbookChapter.self, DownloadedVideo.self,
|
TextbookChapter.self, DownloadedVideo.self,
|
||||||
Book.self, BookChapter.self,
|
Book.self, BookChapter.self,
|
||||||
|
Lexeme.self,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: Conjuga/textbook_vocab.json
|
- path: Conjuga/textbook_vocab.json
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: Conjuga/book_olly-vol2.json
|
||||||
|
buildPhase: resources
|
||||||
|
- path: Conjuga/vocab_lexemes.json
|
||||||
|
buildPhase: resources
|
||||||
info:
|
info:
|
||||||
path: Conjuga/Info.plist
|
path: Conjuga/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
Reference in New Issue
Block a user