Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac84b22977 | |||
| 3ee1563cb0 | |||
| d0582c4ce7 | |||
| 26ce662c60 | |||
| dd08a09860 | |||
| c794c013f0 | |||
| eec0fb56d5 | |||
| 209602eaad | |||
| cee962c0e0 | |||
| f14008f96f | |||
| 5c0fc8ee2d | |||
| d61f9e50b1 | |||
| 900a927f95 | |||
| f0eb75a28a | |||
| f4c139aed0 | |||
| 0af8e648fe | |||
| c890095610 | |||
| 164a0a1bb7 | |||
| d49eb38a6d | |||
| 0b7d4a73ad | |||
| 9aa4d0836d | |||
| 5db4b014a9 | |||
| de446b2301 | |||
| a416233a2d | |||
| 70d8299df8 | |||
| 51067e23fd | |||
| 05a367fdbe | |||
| 09e49bda2c | |||
| ade091f108 | |||
| 05a0cc0d17 | |||
| f368c24ad6 | |||
| 90aea92fba | |||
| dce2cc1f51 | |||
| 06b47d37cf | |||
| f993bfbb96 | |||
| fcb907718a | |||
| 9c7033d1b4 | |||
| 0a099c3fc9 | |||
| 57f945a4d3 | |||
| 5777a210cd | |||
| 98badc98ad | |||
| 4093b5a7f3 |
@@ -50,6 +50,7 @@ epub_extract/
|
||||
# Scripts are committed; their generated outputs are not.
|
||||
Conjuga/Scripts/textbook/*.json
|
||||
Conjuga/Scripts/textbook/review.html
|
||||
Conjuga/Scripts/textbook/paired_vocab_llm/
|
||||
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
|
||||
# ARE committed so `xcodebuild` works on a fresh clone without first running
|
||||
# the pipeline. They're regenerated from the scripts when content changes.
|
||||
|
||||
@@ -8,83 +8,103 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
|
||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */; };
|
||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */; };
|
||||
12117B0704F799292AF1DBB6 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */; };
|
||||
13A649C50A2B72662F92F0BD /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */; };
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */; };
|
||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */; };
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */; };
|
||||
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */; };
|
||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
|
||||
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
|
||||
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
|
||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10C16AA6022E4742898745CE /* TypingView.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 */; };
|
||||
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 */; };
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
|
||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */ = {isa = PBXBuildFile; productRef = 08D6313690BEE4E2F18EADC3 /* YouTubeKit */; };
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */; };
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
|
||||
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D974250C396589656B8443 /* HandwritingCanvas.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 */; };
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
|
||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
|
||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.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 */; };
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */; };
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AC27B1E3D332709657F20B /* StoryLibraryView.swift */; };
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
|
||||
64E08FBC4B188B332F8039FD /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDD4AF96186662567525F8C4 /* BookReaderView.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 */; };
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
|
||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.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 */; };
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8D95887B18216FCA71643D6 /* VocabReviewView.swift */; };
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */; };
|
||||
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */; };
|
||||
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */; };
|
||||
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */; };
|
||||
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */; };
|
||||
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */; };
|
||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */; };
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
|
||||
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */; };
|
||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */; };
|
||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BE2A08EC694FF784ED5575 /* DictionaryService.swift */; };
|
||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */; };
|
||||
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */; };
|
||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */ = {isa = PBXBuildFile; fileRef = 3644B5ED77F29A65877D926A /* reflexive_verbs.json */; };
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */ = {isa = PBXBuildFile; fileRef = A6EC7C278E4287D91A0DB355 /* youtube_videos.md */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
|
||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 3540936F058728CFD87B1A1E /* textbook_vocab.json */; };
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||
AE156A84B4ECDB1A4A38CE88 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */; };
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */; };
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39908548430FDF01D76201FB /* TextbookChapterView.swift */; };
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */; };
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */; };
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
|
||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.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 */; };
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||
@@ -92,20 +112,20 @@
|
||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
|
||||
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 */; };
|
||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6153A5C7241C1AB0373AA17 /* Foundation.framework */; };
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
|
||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
||||
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 539736EB2AB8D149ED0F9C39 /* textbook_data.json */; };
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */; };
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -116,13 +136,6 @@
|
||||
remoteGlobalIDString = F73909B4044081DB8F6272AF;
|
||||
remoteInfo = ConjugaWidgetExtension;
|
||||
};
|
||||
6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AB7396D9C3E14B65B5238368 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 96127FACA68AE541F5C0F8BC;
|
||||
remoteInfo = Conjuga;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -140,33 +153,40 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.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>"; };
|
||||
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.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>"; };
|
||||
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>"; };
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConjugaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.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>"; };
|
||||
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>"; };
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
||||
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
||||
@@ -175,9 +195,13 @@
|
||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
||||
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.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>"; };
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; 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>"; };
|
||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||
@@ -185,65 +209,72 @@
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VocabGridTests.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.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>"; };
|
||||
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
||||
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
|
||||
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookExerciseView.swift; sourceTree = "<group>"; };
|
||||
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerChecker.swift; sourceTree = "<group>"; };
|
||||
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; 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>"; };
|
||||
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>"; };
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AllChaptersScreenshotTests.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>"; };
|
||||
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>"; };
|
||||
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
||||
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeToggleTests.swift; sourceTree = "<group>"; };
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.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; };
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; 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>"; };
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||
A6153A5C7241C1AB0373AA17 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextbookFlowUITests.swift; sourceTree = "<group>"; };
|
||||
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
|
||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.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>"; };
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.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>"; };
|
||||
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>"; };
|
||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.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>"; };
|
||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -260,14 +291,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E82C743EB1FDF6B67ED22EAD /* Foundation.framework in Frameworks */,
|
||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -282,14 +306,17 @@
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||
7A1B2C3D4E5F60718293A4C6 /* textbook_data.json */,
|
||||
7A1B2C3D4E5F60718293A4C7 /* textbook_vocab.json */,
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
|
||||
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
|
||||
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
|
||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||
1994867BC8E985795A172854 /* Services */,
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||
A81CA75762B08D35D5B7A44D /* Views */,
|
||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
|
||||
);
|
||||
path = Conjuga;
|
||||
sourceTree = "<group>";
|
||||
@@ -297,8 +324,9 @@
|
||||
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
|
||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -316,29 +344,48 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
7A1B2C3D4E5F60718293AA14 /* AnswerChecker.swift */,
|
||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
|
||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */,
|
||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */,
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */,
|
||||
FD64713BB475DCF3C2D30621 /* VocabSessionQueue.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1ECAF79E2138DF73BB1F6403 /* Vocab */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
|
||||
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
|
||||
);
|
||||
name = Vocab;
|
||||
path = Vocab;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -351,6 +398,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||
626873572466403C0288090D /* QuizType.swift */,
|
||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||
@@ -358,7 +406,8 @@
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -384,6 +433,16 @@
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
43E4D263B0AF47E401A51601 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
|
||||
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
|
||||
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -404,30 +463,45 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||
DFD75E32A53845A693D98F48 /* Chat */,
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||
43E4D263B0AF47E401A51601 /* Stories */,
|
||||
74AC8A0D381958D2A14316C3 /* Books */,
|
||||
1ECAF79E2138DF73BB1F6403 /* Vocab */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
74AC8A0D381958D2A14316C3 /* Books */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
|
||||
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
|
||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
|
||||
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
|
||||
);
|
||||
name = Books;
|
||||
path = Books;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
|
||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
|
||||
);
|
||||
path = Guide;
|
||||
sourceTree = "<group>";
|
||||
@@ -443,14 +517,13 @@
|
||||
path = Lyrics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
|
||||
79576893566932D2BE207528 /* ChatView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A591A3B6F1F13D23D68D7A9D = {
|
||||
@@ -460,8 +533,6 @@
|
||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */,
|
||||
F7D740BB7D1E23949D4C1AE5 /* Packages */,
|
||||
F605D24E5EA11065FD18AF7E /* Products */,
|
||||
B442229C0A26C1D531472C7D /* Frameworks */,
|
||||
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -481,14 +552,6 @@
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B442229C0A26C1D531472C7D /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E772BA9C3FF67FEA9A034B4B /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BA34B77A38B698101DBBE241 /* Dashboard */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -501,62 +564,26 @@
|
||||
BE5A40BAC9DD6884C58A2096 /* Course */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
|
||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||
7A1B2C3D4E5F60718293AA11 /* TextbookChapterListView.swift */,
|
||||
7A1B2C3D4E5F60718293AA12 /* TextbookChapterView.swift */,
|
||||
7A1B2C3D4E5F60718293AA13 /* TextbookExerciseView.swift */,
|
||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||
CF3A181BF2399D34C23DA933 /* StemChangeConjugationView.swift */,
|
||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C77B065CF67D1F5128E10CC7 /* ConjugaUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CEEA84E15880A9D56DE18F33 /* TextbookFlowUITests.swift */,
|
||||
8A630C74D28CE1B280C9F296 /* AllChaptersScreenshotTests.swift */,
|
||||
8F08E1DC6932D9EA1D380913 /* StemChangeToggleTests.swift */,
|
||||
6584E0FDA939E3B82EECA4B5 /* VocabGridTests.swift */,
|
||||
);
|
||||
name = ConjugaUITests;
|
||||
path = ConjugaUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD75E32A53845A693D98F48 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E772BA9C3FF67FEA9A034B4B /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A6153A5C7241C1AB0373AA17 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
16C1F74196C3C5628953BE3F /* Conjuga.app */,
|
||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */,
|
||||
27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -589,29 +616,12 @@
|
||||
name = Conjuga;
|
||||
packageProductDependencies = (
|
||||
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
|
||||
);
|
||||
productName = Conjuga;
|
||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */;
|
||||
buildPhases = (
|
||||
66589E8F78971725CA2066ED /* Sources */,
|
||||
C5C1BB325D49EE6ED3AC3D5F /* Frameworks */,
|
||||
425DC31DA6EF2C4C7A873DAA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
04C7E3C8079DE56024C2154E /* PBXTargetDependency */,
|
||||
);
|
||||
name = ConjugaUITests;
|
||||
productName = ConjugaUITests;
|
||||
productReference = 27B2A75AAF79A9402AAF3F57 /* ConjugaUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EA7E12CF28EB750C2B8BB2F1 /* Build configuration list for PBXNativeTarget "ConjugaWidgetExtension" */;
|
||||
@@ -651,7 +661,6 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@@ -661,6 +670,7 @@
|
||||
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
|
||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
@@ -670,19 +680,11 @@
|
||||
targets = (
|
||||
96127FACA68AE541F5C0F8BC /* Conjuga */,
|
||||
F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */,
|
||||
C6CC399BFD5A2574CB9956B4 /* ConjugaUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
425DC31DA6EF2C4C7A873DAA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B74A8384221C70A670B902D8 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -690,8 +692,12 @@
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
||||
7A1B2C3D4E5F60718293A4B5 /* textbook_data.json in Resources */,
|
||||
7A1B2C3D4E5F60718293A4B6 /* textbook_vocab.json in Resources */,
|
||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
||||
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
|
||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
|
||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -704,22 +710,29 @@
|
||||
files = (
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */,
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */,
|
||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */,
|
||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */,
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||
7A1B2C3D4E5F60718293AA01 /* TextbookChapterListView.swift in Sources */,
|
||||
7A1B2C3D4E5F60718293AA02 /* TextbookChapterView.swift in Sources */,
|
||||
7A1B2C3D4E5F60718293AA03 /* TextbookExerciseView.swift in Sources */,
|
||||
7A1B2C3D4E5F60718293AA04 /* AnswerChecker.swift in Sources */,
|
||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
|
||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
||||
@@ -727,6 +740,7 @@
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||
@@ -739,8 +753,10 @@
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||
@@ -748,39 +764,49 @@
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
||||
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
|
||||
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
|
||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
|
||||
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
|
||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
|
||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
|
||||
943728CD3E65FE6CCADB05EE /* StemChangeConjugationView.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;
|
||||
};
|
||||
@@ -798,26 +824,9 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
66589E8F78971725CA2066ED /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
96A3E5FA8EC63123D97365E1 /* TextbookFlowUITests.swift in Sources */,
|
||||
F7E459C46F25A8A45D7E0DFB /* AllChaptersScreenshotTests.swift in Sources */,
|
||||
1B0B3B2C771AD72E25B3493C /* StemChangeToggleTests.swift in Sources */,
|
||||
BC662C36AC503E00A977CEC1 /* VocabGridTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
04C7E3C8079DE56024C2154E /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
name = Conjuga;
|
||||
target = 96127FACA68AE541F5C0F8BC /* Conjuga */;
|
||||
targetProxy = 6E1F966015DA38BD4E3CE8AF /* PBXContainerItemProxy */;
|
||||
};
|
||||
0B370CF10B68E386093E5BB2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = F73909B4044081DB8F6272AF /* ConjugaWidgetExtension */;
|
||||
@@ -966,24 +975,6 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A923186E44A25A8086B27A34 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Conjuga;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B9223DC55BB69E9AB81B59AE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -1049,23 +1040,6 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
DB8C0F513F77A50F2EF2D561 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.conjuga.app.uitests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Conjuga;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -1096,15 +1070,6 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
F454EA7279A44C5E151F71BA /* Build configuration list for PBXNativeTarget "ConjugaUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A923186E44A25A8086B27A34 /* Release */,
|
||||
DB8C0F513F77A50F2EF2D561 /* Debug */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
@@ -1114,7 +1079,23 @@
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.3.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
|
||||
productName = YouTubeKit;
|
||||
};
|
||||
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SharedModels;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "youtubekit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
|
||||
"state" : {
|
||||
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
|
||||
"version" : "0.4.8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -53,16 +53,6 @@
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C77B065CF67D1F5128E10CC7"
|
||||
BuildableName = "ConjugaUITests.xctest"
|
||||
BlueprintName = "ConjugaUITests"
|
||||
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
|
||||
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
@State private var studyTimer = StudyTimerService()
|
||||
@State private var dictionary = DictionaryService()
|
||||
@State private var verbExampleCache = VerbExampleCache()
|
||||
@State private var reflexiveStore = ReflexiveVerbStore()
|
||||
@State private var youtubeVideoStore = YouTubeVideoStore()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -67,16 +70,16 @@ struct ConjugaApp: App {
|
||||
let cloudConfig = ModelConfiguration(
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
@@ -113,6 +116,9 @@ struct ConjugaApp: App {
|
||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||
.environment(studyTimer)
|
||||
.environment(dictionary)
|
||||
.environment(verbExampleCache)
|
||||
.environment(reflexiveStore)
|
||||
.environment(youtubeVideoStore)
|
||||
.task {
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
if needsSeed {
|
||||
@@ -135,6 +141,11 @@ struct ConjugaApp: App {
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
)
|
||||
// Reset a broken streak immediately on launch so the
|
||||
// dashboard never shows a stale number even if the user
|
||||
// hasn't navigated to it yet.
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
|
||||
progress.validateStreakIfStale(context: cloudContainer.mainContext)
|
||||
WidgetDataService.update(
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
@@ -206,22 +217,16 @@ struct ConjugaApp: App {
|
||||
}
|
||||
|
||||
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
||||
// Built from the single shared model list so the app and the widget
|
||||
// extension always open the store with an identical schema.
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let localConfig = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: url,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
return try ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
configurations: localConfig
|
||||
)
|
||||
return try ModelContainer(for: schema, configurations: localConfig)
|
||||
}
|
||||
|
||||
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
||||
@@ -248,7 +253,7 @@ struct ConjugaApp: App {
|
||||
/// Clears accumulated stale schema metadata from previous container configurations.
|
||||
/// Bump the version number to force another reset if the schema changes again.
|
||||
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
|
||||
let resetVersion = 3 // bump: SavedSong moved to cloud container
|
||||
let resetVersion = 5 // bump: Book/BookChapter added to local container
|
||||
let key = "localStoreResetVersion"
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -22,21 +26,13 @@
|
||||
<string>1</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.education</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -32,10 +32,21 @@ final class DailyLog {
|
||||
}
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
/// Defensive formatter: explicit POSIX locale + current timezone so date
|
||||
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
|
||||
/// or implicit-zone shifts. The string format is timezone-naive
|
||||
/// `yyyy-MM-dd`, which works because we only ever compare to other
|
||||
/// strings produced by this same formatter.
|
||||
private static func makeFormatter() -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
makeFormatter().string(from: date)
|
||||
}
|
||||
|
||||
static func todayString() -> String {
|
||||
@@ -43,8 +54,6 @@ final class DailyLog {
|
||||
}
|
||||
|
||||
static func date(from string: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: string)
|
||||
makeFormatter().date(from: string)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps tense guides ↔ grammar notes for the Guide tab's cross-link chips.
|
||||
/// The forward map is the curated source; the reverse map is derived once.
|
||||
///
|
||||
/// A tense ID appears here only if at least one grammar note in
|
||||
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
|
||||
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
|
||||
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
|
||||
/// `ind_preterito_anterior`.
|
||||
enum GuideCrossLinks {
|
||||
/// Tense ID → ordered grammar note IDs that go deeper on this tense.
|
||||
/// Order matters — the first chip is the most-relevant note for the
|
||||
/// tense's primary teaching point.
|
||||
static let relatedNotes: [String: [String]] = [
|
||||
"ind_presente": [
|
||||
"present-indicative-conjugation",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
"estar-gerund-progressive",
|
||||
],
|
||||
"ind_preterito": [
|
||||
"preterite-vs-imperfect",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"ind_imperfecto": [
|
||||
"preterite-vs-imperfect",
|
||||
],
|
||||
"ind_futuro": [
|
||||
"future-vs-ir-a",
|
||||
],
|
||||
"ind_perfecto": [
|
||||
"present-perfect-tense",
|
||||
],
|
||||
"ind_futuro_perfecto": [
|
||||
"future-perfect-tense",
|
||||
],
|
||||
"cond_presente": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"cond_perfecto": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_presente": [
|
||||
"subjunctive-triggers",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"subj_imperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_imperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_pluscuamperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_pluscuamperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_futuro": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_futuro_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"imp_afirmativo": [
|
||||
"commands-imperative",
|
||||
],
|
||||
"imp_negativo": [
|
||||
"commands-imperative",
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
]
|
||||
|
||||
/// Grammar note ID → tense IDs that point at this note, ordered by the
|
||||
/// shared `TenseInfo.order` so chips appear in canonical conjugation
|
||||
/// order.
|
||||
static let relatedTenses: [String: [String]] = {
|
||||
var inverse: [String: [String]] = [:]
|
||||
for (tenseId, noteIds) in relatedNotes {
|
||||
for noteId in noteIds {
|
||||
inverse[noteId, default: []].append(tenseId)
|
||||
}
|
||||
}
|
||||
for key in inverse.keys {
|
||||
inverse[key]?.sort { lhs, rhs in
|
||||
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
}()
|
||||
|
||||
static func noteIds(forTense tenseId: String) -> [String] {
|
||||
relatedNotes[tenseId] ?? []
|
||||
}
|
||||
|
||||
static func tenseIds(forNote noteId: String) -> [String] {
|
||||
relatedTenses[noteId] ?? []
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,30 @@ final class CourseReviewCard {
|
||||
self.back = back
|
||||
}
|
||||
}
|
||||
|
||||
/// SRS record for verb-level vocab practice (EN ↔ ES infinitive recall),
|
||||
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
|
||||
/// collide with conjugation form mastery.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||
@Model
|
||||
final class VerbReviewCard {
|
||||
var id: String = ""
|
||||
var verbId: Int = 0
|
||||
|
||||
var easeFactor: Double = 2.5
|
||||
var interval: Int = 0
|
||||
var repetitions: Int = 0
|
||||
var dueDate: Date = Date()
|
||||
var lastReviewDate: Date?
|
||||
|
||||
init(verbId: Int) {
|
||||
self.id = Self.makeId(verbId: verbId)
|
||||
self.verbId = verbId
|
||||
}
|
||||
|
||||
static func makeId(verbId: Int) -> String {
|
||||
"verb-\(verbId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ final class UserProgress {
|
||||
var selectedLevel: String = "basic"
|
||||
var showVosotros: Bool = true
|
||||
var autoFillStem: Bool = false
|
||||
var showReflexiveVerbsOnly: Bool = false
|
||||
|
||||
// Legacy CloudKit array-backed fields retained for migration compatibility.
|
||||
var enabledTenses: [String] = []
|
||||
@@ -122,6 +123,24 @@ final class UserProgress {
|
||||
unlockedBadgeIDs = values.sorted()
|
||||
}
|
||||
|
||||
/// Resets `currentStreak` to zero if more than one day has passed since
|
||||
/// the last recorded activity. Without this check the dashboard keeps
|
||||
/// displaying a stale streak number for days after the user actually
|
||||
/// stops practicing — the underlying counter only updates on the *next*
|
||||
/// practice action. Call from app launch and the dashboard's `.task`.
|
||||
@MainActor
|
||||
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
|
||||
guard !todayDate.isEmpty else { return }
|
||||
let todayString = DailyLog.dateString(from: today)
|
||||
if todayDate == todayString { return }
|
||||
guard let prevDate = DailyLog.date(from: todayDate) else { return }
|
||||
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
|
||||
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
|
||||
currentStreak = 0
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLegacyStorageIfNeeded() {
|
||||
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
|
||||
enabledTenseIDs = enabledTenses
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
|
||||
/// so the same group of verbs follows them across launches and across devices.
|
||||
/// A new group is created when the previous one is fully learned.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
|
||||
/// `@Attribute(.unique)`). There is one active standard group at a time.
|
||||
@Model
|
||||
final class VocabStudyGroup {
|
||||
var id: String = ""
|
||||
/// JSON-encoded `[StoredVocabEntry]` — the in-session queue (un-graduated
|
||||
/// verbs only) in order, each with its learning-step state.
|
||||
var entriesJSON: Data = Data()
|
||||
var learnedCount: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(entriesJSON: Data, learnedCount: Int) {
|
||||
self.id = Self.activeID
|
||||
self.entriesJSON = entriesJSON
|
||||
self.learnedCount = learnedCount
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
/// Single active standard-session group.
|
||||
static let activeID = "active-standard"
|
||||
|
||||
var entries: [StoredVocabEntry] {
|
||||
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// One verb's persisted spot in the study group.
|
||||
struct StoredVocabEntry: Codable {
|
||||
var verbId: Int
|
||||
/// Raw value of `VocabSessionQueue.CardState`.
|
||||
var state: String
|
||||
}
|
||||
|
||||
/// Fetch / persist / clear the active study group. Operates on the cloud
|
||||
/// context so the group syncs across devices.
|
||||
struct VocabStudyGroupStore {
|
||||
let context: ModelContext
|
||||
|
||||
/// The current active group, or nil. If duplicate records exist (two
|
||||
/// devices both created one before sync settled), the newest wins.
|
||||
func activeGroup() -> VocabStudyGroup? {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
/// Write the in-progress group, creating it if needed. If duplicate records
|
||||
/// exist (two devices created a group before sync settled), the newest is
|
||||
/// updated and the rest deleted so a single record survives.
|
||||
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
|
||||
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.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(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// Remove the active group (and any duplicates) — the set is finished.
|
||||
func clear() {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
|
||||
)
|
||||
for group in (try? context.fetch(descriptor)) ?? [] {
|
||||
context.delete(group)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Drives "read aloud" mode for `BookReaderView`. Wraps an
|
||||
/// `AVSpeechSynthesizer` with a queue of paragraph utterances and exposes the
|
||||
/// current paragraph/word index so the view can highlight the active word.
|
||||
///
|
||||
/// Skips vocabulary lines (`palabra = meaning`) since the synth pronounces the
|
||||
/// `=` awkwardly and the bilingual gloss is reference material, not prose.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var isReading: Bool = false
|
||||
private(set) var isPaused: Bool = false
|
||||
private(set) var currentParagraphIndex: Int? = nil
|
||||
private(set) var currentWordIndex: Int? = nil
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
var rate: Float = 0.45
|
||||
var voiceIdentifier: String? = nil
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
|
||||
/// the system speech daemon, so allocating one per `BookReaderView` struct
|
||||
/// construction (SwiftUI rebuilds the struct on every parent render) is a
|
||||
/// real cost — deferring it keeps controller construction cheap.
|
||||
@ObservationIgnored
|
||||
private lazy var synthesizer: AVSpeechSynthesizer = {
|
||||
let synth = AVSpeechSynthesizer()
|
||||
synth.delegate = self
|
||||
return synth
|
||||
}()
|
||||
private var queue: [QueueEntry] = []
|
||||
private var queueCursor: Int = 0
|
||||
private var audioSessionConfigured = false
|
||||
|
||||
private struct QueueEntry {
|
||||
let paragraphIndex: Int
|
||||
let text: String
|
||||
let wordRanges: [Range<String.Index>]
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public control
|
||||
|
||||
/// Start (or restart) reading the given paragraphs. Indexes in
|
||||
/// `currentParagraphIndex` are positions in the original `paragraphs`
|
||||
/// array — vocab lines are skipped internally but the visible index space
|
||||
/// matches what the caller passed.
|
||||
func start(paragraphs: [String], from startIndex: Int = 0) {
|
||||
stop()
|
||||
configureAudioSession()
|
||||
|
||||
var entries: [QueueEntry] = []
|
||||
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
|
||||
if Self.isVocabLine(p) { continue }
|
||||
entries.append(QueueEntry(
|
||||
paragraphIndex: idx,
|
||||
text: p,
|
||||
wordRanges: Self.wordRanges(in: p)
|
||||
))
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
queue = entries
|
||||
queueCursor = 0
|
||||
isReading = true
|
||||
isPaused = false
|
||||
speakCurrent()
|
||||
}
|
||||
|
||||
/// Pause immediately (no word boundary). Use this for tap-to-define so the
|
||||
/// audio stops the moment the user taps.
|
||||
func pause() {
|
||||
guard isReading, !isPaused else { return }
|
||||
synthesizer.pauseSpeaking(at: .immediate)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard isReading, isPaused else { return }
|
||||
synthesizer.continueSpeaking()
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
queue.removeAll()
|
||||
queueCursor = 0
|
||||
isReading = false
|
||||
isPaused = false
|
||||
currentParagraphIndex = nil
|
||||
currentWordIndex = nil
|
||||
deactivateAudioSession()
|
||||
}
|
||||
|
||||
// MARK: - Vocab detection + word ranges
|
||||
|
||||
/// Vocabulary entries in the book are formatted `palabra = meaning`.
|
||||
/// Reading them aloud says "palabra equals meaning" which is awkward, and
|
||||
/// they're reference material, so the read-along skips them.
|
||||
static func isVocabLine(_ paragraph: String) -> Bool {
|
||||
paragraph.contains(" = ")
|
||||
}
|
||||
|
||||
/// Word ranges that match the BookReaderView's space-split rendering —
|
||||
/// the visible word index N in a paragraph corresponds to wordRanges[N].
|
||||
static func wordRanges(in text: String) -> [Range<String.Index>] {
|
||||
var ranges: [Range<String.Index>] = []
|
||||
var i = text.startIndex
|
||||
while i < text.endIndex {
|
||||
while i < text.endIndex && text[i] == " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
guard i < text.endIndex else { break }
|
||||
let start = i
|
||||
while i < text.endIndex && text[i] != " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
ranges.append(start..<i)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func speakCurrent() {
|
||||
guard queueCursor < queue.count else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
let entry = queue[queueCursor]
|
||||
currentParagraphIndex = entry.paragraphIndex
|
||||
currentWordIndex = nil
|
||||
|
||||
let utterance = AVSpeechUtterance(string: entry.text)
|
||||
utterance.voice = resolveVoice()
|
||||
utterance.rate = rate
|
||||
utterance.pitchMultiplier = 1.0
|
||||
utterance.postUtteranceDelay = 0.20
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
private func resolveVoice() -> AVSpeechSynthesisVoice? {
|
||||
if let id = voiceIdentifier, let v = AVSpeechSynthesisVoice(identifier: id) {
|
||||
return v
|
||||
}
|
||||
return AVSpeechSynthesisVoice(language: "es-ES")
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
guard !audioSessionConfigured else { return }
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [])
|
||||
try session.setActive(true)
|
||||
audioSessionConfigured = true
|
||||
} catch {
|
||||
print("[BookSpeech] audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Release audio focus on stop so the OS hands control back to whatever
|
||||
/// app was playing before (music, podcast, etc.). Without this the
|
||||
/// session stays "active" until the app is killed.
|
||||
private func deactivateAudioSession() {
|
||||
guard audioSessionConfigured else { return }
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
print("[BookSpeech] audio session deactivation failed: \(error)")
|
||||
}
|
||||
audioSessionConfigured = false
|
||||
}
|
||||
|
||||
private func handleWillSpeakRange(_ range: NSRange) {
|
||||
guard queueCursor < queue.count else { return }
|
||||
let entry = queue[queueCursor]
|
||||
guard let stringRange = Range(range, in: entry.text) else { return }
|
||||
let lower = stringRange.lowerBound
|
||||
let idx = entry.wordRanges.firstIndex {
|
||||
$0.lowerBound <= lower && lower < $0.upperBound
|
||||
}
|
||||
if let idx, idx != currentWordIndex {
|
||||
currentWordIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDidFinish() {
|
||||
queueCursor += 1
|
||||
if queueCursor < queue.count {
|
||||
speakCurrent()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVSpeechSynthesizerDelegate
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
willSpeakRangeOfSpeechString characterRange: NSRange,
|
||||
utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleWillSpeakRange(characterRange) }
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didFinish utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleDidFinish() }
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,15 @@ import SharedModels
|
||||
import Foundation
|
||||
|
||||
actor DataLoader {
|
||||
static let courseDataVersion = 7
|
||||
static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
|
||||
static let courseDataKey = "courseDataVersion"
|
||||
|
||||
static let textbookDataVersion = 12
|
||||
static let textbookDataVersion = 14
|
||||
static let textbookDataKey = "textbookDataVersion"
|
||||
|
||||
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
||||
static let bookDataKey = "bookDataVersion"
|
||||
|
||||
/// Quick check: does the DB need seeding or course data refresh?
|
||||
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||
let context = ModelContext(container)
|
||||
@@ -21,6 +24,9 @@ actor DataLoader {
|
||||
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
|
||||
if textbookVersion < textbookDataVersion { return true }
|
||||
|
||||
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
|
||||
if bookVersion < bookDataVersion { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -146,6 +152,43 @@ actor DataLoader {
|
||||
if seedTextbookData(context: context) {
|
||||
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
UserDefaults.standard.set(bookDataVersion, forKey: bookDataKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed books if the version has changed or the rows are missing.
|
||||
static func refreshBooksDataIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let storedVersion = shared.integer(forKey: bookDataKey)
|
||||
let versionCurrent = storedVersion >= bookDataVersion
|
||||
|
||||
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if let existing = try? context.fetch(FetchDescriptor<Book>()) {
|
||||
for book in existing { context.delete(book) }
|
||||
}
|
||||
if let existing = try? context.fetch(FetchDescriptor<BookChapter>()) {
|
||||
for chapter in existing { context.delete(chapter) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
shared.set(bookDataVersion, forKey: bookDataKey)
|
||||
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
|
||||
} else {
|
||||
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed textbook data if the version has changed OR if the rows are
|
||||
@@ -523,6 +566,150 @@ actor DataLoader {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Books seeding
|
||||
|
||||
/// Walk the bundle for any `book_*.json` resources and seed `Book` +
|
||||
/// `BookChapter` rows from each one. Returns true when at least one row
|
||||
/// was inserted (mirrors `seedTextbookData`'s contract).
|
||||
@discardableResult
|
||||
private static func seedBooks(context: ModelContext) -> Bool {
|
||||
let bookURLs = bundledBookJSONURLs()
|
||||
guard !bookURLs.isEmpty else {
|
||||
print("[DataLoader] no book_*.json bundled — skipping book seed")
|
||||
return false
|
||||
}
|
||||
|
||||
var insertedBooks = 0
|
||||
for url in bookURLs {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[DataLoader] WARN: could not read \(url.lastPathComponent)")
|
||||
continue
|
||||
}
|
||||
guard let slug = json["slug"] as? String,
|
||||
let title = json["title"] as? String,
|
||||
let chaptersRaw = json["chapters"] as? [[String: Any]] else {
|
||||
print("[DataLoader] WARN: \(url.lastPathComponent) missing required fields")
|
||||
continue
|
||||
}
|
||||
let author = (json["author"] as? String) ?? ""
|
||||
let language = (json["language"] as? String) ?? "es"
|
||||
|
||||
// Pre-computed per-book glossary, keyed by cleaned word.
|
||||
var glossary: [String: WordGloss] = [:]
|
||||
if let glossaryRaw = json["glossary"] as? [String: [String: String]] {
|
||||
for (word, fields) in glossaryRaw {
|
||||
glossary[word] = WordGloss(
|
||||
baseForm: fields["baseForm"] ?? word,
|
||||
english: fields["english"] ?? "",
|
||||
partOfSpeech: fields["partOfSpeech"] ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
let glossaryData = (try? JSONEncoder().encode(glossary)) ?? Data()
|
||||
|
||||
let book = Book(
|
||||
slug: slug,
|
||||
title: title,
|
||||
author: author,
|
||||
language: language,
|
||||
chapterCount: chaptersRaw.count,
|
||||
accentColorHex: accentHex(forSlug: slug),
|
||||
glossaryJSON: glossaryData
|
||||
)
|
||||
context.insert(book)
|
||||
insertedBooks += 1
|
||||
|
||||
for ch in chaptersRaw {
|
||||
guard let number = ch["number"] as? Int,
|
||||
let chTitle = ch["title"] as? String else { continue }
|
||||
let paragraphsES = (ch["paragraphsES"] as? [String]) ?? []
|
||||
let paragraphsEN = (ch["paragraphsEN"] as? [String]) ?? []
|
||||
let esData = (try? JSONEncoder().encode(paragraphsES)) ?? Data()
|
||||
let enData = (try? JSONEncoder().encode(paragraphsEN)) ?? Data()
|
||||
let chapter = BookChapter(
|
||||
id: "\(slug)-ch\(number)",
|
||||
bookSlug: slug,
|
||||
number: number,
|
||||
title: chTitle,
|
||||
paragraphCount: paragraphsES.count,
|
||||
paragraphsESJSON: esData,
|
||||
paragraphsENJSON: enData
|
||||
)
|
||||
context.insert(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let persistedBooks = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let persistedChapters = (try? context.fetchCount(FetchDescriptor<BookChapter>())) ?? 0
|
||||
guard persistedBooks > 0 else {
|
||||
print("[DataLoader] ERROR: seeded \(insertedBooks) books but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
print("Book seeding complete: \(persistedBooks) books, \(persistedChapters) chapters")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Slugs of books bundled with the app. Kept explicit so device installs
|
||||
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
|
||||
/// successfully enumerating the bundle — that API has been observed to
|
||||
/// return empty for some iOS configurations even when the resource is
|
||||
/// present, matching the same `bundleURL.appendingPathComponent` fallback
|
||||
/// used by the textbook seed.
|
||||
private static let bundledBookSlugs: [String] = [
|
||||
"olly-vol2",
|
||||
]
|
||||
|
||||
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
|
||||
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
|
||||
/// directory enumeration so newly-bundled books are picked up without a
|
||||
/// code change.
|
||||
private static func bundledBookJSONURLs() -> [URL] {
|
||||
var seen = Set<String>()
|
||||
var out: [URL] = []
|
||||
let bundle = Bundle.main
|
||||
|
||||
for slug in bundledBookSlugs {
|
||||
let filename = "book_\(slug).json"
|
||||
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
|
||||
?? bundle.bundleURL.appendingPathComponent(filename)
|
||||
if FileManager.default.fileExists(atPath: url.path),
|
||||
seen.insert(filename).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
|
||||
for url in urls where url.lastPathComponent.hasPrefix("book_") {
|
||||
if seen.insert(url.lastPathComponent).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let names = out.map(\.lastPathComponent).joined(separator: ", ")
|
||||
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
|
||||
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
/// Deterministic accent colour for a book, derived from its slug so the
|
||||
/// cover tile has a stable colour across launches.
|
||||
private static func accentHex(forSlug slug: String) -> String {
|
||||
let palette = [
|
||||
"#7B6CF6", "#E07A5F", "#3D5A80", "#81B29A",
|
||||
"#F2CC8F", "#D4A5A5", "#5B8A72", "#A06CD5",
|
||||
]
|
||||
let hash = slug.unicodeScalars.reduce(0) { ($0 &* 31) &+ Int($1.value) }
|
||||
return palette[abs(hash) % palette.count]
|
||||
}
|
||||
|
||||
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
|
||||
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
|
||||
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
|
||||
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
|
||||
struct ExtraStudyStore {
|
||||
let context: ModelContext
|
||||
|
||||
private func fetchMark(id: String) -> ExtraStudyMark? {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
func contains(card: VocabCard) -> Bool {
|
||||
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
|
||||
}
|
||||
|
||||
/// Toggle a mark for the given card. Returns the new "is marked" state.
|
||||
@discardableResult
|
||||
func toggle(
|
||||
card: VocabCard,
|
||||
courseName: String,
|
||||
weekNumber: Int
|
||||
) -> Bool {
|
||||
let id = CourseCardStore.reviewKey(for: card)
|
||||
if let existing = fetchMark(id: id) {
|
||||
context.delete(existing)
|
||||
try? context.save()
|
||||
return false
|
||||
}
|
||||
let mark = ExtraStudyMark(
|
||||
id: id,
|
||||
deckId: card.deckId,
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber,
|
||||
front: card.front,
|
||||
back: card.back
|
||||
)
|
||||
context.insert(mark)
|
||||
try? context.save()
|
||||
return true
|
||||
}
|
||||
|
||||
func count(courseName: String, weekNumber: Int) -> Int {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
|
||||
func countsByWeek(courseName: String) -> [Int: Int] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
|
||||
)
|
||||
let marks = (try? context.fetch(descriptor)) ?? []
|
||||
var counts: [Int: Int] = [:]
|
||||
for mark in marks {
|
||||
counts[mark.weekNumber, default: 0] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
},
|
||||
sortBy: [SortDescriptor(\.markedAt)]
|
||||
)
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
|
||||
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
|
||||
/// button knows which week/course to attribute a mark to.
|
||||
struct ExtraStudyMarkContext: Equatable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
@@ -8,8 +8,10 @@ struct PracticeSettings: Sendable {
|
||||
let enabledTenses: Set<String>
|
||||
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
let showVosotros: Bool
|
||||
let showReflexiveVerbsOnly: Bool
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
|
||||
init(progress: UserProgress?) {
|
||||
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
|
||||
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
||||
@@ -17,6 +19,8 @@ struct PracticeSettings: Sendable {
|
||||
self.enabledTenses = Set(resolvedTenses)
|
||||
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
||||
self.showVosotros = progress?.showVosotros ?? true
|
||||
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
}
|
||||
|
||||
var selectionTenseIDs: [String] {
|
||||
@@ -41,16 +45,25 @@ struct FullTablePrompt {
|
||||
struct PracticeSessionService {
|
||||
let localContext: ModelContext
|
||||
let cloudContext: ModelContext
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
private let referenceStore: ReferenceStore
|
||||
|
||||
init(localContext: ModelContext, cloudContext: ModelContext) {
|
||||
init(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext,
|
||||
reflexiveBaseInfinitives: Set<String> = []
|
||||
) {
|
||||
self.localContext = localContext
|
||||
self.cloudContext = cloudContext
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
self.referenceStore = ReferenceStore(context: localContext)
|
||||
}
|
||||
|
||||
func settings() -> PracticeSettings {
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||
PracticeSettings(
|
||||
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
|
||||
reflexiveBaseInfinitives: reflexiveBaseInfinitives
|
||||
)
|
||||
}
|
||||
|
||||
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
||||
@@ -82,31 +95,114 @@ struct PracticeSessionService {
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||
/// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
|
||||
/// the prompt just shown; when possible the next prompt avoids repeating the
|
||||
/// same tense back-to-back and switches the verb's ending family
|
||||
/// (-ar ⇄ -er/-ir) so consecutive rounds feel varied. Both are best-effort:
|
||||
/// if honouring them would leave no eligible combo, the constraint is
|
||||
/// dropped rather than dead-ending.
|
||||
func randomFullTablePrompt(
|
||||
previousTenseId: String? = nil,
|
||||
previousEnding: String? = nil
|
||||
) -> FullTablePrompt? {
|
||||
let settings = settings()
|
||||
// Full Table practice is regular-only, so the irregular-category setting is
|
||||
// deliberately ignored here (applying it would empty the pool).
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevels: settings.selectedLevels)
|
||||
// Full Table is testing the user's grasp of regular conjugation patterns,
|
||||
// not vocabulary recognition. Level filter is intentionally bypassed so
|
||||
// we draw from the entire verb pool — being able to conjugate `hablar`
|
||||
// regularly transfers to any other regular verb regardless of "level".
|
||||
// Irregular-category and tense filters still apply via downstream checks.
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(),
|
||||
settings: settings
|
||||
)
|
||||
guard !verbs.isEmpty else { return nil }
|
||||
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = settings.selectionTenseIDs.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
let candidateTenseIds = settings.selectionTenseIDs
|
||||
guard !candidateTenseIds.isEmpty else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
if forms.isEmpty { continue }
|
||||
let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
|
||||
let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
|
||||
let isConstrained = tenseChoices.count != candidateTenseIds.count
|
||||
|| verbChoices.count != verbs.count
|
||||
|
||||
// Full Table practice is for regular patterns only — skip combos
|
||||
// where any form in this (verb, tense) is irregular.
|
||||
if forms.contains(where: { $0.regularity != "regular" }) { continue }
|
||||
// Best-effort: random-sample the constrained pool first so consecutive
|
||||
// prompts vary the tense and the -ar/-er-ir family.
|
||||
if isConstrained,
|
||||
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
||||
// No constraint, or honouring it found nothing quickly — sample the
|
||||
// full pool.
|
||||
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Guarantee: if any eligible (verb, tense) combo exists in the data we
|
||||
// return one. Only return nil when the user's settings genuinely produce
|
||||
// an empty pool (so the UI can show an error state instead of a blank).
|
||||
for verb in verbs.shuffled() {
|
||||
for tenseId in candidateTenseIds.shuffled() {
|
||||
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
|
||||
/// With ~1750 verbs and several hundred eligible combos this almost always
|
||||
/// succeeds within a handful of attempts.
|
||||
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
|
||||
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = tenseIds.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Tense ids with the previous one removed — only when more than one tense
|
||||
/// is selected and removing it still leaves a choice.
|
||||
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
|
||||
guard ids.count > 1, let previous else { return ids }
|
||||
let filtered = ids.filter { $0 != previous }
|
||||
return filtered.isEmpty ? ids : filtered
|
||||
}
|
||||
|
||||
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
|
||||
/// verb's — only when both families are actually present in the pool.
|
||||
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
|
||||
guard let previousEnding else { return verbs }
|
||||
let previousIsAr = (previousEnding == "ar")
|
||||
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
|
||||
return switched.isEmpty ? verbs : switched
|
||||
}
|
||||
|
||||
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
|
||||
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
|
||||
private func makePromptIfFullyRegular(
|
||||
verb: Verb,
|
||||
tenseId: String,
|
||||
tenseInfo: TenseInfo
|
||||
) -> FullTablePrompt? {
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
guard !forms.isEmpty else { return nil }
|
||||
// Forms must arrive in personIndex order so the regularity array lines
|
||||
// up. `fetchForms` already sorts them, but assert for safety.
|
||||
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
|
||||
let regularities = sorted.map { $0.regularity }
|
||||
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
|
||||
}
|
||||
|
||||
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
||||
ReviewStore.recordReview(
|
||||
verbId: verbId,
|
||||
@@ -143,6 +239,27 @@ struct PracticeSessionService {
|
||||
return buildCardLoad(verb: verb, form: form)
|
||||
}
|
||||
|
||||
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
|
||||
/// verb-id set to IDs whose infinitive is in the curated list.
|
||||
/// No-op otherwise.
|
||||
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return ids
|
||||
}
|
||||
let matching = ids.filter { id in
|
||||
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
|
||||
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return verbs
|
||||
}
|
||||
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
|
||||
}
|
||||
|
||||
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
||||
let spans = referenceStore.fetchSpans(
|
||||
verbId: form.verbId,
|
||||
@@ -164,9 +281,12 @@ struct PracticeSessionService {
|
||||
|
||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let now = Date()
|
||||
var descriptor = FetchDescriptor<ReviewCard>(
|
||||
@@ -194,9 +314,12 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickWeakForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<ReviewCard>(
|
||||
@@ -221,9 +344,12 @@ struct PracticeSessionService {
|
||||
let settings = settings()
|
||||
// Focus mode explicitly selects one irregular category, so the user's
|
||||
// settings-level irregular filter is deliberately skipped here.
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: []
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let typeRange: ClosedRange<Int>
|
||||
|
||||
@@ -261,9 +387,12 @@ struct PracticeSessionService {
|
||||
private func pickCommonTenseForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let coreTenseIDs = TenseID.coreTenseIDs
|
||||
let verbs = referenceStore.fetchVerbs(
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
@@ -277,9 +406,12 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickRandomForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
|
||||
@@ -94,6 +94,19 @@ struct ReferenceStore {
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Map of tenseId → conjugated forms for a verb, used to ground and
|
||||
/// validate LLM-generated example sentences.
|
||||
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
|
||||
var map: [String: [String]] = [:]
|
||||
for tenseId in tenseIds {
|
||||
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
|
||||
.map(\.form)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
if !forms.isEmpty { map[tenseId] = forms }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
|
||||
let descriptor = FetchDescriptor<VerbForm>(
|
||||
predicate: #Predicate<VerbForm> { form in
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Loads and queries the curated reflexive-verb list bundled with the app
|
||||
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
|
||||
///
|
||||
/// `entries(for:)` returns a list because a single base infinitive may map to
|
||||
/// multiple reflexive entries — e.g., `ponerse` covers both "to put on
|
||||
/// (clothing) / to become" and "to come to an agreement (with)".
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ReflexiveVerbStore {
|
||||
|
||||
/// Process-wide accessor for services that can't use @Environment injection
|
||||
/// (e.g. PracticeSessionService called from ViewModels). Views should still
|
||||
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
|
||||
static let shared = ReflexiveVerbStore()
|
||||
|
||||
private(set) var entries: [ReflexiveVerb] = []
|
||||
private var indexByBase: [String: [ReflexiveVerb]] = [:]
|
||||
|
||||
/// Set of base infinitives present in the list. Cheap lookup for filters.
|
||||
private(set) var baseInfinitives: Set<String> = []
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// All reflexive entries whose base infinitive matches (case-insensitive).
|
||||
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
|
||||
indexByBase[baseInfinitive.lowercased()] ?? []
|
||||
}
|
||||
|
||||
/// Convenience — true when the verb's bare infinitive appears in the list.
|
||||
func isReflexive(baseInfinitive: String) -> Bool {
|
||||
baseInfinitives.contains(baseInfinitive.lowercased())
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
|
||||
entries = decoded
|
||||
var index: [String: [ReflexiveVerb]] = [:]
|
||||
for entry in decoded {
|
||||
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
|
||||
}
|
||||
indexByBase = index
|
||||
baseInfinitives = Set(index.keys)
|
||||
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
|
||||
} catch {
|
||||
print("[ReflexiveVerbStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,13 +72,13 @@ struct ReviewStore {
|
||||
return newCard
|
||||
}
|
||||
|
||||
/// Bumps the streak / "showed up today" bookkeeping without touching
|
||||
/// review-specific counters. Call from any user-initiated learning action
|
||||
/// — sending a chat message, doing an exercise, watching a curated video,
|
||||
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
|
||||
/// only the first call on a fresh date moves the streak.
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
|
||||
let progress = fetchOrCreateUserProgress(context: context)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
@@ -97,9 +97,25 @@ struct ReviewStore {
|
||||
progress.todayCount = 0
|
||||
}
|
||||
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
try? context.save()
|
||||
return progress
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
// Bump streak / today-date first so review-specific counters land on
|
||||
// the correct day if this is the user's first action after midnight.
|
||||
let progress = recordActivity(context: context, date: date)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
progress.todayCount += reviewIncrement
|
||||
progress.totalReviewed += reviewIncrement
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
|
||||
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||
log.reviewCount += reviewIncrement
|
||||
|
||||
@@ -10,6 +10,7 @@ enum StartupCoordinator {
|
||||
await DataLoader.seedIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
|
||||
}
|
||||
|
||||
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Disk-backed cache for verb example sentences (Issue #27). One JSON file
|
||||
/// in the Caches directory keyed by verb id; lazy-loaded on first access and
|
||||
/// write-through on every generation. Matches DictionaryService's disk pattern.
|
||||
///
|
||||
/// Cache eviction by the OS is acceptable because contents are regenerable.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VerbExampleCache {
|
||||
|
||||
/// Bump to invalidate every cached example set. Raised to 2 for Issue #33
|
||||
/// — examples generated before the verb-grounding/validation fix could
|
||||
/// contain sentences built on the wrong verb.
|
||||
private static let cacheVersion = 2
|
||||
|
||||
private struct CacheFile: Codable {
|
||||
var version: Int
|
||||
var entries: [String: [VerbExample]]
|
||||
}
|
||||
|
||||
private var store: [Int: [VerbExample]] = [:]
|
||||
private var isLoaded = false
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Look up cached examples for a verb; returns nil on miss.
|
||||
/// Safe to call before `loadIfNeeded()`; it triggers the disk load itself.
|
||||
func examples(for verbId: Int) -> [VerbExample]? {
|
||||
loadIfNeeded()
|
||||
return store[verbId]
|
||||
}
|
||||
|
||||
/// Store newly generated examples and persist to disk.
|
||||
func setExamples(_ examples: [VerbExample], for verbId: Int) {
|
||||
loadIfNeeded()
|
||||
store[verbId] = examples
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Disk I/O
|
||||
|
||||
private static var cacheURL: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("verb_examples.json")
|
||||
}
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard !isLoaded else { return }
|
||||
defer { isLoaded = true }
|
||||
|
||||
guard let data = try? Data(contentsOf: Self.cacheURL),
|
||||
let decoded = try? JSONDecoder().decode(CacheFile.self, from: data),
|
||||
decoded.version == Self.cacheVersion
|
||||
else {
|
||||
// Missing, unreadable, old flat format, or stale version — start
|
||||
// fresh so pre-fix examples don't linger.
|
||||
return
|
||||
}
|
||||
|
||||
// Persisted with String keys because JSON object keys are strings;
|
||||
// convert back to Int for in-memory lookup.
|
||||
var rebuilt: [Int: [VerbExample]] = [:]
|
||||
for (key, value) in decoded.entries {
|
||||
if let id = Int(key) {
|
||||
rebuilt[id] = value
|
||||
}
|
||||
}
|
||||
store = rebuilt
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let entries = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
|
||||
let file = CacheFile(version: Self.cacheVersion, entries: entries)
|
||||
guard let data = try? JSONEncoder().encode(file) else { return }
|
||||
try? data.write(to: Self.cacheURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
|
||||
/// Generates a set of example sentences for a single verb, one per core tense
|
||||
/// (Issue #27). Mirrors the StoryGenerator pattern: @Generable response types,
|
||||
/// a static availability flag, and a single generate(...) entry point.
|
||||
///
|
||||
/// Issue #33: the model used to drift onto other verbs partway through the
|
||||
/// 6-example batch (a "tener" set would contain sentences built on estar / ir
|
||||
/// / deber). Two defenses now apply:
|
||||
/// 1. The prompt embeds the verb's *exact* conjugated forms per tense, so
|
||||
/// the model echoes a real form instead of recalling one.
|
||||
/// 2. Every generated sentence is validated against those forms; failures
|
||||
/// are regenerated once, and anything still wrong is dropped rather than
|
||||
/// shown.
|
||||
@MainActor
|
||||
struct VerbExampleGenerator {
|
||||
|
||||
// MARK: - Generable Types
|
||||
|
||||
@Generable
|
||||
struct GeneratedExampleSet {
|
||||
@Guide(
|
||||
description: "Six example sentences, one per tense in the exact order requested. Each sentence must actually use the target verb conjugated in that tense.",
|
||||
.count(6)
|
||||
)
|
||||
var examples: [GeneratedExample]
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedExample {
|
||||
@Guide(description: "The tense id this sentence demonstrates. Must match one of the ids provided in the prompt exactly (e.g. ind_presente).")
|
||||
var tenseId: String
|
||||
|
||||
@Guide(description: "A natural Spanish sentence, 6-14 words, that uses the target verb in the specified tense. For imperative tenses use tú or nosotros — never yo.")
|
||||
var spanish: String
|
||||
|
||||
@Guide(description: "An accurate, idiomatic English translation of the Spanish sentence.")
|
||||
var english: String
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
/// Generate one validated example per tense in `tenseIds`.
|
||||
///
|
||||
/// - Parameter formsByTense: the verb's conjugated forms keyed by tenseId
|
||||
/// (from `ReferenceStore.conjugatedForms`). Used to ground the prompt
|
||||
/// and validate the output. A tense with no forms here is accepted
|
||||
/// without validation.
|
||||
static func generate(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [VerbExample] {
|
||||
// A thrown error here (model busy, context overflow) shouldn't abort the
|
||||
// whole generation — treat it as a fully-failed batch so the retry below
|
||||
// still runs, same as the retry path's own `try?`.
|
||||
let firstPass = (try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)) ?? [:]
|
||||
|
||||
var valid: [String: VerbExample] = [:]
|
||||
var failedTenses: [String] = []
|
||||
for id in tenseIds {
|
||||
if let ex = firstPass[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
} else {
|
||||
failedTenses.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
// One focused retry — regenerate the whole batch, but only adopt the
|
||||
// results for tenses that failed the first pass.
|
||||
if !failedTenses.isEmpty {
|
||||
let retry = try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
if let retry {
|
||||
for id in failedTenses {
|
||||
if let ex = retry[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requested order, dropping any tense that never produced a valid
|
||||
// sentence — better to show fewer examples than wrong ones.
|
||||
return tenseIds.compactMap { valid[$0] }
|
||||
}
|
||||
|
||||
// MARK: - Single batch call
|
||||
|
||||
private static func generateBatch(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [String: VerbExample] {
|
||||
let tenseBlock = tenseIds.compactMap { id -> String? in
|
||||
guard let info = TenseInfo.find(id) else { return nil }
|
||||
let forms = formsByTense[id] ?? []
|
||||
if forms.isEmpty {
|
||||
return "- \(id) (\(info.english))"
|
||||
}
|
||||
return "- \(id) (\(info.english)): use one of these exact conjugated forms — \(forms.joined(separator: ", "))"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish language teacher writing short example sentences for a learner.
|
||||
The learner is studying the verb "\(verbInfinitive)" (to \(verbEnglish)).
|
||||
EVERY sentence must use "\(verbInfinitive)" as its main verb, conjugated in the
|
||||
requested tense — never substitute a different verb (no estar, ir, deber, etc.
|
||||
unless the target verb itself is that verb). Each sentence must:
|
||||
- Contain one of the exact conjugated forms listed for its tense.
|
||||
- Be 6-14 words, natural and everyday.
|
||||
- Use vocabulary appropriate for intermediate learners.
|
||||
- Vary subjects and contexts across the set; do not reuse the same subject twice.
|
||||
For imperative tenses, address "tú" or "nosotros" — never "yo".
|
||||
""")
|
||||
|
||||
let prompt = """
|
||||
Write one example sentence for "\(verbInfinitive)" per tense below, in this order:
|
||||
\(tenseBlock)
|
||||
|
||||
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
|
||||
The Spanish sentence MUST contain one of the conjugated forms shown for that tense.
|
||||
"""
|
||||
|
||||
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
|
||||
|
||||
// `uniquingKeysWith` defensively — the schema forces 6 examples even
|
||||
// when fewer tenses are requested, so the model may repeat a tenseId.
|
||||
return Dictionary(
|
||||
response.content.examples.map {
|
||||
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// True when `sentence` contains at least one of `forms` as a whole word
|
||||
/// (accent- and case-insensitive). Empty `forms` → accept (can't validate).
|
||||
static func exampleUsesVerb(_ sentence: String, forms: [String]) -> Bool {
|
||||
guard !forms.isEmpty else { return true }
|
||||
let sentenceWords = foldedWords(sentence)
|
||||
let formWords = Set(forms.flatMap { foldedWords($0) })
|
||||
return !sentenceWords.isDisjoint(with: formWords)
|
||||
}
|
||||
|
||||
private static func foldedWords(_ text: String) -> Set<String> {
|
||||
let folded = text.folding(
|
||||
options: [.diacriticInsensitive, .caseInsensitive],
|
||||
locale: nil
|
||||
)
|
||||
return Set(folded.split { !$0.isLetter }.map(String.init))
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
|
||||
/// keyed by `verbId` (the integer primary key on `Verb`).
|
||||
struct VerbReviewStore {
|
||||
let context: ModelContext
|
||||
|
||||
@discardableResult
|
||||
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
|
||||
let id = VerbReviewCard.makeId(verbId: verbId)
|
||||
let descriptor = FetchDescriptor<VerbReviewCard>(
|
||||
predicate: #Predicate<VerbReviewCard> { $0.id == id }
|
||||
)
|
||||
if let existing = (try? context.fetch(descriptor))?.first {
|
||||
return existing
|
||||
}
|
||||
let card = VerbReviewCard(verbId: verbId)
|
||||
context.insert(card)
|
||||
return card
|
||||
}
|
||||
|
||||
func rate(verbId: Int, quality: ReviewQuality) {
|
||||
let card = fetchOrCreateReviewCard(verbId: verbId)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
import AVFoundation
|
||||
import YouTubeKit
|
||||
|
||||
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
|
||||
///
|
||||
/// Two-path strategy since YouTube phased out high-quality progressive streams:
|
||||
/// 1. **Progressive** — single MP4 with audio+video combined (rare, ≤360p).
|
||||
/// Download directly to the final path.
|
||||
/// 2. **Adaptive** — DASH: separate video + audio tracks. Download each to
|
||||
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
|
||||
/// no re-encoding) into the final MP4.
|
||||
///
|
||||
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
|
||||
/// See the library's README Example 3 for the progressive-only pattern.
|
||||
///
|
||||
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
||||
/// will break when YouTube changes their internal format. Streaming (iframe
|
||||
/// embed elsewhere) keeps working regardless.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VideoDownloadService {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// Per-download state surfaced to the UI. `progress == nil` means show an
|
||||
/// indeterminate spinner (e.g. muxing phase).
|
||||
struct DownloadStatus: Equatable, Sendable {
|
||||
var progress: Double?
|
||||
var label: String
|
||||
}
|
||||
|
||||
enum DownloadError: Error, LocalizedError {
|
||||
case extractionFailed(String)
|
||||
case noSuitableStream
|
||||
case downloadFailed(String)
|
||||
case muxFailed(String)
|
||||
case fileWriteFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .extractionFailed(let why): "Could not extract video: \(why)"
|
||||
case .noSuitableStream: "No downloadable video+audio streams found for this video."
|
||||
case .downloadFailed(let why): "Download failed: \(why)"
|
||||
case .muxFailed(let why): "Could not combine audio and video: \(why)"
|
||||
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-flight downloads by videoId, with a phase label and progress.
|
||||
var activeDownloads: [String: DownloadStatus] = [:]
|
||||
|
||||
static let shared = VideoDownloadService()
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private static var videosDirectory: URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return docs.appendingPathComponent("videos", isDirectory: true)
|
||||
}
|
||||
|
||||
private static var tempDirectory: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
}
|
||||
|
||||
private static func ensureDirectory() throws {
|
||||
let url = videosDirectory
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
static func fileURL(for videoId: String) -> URL {
|
||||
videosDirectory.appendingPathComponent("\(videoId).mp4")
|
||||
}
|
||||
|
||||
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
|
||||
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
|
||||
}
|
||||
|
||||
/// True if a downloaded MP4 exists for this videoId.
|
||||
static func isDownloaded(videoId: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
||||
}
|
||||
|
||||
// MARK: - Public download entry point
|
||||
|
||||
func download(
|
||||
videoId: String,
|
||||
title: String,
|
||||
into modelContext: ModelContext
|
||||
) async throws {
|
||||
guard activeDownloads[videoId] == nil else { return }
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
|
||||
|
||||
defer { activeDownloads.removeValue(forKey: videoId) }
|
||||
|
||||
try Self.ensureDirectory()
|
||||
|
||||
let destURL = Self.fileURL(for: videoId)
|
||||
|
||||
// Resolve streams once; decide progressive vs adaptive.
|
||||
let plan: DownloadPlan
|
||||
do {
|
||||
plan = try await Self.resolvePlan(videoId: videoId)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.extractionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
switch plan {
|
||||
case .progressive(let url):
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
|
||||
try await downloadStream(from: url, to: destURL) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p,
|
||||
label: "Downloading \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case .adaptive(let videoURL, let audioURL):
|
||||
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
|
||||
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempVideo)
|
||||
try? FileManager.default.removeItem(at: tempAudio)
|
||||
}
|
||||
|
||||
// Phase 1 of 3: video track (0–55% of overall progress)
|
||||
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p * 0.55,
|
||||
label: "Video \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 of 3: audio track (55–80%)
|
||||
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: 0.55 + p * 0.25,
|
||||
label: "Audio \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 of 3: mux (indeterminate)
|
||||
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
|
||||
do {
|
||||
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.muxFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Record in SwiftData.
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
|
||||
let byteCount = (attrs?[.size] as? Int) ?? 0
|
||||
let entry = DownloadedVideo(
|
||||
videoId: videoId,
|
||||
title: title,
|
||||
filename: "\(videoId).mp4",
|
||||
byteCount: byteCount
|
||||
)
|
||||
modelContext.insert(entry)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
/// Deletes the downloaded file and its SwiftData row.
|
||||
func delete(videoId: String, modelContext: ModelContext) {
|
||||
let url = Self.fileURL(for: videoId)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
let descriptor = FetchDescriptor<DownloadedVideo>(
|
||||
predicate: #Predicate<DownloadedVideo> { $0.videoId == videoId }
|
||||
)
|
||||
if let existing = try? modelContext.fetch(descriptor) {
|
||||
for entry in existing {
|
||||
modelContext.delete(entry)
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes used by all downloads.
|
||||
static func totalBytesUsed() -> Int {
|
||||
let url = videosDirectory
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
return contents.reduce(0) { acc, file in
|
||||
let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
return acc + size
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream resolution
|
||||
|
||||
private enum DownloadPlan {
|
||||
case progressive(URL)
|
||||
case adaptive(video: URL, audio: URL)
|
||||
}
|
||||
|
||||
/// Picks progressive MP4 if available (single download); otherwise the
|
||||
/// best adaptive MP4 video + M4A audio pair for later muxing.
|
||||
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
|
||||
let youtube = YouTube(videoID: videoId)
|
||||
let streams = try await youtube.streams
|
||||
|
||||
// 1. Progressive path — uses the library's own recommended filter chain.
|
||||
if let progressive = streams
|
||||
.filterVideoAndAudio()
|
||||
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
|
||||
.highestResolutionStream() {
|
||||
return .progressive(progressive.url)
|
||||
}
|
||||
|
||||
// 2. Adaptive path — highest-resolution MP4 video track + highest-bitrate M4A audio.
|
||||
let videoStream = streams
|
||||
.filterVideoOnly()
|
||||
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
|
||||
.highestResolutionStream()
|
||||
let audioStream = streams
|
||||
.filterAudioOnly()
|
||||
.filter {
|
||||
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
|
||||
}
|
||||
.highestAudioBitrateStream()
|
||||
|
||||
if let v = videoStream, let a = audioStream {
|
||||
return .adaptive(video: v.url, audio: a.url)
|
||||
}
|
||||
|
||||
throw DownloadError.noSuitableStream
|
||||
}
|
||||
|
||||
// MARK: - Download helper
|
||||
|
||||
nonisolated private func downloadStream(
|
||||
from url: URL,
|
||||
to dest: URL,
|
||||
onProgress: @escaping @Sendable (Double) -> Void
|
||||
) async throws {
|
||||
let delegate = DownloadProgressDelegate(onProgress: onProgress)
|
||||
do {
|
||||
let (tempURL, _) = try await URLSession.shared.download(
|
||||
for: URLRequest(url: url),
|
||||
delegate: delegate
|
||||
)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: dest)
|
||||
} catch {
|
||||
throw DownloadError.downloadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Muxing
|
||||
|
||||
/// Combines a video-only file and an audio-only file into a single MP4.
|
||||
/// Uses `.passthrough` preset — no re-encoding, just container rewrite.
|
||||
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
|
||||
/// rejects the composition due to codec incompatibility.
|
||||
nonisolated private static func mux(
|
||||
videoURL: URL,
|
||||
audioURL: URL,
|
||||
to outputURL: URL
|
||||
) async throws {
|
||||
let videoAsset = AVURLAsset(url: videoURL)
|
||||
let audioAsset = AVURLAsset(url: audioURL)
|
||||
|
||||
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
|
||||
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
|
||||
|
||||
guard let srcVideo = videoTracks.first,
|
||||
let srcAudio = audioTracks.first else {
|
||||
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
|
||||
}
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let compVideo = composition.addMutableTrack(
|
||||
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
),
|
||||
let compAudio = composition.addMutableTrack(
|
||||
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
throw DownloadError.muxFailed("Could not create composition tracks")
|
||||
}
|
||||
|
||||
let videoDuration = try await videoAsset.load(.duration)
|
||||
let audioDuration = try await audioAsset.load(.duration)
|
||||
let duration = CMTimeMinimum(videoDuration, audioDuration)
|
||||
let range = CMTimeRange(start: .zero, duration: duration)
|
||||
|
||||
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
|
||||
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
|
||||
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
// Try passthrough (no re-encode) first; fall back to quality export on failure.
|
||||
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
|
||||
var lastError: Error?
|
||||
|
||||
for preset in presets {
|
||||
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try await exporter.export(to: outputURL, as: .mp4)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
// Clean up partial output before retrying with next preset.
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSession progress delegate
|
||||
|
||||
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
let onProgress: @Sendable (Double) -> Void
|
||||
|
||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Not used — `URLSession.download(for:delegate:)` returns the temp URL directly.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// In-session "learning steps" queue for vocab practice — the short-term
|
||||
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
|
||||
///
|
||||
/// A card is requeued a relative number of positions ahead based on the
|
||||
/// rating, mirroring Anki's learning steps but position-based instead of
|
||||
/// time-based:
|
||||
/// Again → reappears 5–8 cards later
|
||||
/// Hard → reappears 7–10 cards later
|
||||
/// Good → first time: advances to `review`, reappears ~20 cards later
|
||||
/// already in review: graduates (leaves the session)
|
||||
/// Easy → graduates immediately
|
||||
///
|
||||
/// `answer` returns a `ReviewQuality` only when the card graduates — that's
|
||||
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
|
||||
/// Again/Hard presses don't touch the cross-session schedule.
|
||||
struct VocabSessionQueue {
|
||||
|
||||
enum CardState: String {
|
||||
case new // never answered this session
|
||||
case learning // answered Again/Hard at least once
|
||||
case review // answered Good once, one confirmation pass to go
|
||||
}
|
||||
|
||||
enum Rating {
|
||||
case again, hard, good, easy
|
||||
}
|
||||
|
||||
struct Entry: Identifiable {
|
||||
let id = UUID()
|
||||
let verb: Verb
|
||||
var state: CardState
|
||||
}
|
||||
|
||||
private(set) var queue: [Entry]
|
||||
private(set) var learnedCount: Int = 0
|
||||
private let originalVerbs: [Verb]
|
||||
|
||||
init(verbs: [Verb]) {
|
||||
originalVerbs = verbs
|
||||
queue = verbs.map { Entry(verb: $0, state: .new) }
|
||||
}
|
||||
|
||||
/// Resume a persisted group: rebuild the queue from saved (verb, state)
|
||||
/// pairs in order, restoring the learned count. The exact requeue
|
||||
/// positions aren't persisted — the queue order itself is what's saved.
|
||||
init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) {
|
||||
originalVerbs = entries.map(\.verb)
|
||||
queue = entries.map { Entry(verb: $0.verb, state: $0.state) }
|
||||
self.learnedCount = learnedCount
|
||||
}
|
||||
|
||||
/// Current queue (un-graduated cards) in order — for persistence.
|
||||
func snapshot() -> [(verbId: Int, state: CardState)] {
|
||||
queue.map { ($0.verb.id, $0.state) }
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var current: Entry? { queue.first }
|
||||
var isComplete: Bool { queue.isEmpty }
|
||||
var remainingCount: Int { queue.count }
|
||||
|
||||
/// 0–1, climbs as cards graduate. Requeuing a card lowers it slightly but
|
||||
/// it always trends to 1 as the session drains.
|
||||
var progress: Double {
|
||||
let total = learnedCount + queue.count
|
||||
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Answering
|
||||
|
||||
/// Apply a rating to the current card. Returns the `ReviewQuality` to
|
||||
/// record in the long-term SRS *iff* the card graduated; nil if it was
|
||||
/// requeued for more in-session practice.
|
||||
@discardableResult
|
||||
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||||
guard !queue.isEmpty else { return nil }
|
||||
var entry = queue.removeFirst()
|
||||
|
||||
switch rating {
|
||||
// Again/Hard always (re)set the card to `.learning`. A card already in
|
||||
// `.review` therefore drops back a step — an intentional lapse, matching
|
||||
// Anki: missing a card you'd previously passed sends it back through the
|
||||
// learning steps rather than letting it graduate.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the session from the same verb set (re-shuffled) — "Study Again".
|
||||
mutating func restart() {
|
||||
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
|
||||
learnedCount = 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
|
||||
/// cards will be shown before it reappears. Clamps to the queue's end.
|
||||
private mutating func insert(_ entry: Entry, offset: Int) {
|
||||
let idx = min(queue.count, offset)
|
||||
queue.insert(entry, at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session verb pool
|
||||
|
||||
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
|
||||
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
|
||||
/// sitting is bounded — proper SRS behaviour rather than a 100+ card slog.
|
||||
enum VocabVerbPool {
|
||||
|
||||
/// Maximum verbs in one session, from the "Cards per session" setting
|
||||
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
|
||||
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
|
||||
static var sessionCardLimit: Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
static func sessionVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
|
||||
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let pool = levels.isEmpty
|
||||
? store.fetchVerbs()
|
||||
: store.fetchVerbs(selectedLevels: levels)
|
||||
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let cardByVerbId = Dictionary(
|
||||
reviewCards.map { ($0.verbId, $0) },
|
||||
uniquingKeysWith: { existing, _ in existing }
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
var due: [(verb: Verb, dueDate: Date)] = []
|
||||
var fresh: [Verb] = []
|
||||
for verb in pool {
|
||||
if let card = cardByVerbId[verb.id] {
|
||||
if card.dueDate <= now {
|
||||
due.append((verb, card.dueDate))
|
||||
}
|
||||
// Not yet due → intentionally skipped; that's the SRS schedule.
|
||||
} else {
|
||||
fresh.append(verb)
|
||||
}
|
||||
}
|
||||
|
||||
// Most-overdue first, then new verbs (lower rank = more common first).
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
fresh.sort { $0.rank < $1.rank }
|
||||
|
||||
let ordered = due.map(\.verb) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit))
|
||||
}
|
||||
|
||||
/// Verbs the user has already studied at least once (have a
|
||||
/// `VerbReviewCard`), most-recently-studied first. Used by the
|
||||
/// "Review Learned" consolidation pass — ignores due dates and the
|
||||
/// Level filter, and is uncapped: it's a deliberate cram over
|
||||
/// everything you've learned.
|
||||
static func reviewLearnedVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let sorted = reviewCards.sorted {
|
||||
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
|
||||
}
|
||||
|
||||
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
|
||||
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
return sorted.compactMap { byId[$0.verbId] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
|
||||
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
|
||||
enum VocabExampleTenseIds {
|
||||
static let canonical: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Curated YouTube-video lookup for guide + grammar items (Issue #21).
|
||||
/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and
|
||||
/// grammar-note videos by id. The data is static after load; `static let shared`
|
||||
/// lets services access it without environment injection.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class YouTubeVideoStore {
|
||||
|
||||
struct VideoEntry: Codable, Hashable, Sendable, Identifiable {
|
||||
let videoId: String
|
||||
let title: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
static let shared = YouTubeVideoStore()
|
||||
|
||||
private(set) var tenseVideos: [String: VideoEntry] = [:]
|
||||
private(set) var grammarVideos: [String: VideoEntry] = [:]
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// Returns the curated video for a tense guide, or nil if unmapped.
|
||||
func video(forTenseId id: String) -> VideoEntry? {
|
||||
tenseVideos[id]
|
||||
}
|
||||
|
||||
/// Returns the curated video for a grammar note, or nil if unmapped.
|
||||
func video(forGrammarNoteId id: String) -> VideoEntry? {
|
||||
grammarVideos[id]
|
||||
}
|
||||
|
||||
/// All distinct videoIds present in the store. Useful for bulk operations
|
||||
/// like "download all" or cache cleanup.
|
||||
var allVideoIds: Set<String> {
|
||||
Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId))
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[YouTubeVideoStore] bundled youtube_videos.json not found")
|
||||
return
|
||||
}
|
||||
struct Root: Decodable {
|
||||
let tenseGuides: [String: VideoEntry]
|
||||
let grammarNotes: [String: VideoEntry]
|
||||
}
|
||||
do {
|
||||
let root = try JSONDecoder().decode(Root.self, from: data)
|
||||
tenseVideos = root.tenseGuides
|
||||
grammarVideos = root.grammarNotes
|
||||
print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries")
|
||||
} catch {
|
||||
print("[YouTubeVideoStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,11 @@ final class PracticeViewModel {
|
||||
currentSpans = []
|
||||
hasCards = true
|
||||
isLoading = true
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
|
||||
clearCurrentCard()
|
||||
hasCards = false
|
||||
|
||||
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
|
||||
)
|
||||
cloudModelContext.insert(result)
|
||||
try? cloudModelContext.save()
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,16 @@ struct CourseView: View {
|
||||
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
|
||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||
@State private var testResults: [TestResult] = []
|
||||
@State private var extraStudyCounts: [Int: Int] = [:]
|
||||
|
||||
private var textbookCourses: [String] {
|
||||
Array(Set(textbookChapters.map(\.courseName))).sorted()
|
||||
}
|
||||
|
||||
private var activeCourseIsTextbook: Bool {
|
||||
textbookCourses.contains(activeCourse)
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var courseNames: [String] {
|
||||
@@ -169,6 +174,28 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra Study row — only when there are marks for this week
|
||||
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
|
||||
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.yellow)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Extra Study")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -176,10 +203,19 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||
.onAppear(perform: loadTestResults)
|
||||
.onAppear {
|
||||
loadTestResults()
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.onChange(of: activeCourse) { _, _ in
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.navigationDestination(for: CourseDeck.self) { deck in
|
||||
DeckStudyView(deck: deck)
|
||||
}
|
||||
.navigationDestination(for: ExtraStudyDestination.self) { dest in
|
||||
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
@@ -210,6 +246,12 @@ struct CourseView: View {
|
||||
private func loadTestResults() {
|
||||
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||
}
|
||||
|
||||
private func loadExtraStudyCounts() {
|
||||
guard !activeCourse.isEmpty else { return }
|
||||
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
|
||||
.countsByWeek(courseName: activeCourse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
@@ -228,6 +270,11 @@ struct TextbookDestination: Hashable {
|
||||
let courseName: String
|
||||
}
|
||||
|
||||
struct ExtraStudyDestination: Hashable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -5,6 +5,9 @@ import SwiftData
|
||||
struct DeckStudyView: View {
|
||||
let deck: CourseDeck
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var textbookChapters: [TextbookChapter]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var isStudying = false
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var deckCards: [VocabCard] = []
|
||||
@@ -14,6 +17,18 @@ struct DeckStudyView: View {
|
||||
deck.title.localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var isTextbookDeck: Bool {
|
||||
textbookChapters.contains { $0.courseName == deck.courseName }
|
||||
}
|
||||
|
||||
private var markContext: ExtraStudyMarkContext? {
|
||||
guard !isTextbookDeck else { return nil }
|
||||
return ExtraStudyMarkContext(
|
||||
courseName: deck.courseName,
|
||||
weekNumber: deck.weekNumber
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
cardListView
|
||||
.navigationTitle(deck.title)
|
||||
@@ -24,8 +39,12 @@ struct DeckStudyView: View {
|
||||
VocabFlashcardView(
|
||||
cards: deckCards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: { isStudying = false },
|
||||
deckTitle: deck.title
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
isStudying = false
|
||||
},
|
||||
deckTitle: deck.title,
|
||||
markContext: markContext
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Study session containing only cards the user has marked for extra study,
|
||||
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
|
||||
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
|
||||
/// duplicate (deckId, front, back) tuples that differ in examples.
|
||||
struct ExtraStudyView: View {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var loaded = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !loaded {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
} else if cards.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Marked Cards",
|
||||
systemImage: "star",
|
||||
description: Text("Tap the star on a card during study to add it here.")
|
||||
)
|
||||
} else {
|
||||
VocabFlashcardView(
|
||||
cards: cards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
dismiss()
|
||||
},
|
||||
deckTitle: "Extra Study",
|
||||
markContext: ExtraStudyMarkContext(
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Extra Study · Week \(weekNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { load() }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard !loaded else { return }
|
||||
let marks = ExtraStudyStore(context: cloudContext)
|
||||
.fetch(courseName: courseName, weekNumber: weekNumber)
|
||||
let markIds = Set(marks.map(\.id))
|
||||
let deckIds = Set(marks.map(\.deckId))
|
||||
|
||||
var collected: [VocabCard] = []
|
||||
for deckId in deckIds {
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
let deckCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
collected.append(contentsOf: deckCards.filter {
|
||||
markIds.contains(CourseCardStore.reviewKey(for: $0))
|
||||
})
|
||||
}
|
||||
cards = collected
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,7 @@ struct TextbookExerciseView: View {
|
||||
}
|
||||
grades = newGrades
|
||||
isChecked = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,16 @@ struct VocabFlashcardView: View {
|
||||
/// Optional deck context — when present and the title indicates a stem-
|
||||
/// changing deck, each card gets an inline conjugation toggle.
|
||||
var deckTitle: String? = nil
|
||||
/// When set, a star button appears next to the speaker on reveal so the
|
||||
/// user can mark the card for extra study.
|
||||
var markContext: ExtraStudyMarkContext? = nil
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var showConjugation = false
|
||||
@State private var markedIds: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
|
||||
@@ -61,6 +65,7 @@ struct VocabFlashcardView: View {
|
||||
.font(.title.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
@@ -70,6 +75,20 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
|
||||
if markContext != nil {
|
||||
Button {
|
||||
toggleMark()
|
||||
} label: {
|
||||
Image(systemName: currentIsMarked ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
|
||||
}
|
||||
}
|
||||
|
||||
if isStemChangingDeck {
|
||||
Button {
|
||||
withAnimation(.smooth) { showConjugation.toggle() }
|
||||
@@ -194,6 +213,34 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.animation(.smooth, value: isRevealed)
|
||||
.animation(.smooth, value: currentIndex)
|
||||
.task { loadMarks() }
|
||||
}
|
||||
|
||||
private var currentIsMarked: Bool {
|
||||
guard let card = currentCard else { return false }
|
||||
return markedIds.contains(CourseCardStore.reviewKey(for: card))
|
||||
}
|
||||
|
||||
private func loadMarks() {
|
||||
guard let ctx = markContext else { return }
|
||||
markedIds = ExtraStudyStore(context: cloudModelContext)
|
||||
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
|
||||
}
|
||||
|
||||
private func toggleMark() {
|
||||
guard let card = currentCard, let ctx = markContext else { return }
|
||||
let store = ExtraStudyStore(context: cloudModelContext)
|
||||
let isNowMarked = store.toggle(
|
||||
card: card,
|
||||
courseName: ctx.courseName,
|
||||
weekNumber: ctx.weekNumber
|
||||
)
|
||||
let key = CourseCardStore.reviewKey(for: card)
|
||||
if isNowMarked {
|
||||
markedIds.insert(key)
|
||||
} else {
|
||||
markedIds.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
|
||||
@@ -288,7 +288,10 @@ struct DashboardView: View {
|
||||
}
|
||||
|
||||
private func loadData() {
|
||||
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
// Reset a stale streak before rendering so the dashboard never lies.
|
||||
progress.validateStreakIfStale(context: cloudModelContext)
|
||||
userProgress = progress
|
||||
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
||||
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct GrammarExerciseView: View {
|
||||
let noteId: String
|
||||
let noteTitle: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var exercises: [GrammarExercise] = []
|
||||
@State private var currentIndex = 0
|
||||
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
|
||||
struct GrammarNotesView: View {
|
||||
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
|
||||
|
||||
struct GrammarNoteDetailView: View {
|
||||
let note: GrammarNote
|
||||
var onJumpToTense: ((TenseGuide) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
@State private var relatedTenses: [TenseGuide] = []
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forGrammarNoteId: note.id)
|
||||
}
|
||||
|
||||
private func loadRelatedTenses() {
|
||||
guard let container = SharedStore.localContainer else {
|
||||
relatedTenses = []
|
||||
return
|
||||
}
|
||||
let context = ModelContext(container)
|
||||
let guides = ReferenceStore(context: context).fetchGuides()
|
||||
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
|
||||
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
|
||||
if !relatedTenses.isEmpty {
|
||||
relatedTensesSection
|
||||
}
|
||||
|
||||
videoSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Parsed body
|
||||
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
|
||||
}
|
||||
.navigationTitle(note.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadRelatedTenses)
|
||||
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
|
||||
}
|
||||
|
||||
private var relatedTensesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedTenses, id: \.tenseId) { guide in
|
||||
Button {
|
||||
onJumpToTense?(guide)
|
||||
} label: {
|
||||
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.orange.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,15 +41,20 @@ struct GuideView: View {
|
||||
.navigationTitle("Guide")
|
||||
.task { loadGuides() }
|
||||
.onAppear(perform: loadGuides)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
selectedGuide = nil
|
||||
selectedNote = nil
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
// Only clear the *other* tab's selection so programmatic
|
||||
// cross-link jumps (chip taps in the detail pane) can keep
|
||||
// their newly-set selection on the destination tab.
|
||||
switch newTab {
|
||||
case .tenses: selectedNote = nil
|
||||
case .grammar: selectedGuide = nil
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let guide = selectedGuide {
|
||||
GuideDetailView(guide: guide)
|
||||
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
|
||||
} else if let note = selectedNote {
|
||||
GrammarNoteDetailView(note: note)
|
||||
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
|
||||
}
|
||||
@@ -79,6 +84,16 @@ struct GuideView: View {
|
||||
GrammarNotesListView(selectedNote: $selectedNote)
|
||||
}
|
||||
|
||||
private func jumpToNote(_ note: GrammarNote) {
|
||||
selectedTab = .grammar
|
||||
selectedNote = note
|
||||
}
|
||||
|
||||
private func jumpToTense(_ guide: TenseGuide) {
|
||||
selectedTab = .tenses
|
||||
selectedGuide = guide
|
||||
}
|
||||
|
||||
private func loadGuides() {
|
||||
// Hit the shared local container directly, bypassing @Environment.
|
||||
guard let container = SharedStore.localContainer else {
|
||||
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
|
||||
|
||||
struct GuideDetailView: View {
|
||||
let guide: TenseGuide
|
||||
var onJumpToNote: ((GrammarNote) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
|
||||
private var relatedNotes: [GrammarNote] {
|
||||
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
|
||||
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private var tenseInfo: TenseInfo? {
|
||||
TenseInfo.find(guide.tenseId)
|
||||
}
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forTenseId: guide.tenseId)
|
||||
}
|
||||
|
||||
private var endingTable: TenseEndingTable? {
|
||||
TenseEndingTable.find(guide.tenseId)
|
||||
}
|
||||
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Related grammar notes — cross-links into the Grammar tab
|
||||
if !relatedNotes.isEmpty {
|
||||
relatedNotesSection
|
||||
}
|
||||
|
||||
// Video section (Issue #21)
|
||||
videoSection
|
||||
|
||||
// Conjugation ending table
|
||||
if let table = endingTable {
|
||||
conjugationTableSection(table)
|
||||
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Related grammar notes
|
||||
|
||||
private var relatedNotesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Related grammar", systemImage: "book")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedNotes, id: \.id) { note in
|
||||
Button {
|
||||
onJumpToNote?(note)
|
||||
} label: {
|
||||
Text(note.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.indigo.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video (Issue #21)
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -571,4 +651,5 @@ struct GuideExample: Identifiable {
|
||||
#Preview {
|
||||
GuideView()
|
||||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
||||
.environment(YouTubeVideoStore())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AVKit
|
||||
import SharedModels
|
||||
|
||||
/// Three-button row for a curated YouTube video (Issue #21):
|
||||
/// - **Stream** — opens in the YouTube app (falls back to Safari).
|
||||
/// - **Download** — pulls the MP4 via YouTubeKit, shows progress, then enables Play.
|
||||
/// - **Play** — enabled only when the video exists on disk; plays via AVPlayer.
|
||||
///
|
||||
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
|
||||
/// video affordances consistent.
|
||||
struct VideoActionsButtonRow: View {
|
||||
let video: YouTubeVideoStore.VideoEntry
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var isDownloaded: Bool
|
||||
@State private var playerVideoId: String?
|
||||
@State private var downloadError: String?
|
||||
@State private var confirmDelete: Bool = false
|
||||
|
||||
init(video: YouTubeVideoStore.VideoEntry) {
|
||||
self.video = video
|
||||
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
||||
}
|
||||
|
||||
private var activeStatus: VideoDownloadService.DownloadStatus? {
|
||||
downloadService.activeDownloads[video.videoId]
|
||||
}
|
||||
|
||||
private var isDownloading: Bool {
|
||||
activeStatus != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(video.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
streamButton
|
||||
downloadButton
|
||||
playButton
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.fullScreenCover(item: Binding(
|
||||
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
|
||||
set: { playerVideoId = $0?.videoId }
|
||||
)) { id in
|
||||
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
|
||||
}
|
||||
.alert("Download failed", isPresented: .init(
|
||||
get: { downloadError != nil },
|
||||
set: { if !$0 { downloadError = nil } }
|
||||
)) {
|
||||
Button("OK") { downloadError = nil }
|
||||
} message: {
|
||||
Text(downloadError ?? "")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete this downloaded video?",
|
||||
isPresented: $confirmDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
|
||||
isDownloaded = false
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You can re-download it at any time.")
|
||||
}
|
||||
.onAppear {
|
||||
// Refresh on appear in case the user deleted the file via Settings.
|
||||
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Buttons
|
||||
|
||||
private var streamButton: some View {
|
||||
Button {
|
||||
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Stream", systemImage: "play.rectangle.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadButton: some View {
|
||||
// Single slot whose role flips through the download lifecycle:
|
||||
// Download → progress/label (disabled) → Delete.
|
||||
if let status = activeStatus {
|
||||
Button {} label: {
|
||||
VStack(spacing: 4) {
|
||||
if let progress = status.progress {
|
||||
ProgressView(value: progress).frame(width: 56)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text(status.label)
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
.disabled(true)
|
||||
} else if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
confirmDelete = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
} else {
|
||||
Button {
|
||||
Task { await startDownload() }
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.to.line")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
playerVideoId = video.videoId
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.green)
|
||||
.controlSize(.large)
|
||||
.disabled(!isDownloaded)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func startDownload() async {
|
||||
do {
|
||||
try await downloadService.download(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
into: modelContext
|
||||
)
|
||||
isDownloaded = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
} catch {
|
||||
downloadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
|
||||
|
||||
private struct LocalVideoID: Identifiable {
|
||||
let videoId: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
// Stacks the icon above the title so three equal-width buttons fit an iPhone
|
||||
// row without wrapping mid-word.
|
||||
private struct VideoActionLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
configuration.icon
|
||||
.imageScale(.large)
|
||||
configuration.title
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local playback sheet
|
||||
|
||||
struct LocalVideoPlayerSheet: View {
|
||||
let videoId: String
|
||||
let title: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var player: AVPlayer
|
||||
|
||||
init(videoId: String, title: String) {
|
||||
self.videoId = videoId
|
||||
self.title = title
|
||||
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.black, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct BookChapterListView: View {
|
||||
let book: Book
|
||||
|
||||
@Query private var allChapters: [BookChapter]
|
||||
|
||||
init(book: Book) {
|
||||
self.book = book
|
||||
let slug = book.slug
|
||||
_allChapters = Query(
|
||||
filter: #Predicate<BookChapter> { $0.bookSlug == slug },
|
||||
sort: \BookChapter.number
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(allChapters) { chapter in
|
||||
NavigationLink(value: chapter) {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(chapter.number)")
|
||||
.font(.subheadline.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(book.title.prefix(while: { $0 != ":" }).description)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Route value for pushing the books library. Lets `PracticeView` use a
|
||||
/// value-based link so the entire books navigation chain is consistent —
|
||||
/// mixing a view-based push with value-based pushes deeper in the same
|
||||
/// NavigationStack made pushed screens pop back immediately.
|
||||
enum BooksRoute: Hashable {
|
||||
case library
|
||||
}
|
||||
|
||||
struct BookLibraryView: View {
|
||||
@Query(sort: \Book.title) private var books: [Book]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if books.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Books",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("Books bundled with the app will appear here.")
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(books) { book in
|
||||
NavigationLink(value: book) {
|
||||
BookCard(book: book)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Books")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookCard: View {
|
||||
let book: Book
|
||||
|
||||
private var accentColor: Color {
|
||||
Color(hex: book.accentColorHex) ?? .indigo
|
||||
}
|
||||
|
||||
private var shortTitle: String {
|
||||
// Trim "Volume X" subtitle if present — most book titles are way too long.
|
||||
if let colon = book.title.firstIndex(of: ":") {
|
||||
return String(book.title[..<colon])
|
||||
}
|
||||
return book.title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(accentColor.gradient)
|
||||
.frame(width: 48, height: 64)
|
||||
.overlay {
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(shortTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
if !book.author.isEmpty {
|
||||
Text(book.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(book.chapterCount) chapter\(book.chapterCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
init?(hex: String) {
|
||||
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.hasPrefix("#") { s.removeFirst() }
|
||||
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
|
||||
let r = Double((v >> 16) & 0xFF) / 255.0
|
||||
let g = Double((v >> 8) & 0xFF) / 255.0
|
||||
let b = Double(v & 0xFF) / 255.0
|
||||
self = Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
struct BookReaderView: View {
|
||||
let chapter: BookChapter
|
||||
|
||||
/// The book this chapter belongs to, resolved by slug — used for the
|
||||
/// pre-computed glossary. A @Query is safe here because the reader is
|
||||
/// built lazily by `navigationDestination`: one instance, when opened.
|
||||
@Query private var bookMatches: [Book]
|
||||
private var book: Book? { bookMatches.first }
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var speech = BookSpeechController()
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var showEnglish = false
|
||||
@State private var showVoicePicker = false
|
||||
@State private var wasReadingBeforeTap = false
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
/// The book's pre-computed glossary, decoded once on appear.
|
||||
@State private var glossary: [String: WordGloss] = [:]
|
||||
|
||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
let slug = chapter.bookSlug
|
||||
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
||||
}
|
||||
|
||||
private var paragraphsES: [String] { chapter.paragraphsES() }
|
||||
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 18) {
|
||||
Text(chapter.title)
|
||||
.font(.title2.bold())
|
||||
.padding(.bottom, 4)
|
||||
.id(-1)
|
||||
|
||||
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
|
||||
paragraphView(index: index, paragraph: paragraph)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
|
||||
guard let newIndex else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(newIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapter \(chapter.number)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
Image(systemName: "waveform.circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel("Voice & speed")
|
||||
|
||||
Button {
|
||||
toggleReadAloud()
|
||||
} label: {
|
||||
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
||||
|
||||
Button {
|
||||
withAnimation { showEnglish.toggle() }
|
||||
} label: {
|
||||
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
|
||||
WordDetailSheet(word: word)
|
||||
.presentationDetents([.height(220)])
|
||||
}
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
|
||||
}
|
||||
.onAppear {
|
||||
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
||||
speech.rate = Float(storedRate)
|
||||
if glossary.isEmpty {
|
||||
glossary = book?.glossary() ?? [:]
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
speech.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func paragraphView(index: Int, paragraph: String) -> some View {
|
||||
if showEnglish {
|
||||
Text(translation(for: index))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
TappableParagraph(
|
||||
text: paragraph,
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
||||
) { word in
|
||||
handleTap(word: word, paragraph: paragraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func translation(for index: Int) -> String {
|
||||
guard index < paragraphsEN.count else { return "" }
|
||||
let en = paragraphsEN[index]
|
||||
return en.isEmpty ? "[translation unavailable]" : en
|
||||
}
|
||||
|
||||
// MARK: - Read-along controls
|
||||
|
||||
private func toggleReadAloud() {
|
||||
if speech.isReading {
|
||||
speech.stop()
|
||||
} else {
|
||||
// Start from the first non-vocab paragraph at or after the topmost
|
||||
// visible one. For V1 we start from the chapter top — adding
|
||||
// "start from visible paragraph" would need a scroll-position
|
||||
// observer, which isn't worth the complexity yet.
|
||||
speech.start(paragraphs: paragraphsES)
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceBinding: Binding<String?> {
|
||||
Binding(
|
||||
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
||||
set: { newValue in
|
||||
storedVoiceId = newValue ?? ""
|
||||
speech.voiceIdentifier = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
Binding(
|
||||
get: { Float(storedRate) },
|
||||
set: { newValue in
|
||||
storedRate = Double(newValue)
|
||||
speech.rate = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Word tap → definition
|
||||
|
||||
private func handleTap(word: String, paragraph: String) {
|
||||
let cleaned = cleanWord(word)
|
||||
if cleaned.isEmpty { return }
|
||||
|
||||
// If reading aloud, pause immediately. Remember so we can resume when
|
||||
// the user dismisses the definition sheet.
|
||||
if speech.isReading, !speech.isPaused {
|
||||
speech.pause()
|
||||
wasReadingBeforeTap = true
|
||||
}
|
||||
|
||||
// Fall-through chain, best source first. Whichever resource answers,
|
||||
// the popup names it so a curated glossary hit reads differently from
|
||||
// a best-effort on-device LLM guess.
|
||||
if let cached = lookupCache[cleaned] {
|
||||
selectedWord = cached
|
||||
return
|
||||
}
|
||||
if let gloss = glossary[cleaned] {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: gloss.baseForm,
|
||||
english: gloss.english,
|
||||
partOfSpeech: gloss.partOfSpeech,
|
||||
source: "Book glossary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
if let entry = dictionary.lookup(cleaned) {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
source: "Dictionary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
|
||||
Task {
|
||||
do {
|
||||
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
|
||||
annotation.source = "AI guess"
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
} catch {
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSheetDismiss() {
|
||||
guard wasReadingBeforeTap else { return }
|
||||
wasReadingBeforeTap = false
|
||||
speech.resume()
|
||||
}
|
||||
|
||||
private func cleanWord(_ word: String) -> String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tappable paragraph
|
||||
|
||||
private struct TappableParagraph: View {
|
||||
let text: String
|
||||
let highlightedWordIndex: Int?
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
FlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
|
||||
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let isHighlighted: Bool
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(word)
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, isHighlighted ? 2 : 0)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
isHighlighted
|
||||
? Color.yellow.opacity(0.35)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 4)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow layout
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows {
|
||||
height += row.map { $0.height }.max() ?? 0
|
||||
}
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var subviewIndex = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rowHeight = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
subviewIndex += 1
|
||||
}
|
||||
y += rowHeight + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var currentWidth: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
currentWidth = 0
|
||||
}
|
||||
rows[rows.count - 1].append(size)
|
||||
currentWidth += size.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word detail sheet
|
||||
|
||||
private struct WordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !word.source.isEmpty {
|
||||
Text(sourceLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var sourceLabel: String {
|
||||
word.source == "AI guess"
|
||||
? "AI guess · on-device estimate, may be approximate"
|
||||
: "Source: \(word.source)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
|
||||
|
||||
@MainActor
|
||||
private enum WordLookup {
|
||||
@Generable
|
||||
struct WordInfo {
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish dictionary. Given a word and the sentence it appears in, \
|
||||
provide its base form, English translation, and part of speech.
|
||||
""")
|
||||
let response = try await session.respond(
|
||||
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
|
||||
generating: WordInfo.self
|
||||
)
|
||||
let info = response.content
|
||||
return WordAnnotation(
|
||||
word: word,
|
||||
baseForm: info.baseForm,
|
||||
english: info.english,
|
||||
partOfSpeech: info.partOfSpeech
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Voice + speed picker shown from the book reader's toolbar. Lists Spanish
|
||||
/// voices currently installed on the device grouped by quality, and offers a
|
||||
/// shortcut to the iOS Settings app where the user can download premium voices
|
||||
/// (no public deep-link to the Accessibility section exists, so we open the
|
||||
/// app's own Settings page with a hint).
|
||||
struct BookVoicePickerSheet: View {
|
||||
@Binding var voiceIdentifier: String?
|
||||
@Binding var rate: Float
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private struct VoiceGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let voices: [AVSpeechSynthesisVoice]
|
||||
}
|
||||
|
||||
private var groups: [VoiceGroup] {
|
||||
let all = AVSpeechSynthesisVoice.speechVoices()
|
||||
.filter { $0.language.hasPrefix("es") }
|
||||
let buckets: [(String, AVSpeechSynthesisVoiceQuality)] = [
|
||||
("Premium", .premium),
|
||||
("Enhanced", .enhanced),
|
||||
("Default", .default),
|
||||
]
|
||||
return buckets.compactMap { (title, quality) in
|
||||
let voices = all
|
||||
.filter { $0.quality == quality }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.language != rhs.language { return lhs.language < rhs.language }
|
||||
return lhs.name < rhs.name
|
||||
}
|
||||
return voices.isEmpty ? nil : VoiceGroup(id: title, title: title, voices: voices)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Speed") {
|
||||
Picker("Speed", selection: $rate) {
|
||||
Text("Slow").tag(Float(0.40))
|
||||
Text("Normal").tag(Float(0.50))
|
||||
Text("Fast").tag(Float(0.55))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if groups.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No Spanish voices",
|
||||
systemImage: "person.wave.2",
|
||||
description: Text("Install a Spanish voice in Settings → Accessibility → Spoken Content → Voices.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(groups) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.voices, id: \.identifier) { voice in
|
||||
voiceRow(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
openSettings()
|
||||
} label: {
|
||||
Label("Download more voices…", systemImage: "arrow.down.circle")
|
||||
}
|
||||
} footer: {
|
||||
Text("Opens Settings. Navigate to Accessibility → Spoken Content → Voices → Spanish to install premium or enhanced voices.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Read aloud")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceRow(_ voice: AVSpeechSynthesisVoice) -> some View {
|
||||
Button {
|
||||
voiceIdentifier = voice.identifier
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(voice.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(voice.language)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if voice.identifier == voiceIdentifier {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@ struct ChatView: View {
|
||||
messages = conversation.decodedMessages
|
||||
inputText = ""
|
||||
try? cloudContext.save()
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
|
||||
Task {
|
||||
do {
|
||||
|
||||
@@ -98,6 +98,7 @@ struct ClozeView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -20,6 +20,7 @@ struct FullTableView: View {
|
||||
@State private var useHandwriting = false
|
||||
@State private var sessionCount = 0
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var noEligibleVerbs = false
|
||||
|
||||
// Handwriting state per field
|
||||
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
|
||||
@@ -53,6 +54,9 @@ struct FullTableView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if noEligibleVerbs {
|
||||
emptyPoolError
|
||||
} else {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
@@ -83,6 +87,7 @@ struct FullTableView: View {
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Full Table")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
@@ -91,6 +96,22 @@ struct FullTableView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty pool error
|
||||
|
||||
private var emptyPoolError: some View {
|
||||
VStack(spacing: 16) {
|
||||
ContentUnavailableView(
|
||||
"No regular verbs available",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(
|
||||
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
|
||||
@@ -243,15 +264,27 @@ struct FullTableView: View {
|
||||
results = Array(repeating: nil, count: 6)
|
||||
correctForms = []
|
||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
guard let prompt = service.randomFullTablePrompt() else {
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let prompt = service.randomFullTablePrompt(
|
||||
previousTenseId: currentTense?.id,
|
||||
previousEnding: currentVerb?.ending
|
||||
) else {
|
||||
// Genuinely no eligible (verb, tense) combo. Surface a clear error
|
||||
// instead of a blank screen — the previous behaviour silently
|
||||
// rendered an empty header and inputs.
|
||||
currentVerb = nil
|
||||
currentTense = nil
|
||||
userAnswers = Array(repeating: "", count: 6)
|
||||
focusedField = nil
|
||||
noEligibleVerbs = true
|
||||
return
|
||||
}
|
||||
|
||||
noEligibleVerbs = false
|
||||
currentVerb = prompt.verb
|
||||
currentTense = prompt.tenseInfo
|
||||
correctForms = prompt.forms
|
||||
@@ -312,7 +345,11 @@ struct FullTableView: View {
|
||||
if allCorrect { sessionCorrect += 1 }
|
||||
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct ListeningView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var pronunciation = PronunciationService()
|
||||
@State private var speechService = SpeechService()
|
||||
|
||||
@@ -122,6 +124,7 @@ struct ListeningView: View {
|
||||
Button {
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Check")
|
||||
@@ -164,6 +167,7 @@ struct ListeningView: View {
|
||||
score = result.score
|
||||
wordMatches = result.matches
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} else {
|
||||
pronunciation.startRecording()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var selectedWord: LyricsWordLookup?
|
||||
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||
|
||||
@@ -98,6 +101,7 @@ struct LyricsReaderView: View {
|
||||
return LyricsFlowLayout(spacing: 0) {
|
||||
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
selectedWord = word
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@ struct PracticeView: View {
|
||||
practiceHomeView
|
||||
}
|
||||
}
|
||||
// Book navigation is value-based and declared once here, at the
|
||||
// stack root. Eager `NavigationLink { destination }` forms inside
|
||||
// the List/LazyVStack of the book screens caused an infinite
|
||||
// render loop; value-based links build destinations lazily.
|
||||
.navigationDestination(for: BooksRoute.self) { _ in
|
||||
BookLibraryView()
|
||||
}
|
||||
.navigationDestination(for: Book.self) { book in
|
||||
BookChapterListView(book: book)
|
||||
}
|
||||
.navigationDestination(for: BookChapter.self) { chapter in
|
||||
BookReaderView(chapter: chapter)
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
@@ -74,12 +87,10 @@ struct PracticeView: View {
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// Mode selection
|
||||
VStack(spacing: 12) {
|
||||
Text("Choose a Mode")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// === Section: Conjugation ===
|
||||
sectionHeader("Conjugation")
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(PracticeMode.allCases) { mode in
|
||||
ModeButton(mode: mode) {
|
||||
viewModel.practiceMode = mode
|
||||
@@ -98,6 +109,15 @@ struct PracticeView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
conjugationFocusButtons
|
||||
|
||||
// === Section: Vocabulary ===
|
||||
sectionHeader("Vocabulary")
|
||||
vocabSection
|
||||
|
||||
// === Section: Reading ===
|
||||
sectionHeader("Reading")
|
||||
|
||||
// Lyrics
|
||||
NavigationLink {
|
||||
LyricsLibraryView()
|
||||
@@ -253,13 +273,161 @@ struct PracticeView: View {
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
// Books
|
||||
NavigationLink(value: BooksRoute.library) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.indigo)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Books")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read full-length books with tap-to-define")
|
||||
.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
|
||||
if viewModel.sessionTotal > 0 && !isPracticing {
|
||||
VStack(spacing: 8) {
|
||||
Text("Last Session")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Vocab review
|
||||
HStack(spacing: 20) {
|
||||
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
|
||||
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
|
||||
StatItem(
|
||||
label: "Accuracy",
|
||||
value: "\(Int(viewModel.sessionAccuracy * 100))%"
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
.adaptiveContainer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section header
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
|
||||
|
||||
private var conjugationFocusButtons: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Common Tenses
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
subtitle: "Practice the 6 most essential tenses")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak Verbs
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
|
||||
title: "Weak Verbs",
|
||||
subtitle: "Focus on verbs you struggle with")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
|
||||
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "wand.and.stars", color: .purple,
|
||||
title: "Irregularity Drills",
|
||||
subtitle: "Practice by irregularity type")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Vocabulary section
|
||||
|
||||
private var vocabSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Vocab Flashcards (verb pool, filtered by Settings levels)
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
subtitle: "Verb meaning → infinitive recall")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Vocab Multiple Choice (same verb pool)
|
||||
NavigationLink {
|
||||
VocabMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
subtitle: "Pick the Spanish infinitive from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Review Learned — consolidation cram over already-studied verbs
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
|
||||
title: "Review Learned",
|
||||
subtitle: "Re-review verbs you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Existing: Vocab Review (due cards)
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
} label: {
|
||||
@@ -298,148 +466,33 @@ struct PracticeView: View {
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Common tenses focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Common Tenses")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice the 6 most essential tenses")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak verbs focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Weak Verbs")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Focus on verbs you struggle with")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") {
|
||||
startIrregularityDrill(.spelling)
|
||||
}
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") {
|
||||
startIrregularityDrill(.stemChange)
|
||||
}
|
||||
Button("Unique Irregulars (ser, ir, ...)") {
|
||||
startIrregularityDrill(.uniqueIrregular)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.purple)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Irregularity Drills")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice by irregularity type")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Session stats summary
|
||||
if viewModel.sessionTotal > 0 && !isPracticing {
|
||||
VStack(spacing: 8) {
|
||||
Text("Last Session")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
StatItem(label: "Reviewed", value: "\(viewModel.sessionTotal)")
|
||||
StatItem(label: "Correct", value: "\(viewModel.sessionCorrect)")
|
||||
StatItem(
|
||||
label: "Accuracy",
|
||||
value: "\(Int(viewModel.sessionAccuracy * 100))%"
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical)
|
||||
.adaptiveContainer()
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Practice Session View
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct SentenceBuilderView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentCard: VocabCard?
|
||||
@State private var exampleIndex: Int = 0
|
||||
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
|
||||
if isCorrect {
|
||||
sessionCorrect += 1
|
||||
}
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
|
||||
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct StoryQuizView: View {
|
||||
let story: Story
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb flashcards with two modes, switched from the toolbar:
|
||||
///
|
||||
/// - **Quiz** — the SRS path. `VocabSessionQueue` learning-step queue:
|
||||
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
|
||||
/// feeds the long-term `VerbReviewStore` schedule.
|
||||
/// - **Learn** — no-pressure browsing. Both sides shown at once (English +
|
||||
/// Spanish + example), Next/Previous step through the same session pool
|
||||
/// on a loop. No rating, no SRS side effects.
|
||||
/// Which pool a flashcard session draws from.
|
||||
enum VocabSessionKind {
|
||||
/// Due-first + new verbs, capped — the standard SRS session. Ratings
|
||||
/// update the long-term schedule.
|
||||
case standard
|
||||
/// Verbs already studied at least once, most-recent first, uncapped — a
|
||||
/// consolidation cram. Ratings drive the in-session queue only and do NOT
|
||||
/// reschedule (the long-term SM-2 due dates are left untouched).
|
||||
case reviewLearned
|
||||
}
|
||||
|
||||
struct VocabFlashcardPracticeView: View {
|
||||
enum Mode: String { case quiz, learn }
|
||||
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var learnIndex: Int = 0
|
||||
@State private var revealed: Bool = false
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// The session's un-graduated verbs, derived live from the quiz queue so
|
||||
/// Learn mode walks exactly what's left to learn — it stays in sync as
|
||||
/// quiz mode graduates cards rather than browsing a stale frozen pool.
|
||||
private var sessionVerbs: [Verb] {
|
||||
session?.queue.map(\.verb) ?? []
|
||||
}
|
||||
|
||||
private var mode: Mode {
|
||||
get { Mode(rawValue: modeRaw) ?? .quiz }
|
||||
nonmutating set { modeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
private var currentVerb: Verb? {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
return session?.current?.verb
|
||||
case .learn:
|
||||
guard !sessionVerbs.isEmpty else { return nil }
|
||||
return sessionVerbs[learnIndex % sessionVerbs.count]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerBar
|
||||
switch mode {
|
||||
case .quiz:
|
||||
quizContent
|
||||
case .learn:
|
||||
learnContent
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Picker("Mode", selection: Binding(
|
||||
get: { mode },
|
||||
set: { mode = $0 }
|
||||
)) {
|
||||
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
|
||||
Label("Learn", systemImage: "book").tag(Mode.learn)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
mode == .learn ? "Learn" : "Quiz",
|
||||
systemImage: mode == .learn ? "book" : "checklist"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.onChange(of: modeRaw) { _, _ in
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
.animation(.smooth, value: revealed)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(quizProgressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .learn:
|
||||
if sessionVerbs.isEmpty {
|
||||
Text(emptyPoolMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if kind == .reviewLearned {
|
||||
Text("Practice pass — your review schedule won't change.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyPoolMessage: String {
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs match the levels enabled in Settings"
|
||||
case .reviewLearned:
|
||||
return "Nothing studied yet — finish a Vocab Flashcards session first"
|
||||
}
|
||||
}
|
||||
|
||||
private var quizProgressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Quiz mode
|
||||
|
||||
@ViewBuilder
|
||||
private var quizContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
quizRevealed(verb)
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
|
||||
private var tapToReveal: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hand.tap")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Tap to reveal")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 200)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.smooth) { revealed = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func quizRevealed(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text(completionTitle)
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label(
|
||||
kind == .reviewLearned ? "Study Again" : "Next Set",
|
||||
systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
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
|
||||
switch kind {
|
||||
case .standard:
|
||||
return learned > 0 ? "Session Complete" : "Nothing Due"
|
||||
case .reviewLearned:
|
||||
return learned > 0 ? "Review Complete" : "Nothing to Review"
|
||||
}
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
let noun = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish a Vocab Flashcards session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Learn mode
|
||||
|
||||
@ViewBuilder
|
||||
private var learnContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
learnStep(-1)
|
||||
} label: {
|
||||
Label("Previous", systemImage: "chevron.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.secondary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Button {
|
||||
learnStep(1)
|
||||
} label: {
|
||||
Label("Next", systemImage: "chevron.right")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.purple)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Nothing to Learn",
|
||||
systemImage: "book",
|
||||
description: Text("Enable some verb levels in Settings.")
|
||||
)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func learnStep(_ delta: Int) {
|
||||
guard !sessionVerbs.isEmpty else { return }
|
||||
let count = sessionVerbs.count
|
||||
learnIndex = ((learnIndex + delta) % count + count) % count
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
// MARK: - Shared card pieces
|
||||
|
||||
private func spanishRow(_ verb: Verb) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(10)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
switch kind {
|
||||
case .standard:
|
||||
loadStandardSession()
|
||||
case .reviewLearned:
|
||||
let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
}
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
/// Resume the persisted, cross-device study group if one is active;
|
||||
/// otherwise start a fresh group and persist it.
|
||||
private func loadStandardSession() {
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if let group = store.activeGroup() {
|
||||
let stored = group.entries
|
||||
if !stored.isEmpty {
|
||||
let byId = verbsByID(Set(stored.map(\.verbId)))
|
||||
let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in
|
||||
guard let verb = byId[e.verbId] else { return nil }
|
||||
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||
}
|
||||
// Resume only if EVERY stored verb resolved. A partial resume
|
||||
// would desync learnedCount from a shrunken queue and then
|
||||
// persist that loss — fall through to a fresh rebuild instead.
|
||||
if entries.count == stored.count {
|
||||
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active group (or it couldn't be fully resolved) — start fresh and
|
||||
// persist immediately so closing the app right away still resumes this set.
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
}
|
||||
|
||||
private func verbsByID(_ ids: Set<Int>) -> [Int: Verb] {
|
||||
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
|
||||
var map: [Int: Verb] = [:]
|
||||
for verb in all where ids.contains(verb.id) { map[verb.id] = verb }
|
||||
return map
|
||||
}
|
||||
|
||||
/// Write the standard session's progress to the cloud-synced study group,
|
||||
/// or clear the group when the set is fully learned.
|
||||
private func persistGroup() {
|
||||
guard kind == .standard, let session else { return }
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if session.isComplete {
|
||||
store.clear()
|
||||
} else {
|
||||
let entries = session.snapshot().map {
|
||||
StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue)
|
||||
}
|
||||
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .standard:
|
||||
// Clear the finished group explicitly before building a fresh one,
|
||||
// so a fresh set is never appended onto a stale group record.
|
||||
VocabStudyGroupStore(context: cloudContext).clear()
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
}
|
||||
learnIndex = 0
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term SM-2 schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
persistGroup()
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {
|
||||
// Silent — the example block just stays hidden.
|
||||
}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
|
||||
/// (1 correct + 3 random distractors from the session pool). After answering:
|
||||
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
|
||||
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
|
||||
struct VocabMultipleChoicePracticeView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var distractorPool: [Verb] = []
|
||||
@State private var options: [Verb] = []
|
||||
@State private var selectedOption: Verb? = nil
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentVerb: Verb? { session?.current?.verb }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let verb = currentVerb {
|
||||
questionBody(verb)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle("Vocab Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(progressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Question
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ verb: Verb) -> some View {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(verb)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionGrid: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(option.infinitive)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ verb: Verb) -> some View {
|
||||
let correct = (selectedOption?.id == verb.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
HStack(spacing: 10) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.body)
|
||||
.padding(8)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label("Study Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
|
||||
}
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
distractorPool = verbs
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
session?.restart()
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let verb = currentVerb else { options = []; return }
|
||||
let candidates = distractorPool.filter { $0.id != verb.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([verb] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
if let graduation {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let store = CourseReviewStore(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Lists downloaded YouTube videos with per-item deletion and total-size
|
||||
/// summary (Issue #21, phase 4). Files live in the local (non-synced)
|
||||
/// SwiftData container and the app's documents directory.
|
||||
struct DownloadedVideosView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \DownloadedVideo.downloadedAt, order: .reverse)
|
||||
private var downloads: [DownloadedVideo]
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var confirmDeleteAll = false
|
||||
|
||||
private var totalBytes: Int {
|
||||
downloads.reduce(0) { $0 + $1.byteCount }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if downloads.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No downloads",
|
||||
systemImage: "arrow.down.to.line",
|
||||
description: Text("Tap Download on any guide or grammar video to save it for offline viewing.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
LabeledContent("Total size", value: sizeString(totalBytes))
|
||||
if totalBytes > 500_000_000 {
|
||||
Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Videos") {
|
||||
ForEach(downloads) { download in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(download.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(2)
|
||||
HStack {
|
||||
Text(sizeString(download.byteCount))
|
||||
Text("·")
|
||||
Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
confirmDeleteAll = true
|
||||
} label: {
|
||||
Label("Delete all downloads", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Downloaded Videos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Delete all \(downloads.count) downloaded videos?",
|
||||
isPresented: $confirmDeleteAll,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
for download in downloads {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private func sizeString(_ bytes: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
.modelContainer(for: DownloadedVideo.self, inMemory: true)
|
||||
}
|
||||
@@ -3,15 +3,17 @@ import SwiftUI
|
||||
struct FeatureReferenceView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Verb Conjugation Practice") {
|
||||
// MARK: Conjugation
|
||||
|
||||
Section("Practice — Conjugation") {
|
||||
featureRow(
|
||||
icon: "rectangle.stack", color: .blue,
|
||||
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
|
||||
details: [
|
||||
"Pulls from verb conjugation database (1,750 verbs)",
|
||||
"Pulls from the verb conjugation database (1,750 verbs)",
|
||||
"Filtered by your Level setting",
|
||||
"Filtered by your Enabled Tenses",
|
||||
"Respects Include Vosotros setting",
|
||||
"Respects the Include Vosotros setting",
|
||||
"Due cards (SRS) shown first, then random",
|
||||
]
|
||||
)
|
||||
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
|
||||
title: "Full Table",
|
||||
details: [
|
||||
"Shows all 6 person forms for one verb + tense",
|
||||
"Random verb from your Level",
|
||||
"Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
|
||||
"Random tense from your Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Quick Actions") {
|
||||
featureRow(
|
||||
icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
|
||||
"Filtered by your Level and Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Vocabulary
|
||||
|
||||
Section("Practice — Vocabulary") {
|
||||
featureRow(
|
||||
icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
details: [
|
||||
"English meaning → recall the Spanish verb",
|
||||
"Pool = verbs at your enabled Levels (the same Level set the Verbs tab filters by)",
|
||||
"Session size set by Settings → Cards per session",
|
||||
"Overdue verbs pulled first, then new verbs by frequency",
|
||||
"Quiz mode: tap to reveal, rate Again/Hard/Good/Easy. Again/Hard requeue the card a few cards later; a second Good or an Easy graduates it. Graduation updates the long-term SRS schedule.",
|
||||
"Learn mode (toolbar toggle): both sides shown at once, Next/Previous to browse, loops — no rating, no pressure",
|
||||
"Example sentence generated on-device; speaker button reads the verb aloud",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
details: [
|
||||
"English meaning → pick the Spanish verb from 4 options",
|
||||
"Distractors prefer the same part of speech",
|
||||
"Same level-filtered pool and SRS session queue as Vocab Flashcards",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "rectangle.stack.fill", color: .teal,
|
||||
title: "Vocab Review",
|
||||
details: [
|
||||
"Reviews vocabulary cards that are due (SRS scheduled)",
|
||||
"Reviews course/textbook vocabulary cards that are due (SRS scheduled)",
|
||||
"Cards become due after you study them in Course quizzes",
|
||||
"Rate Again/Hard/Good/Easy to schedule next review",
|
||||
"Uses all course vocabulary, not filtered by level",
|
||||
"Rate Again/Hard/Good/Easy to schedule the next review",
|
||||
"Distinct from Vocab Flashcards — this is course vocab, not the verb table",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Practice Activities") {
|
||||
// MARK: Reading
|
||||
|
||||
Section("Practice — Reading") {
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for a definition",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "books.vertical.fill", color: .indigo,
|
||||
title: "Books",
|
||||
details: [
|
||||
"Full-length bilingual books bundled with the app",
|
||||
"Chapter list; read a chapter paragraph by paragraph",
|
||||
"Tap any word for a definition (offline dictionary, on-device AI fallback)",
|
||||
"Toggle between Spanish and pre-translated English",
|
||||
"Read-aloud: a voice reads the chapter with the current word highlighted; tap a word to pause and look it up",
|
||||
"Voice and speed picker in the read-aloud controls",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English",
|
||||
"Long-press a word for a definition",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "bubble.left.and.bubble.right.fill", color: .green,
|
||||
title: "Conversation",
|
||||
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
|
||||
"10 scenario types (restaurant, directions, etc.)",
|
||||
"AI adapts vocabulary to your Level setting",
|
||||
"Corrections provided inline when you make mistakes",
|
||||
"Conversations saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
|
||||
"Listen & Type: hear a sentence, type what you heard",
|
||||
"Pronunciation: read a sentence aloud, get scored on accuracy",
|
||||
"Sentences pulled from course vocabulary examples",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
"Pronunciation requires microphone permission",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -101,50 +164,23 @@ struct FeatureReferenceView: View {
|
||||
title: "Cloze Practice",
|
||||
details: [
|
||||
"Fill in the missing word in a Spanish sentence",
|
||||
"Sentences from course vocabulary examples",
|
||||
"4 multiple-choice options (1 correct + 3 distractors)",
|
||||
"Distractors are other vocabulary words from same pool",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English translations",
|
||||
"User-curated library, not filtered by level",
|
||||
"Saved to iCloud for sync across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for definition",
|
||||
"Known words use offline dictionary (175K+ verb forms)",
|
||||
"Unknown words looked up via on-device AI",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Sentences from course vocabulary examples",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Guide
|
||||
|
||||
Section("Guide") {
|
||||
featureRow(
|
||||
icon: "book", color: .brown,
|
||||
title: "Tense Guides",
|
||||
details: [
|
||||
"Detailed explanation of each of the 20 verb tenses",
|
||||
"Conjugation ending tables for -ar, -er, -ir verbs",
|
||||
"Usage patterns with example sentences",
|
||||
"Essential tenses marked with orange badge",
|
||||
"In-depth guide to each of the 20 verb tenses",
|
||||
"Conjugation ending tables, common irregulars, mnemonics",
|
||||
"Usage patterns, pitfalls, and contrast with neighbouring tenses",
|
||||
"Essential tenses marked with an orange badge",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
|
||||
icon: "doc.text", color: .brown,
|
||||
title: "Grammar Notes",
|
||||
details: [
|
||||
"23 grammar topics (ser vs estar, por vs para, etc.)",
|
||||
"Interactive exercises available for 5 topics",
|
||||
"Tap 'Practice This' on notes that have exercises",
|
||||
"Content grouped by category with card-based layout",
|
||||
"36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
|
||||
"Each with a mnemonic, contrast examples, and common pitfalls",
|
||||
"Interactive exercises available on selected topics",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "arrow.triangle.branch", color: .indigo,
|
||||
title: "Cross-links",
|
||||
details: [
|
||||
"Tense guides show \"Related grammar\" chips that jump to the matching grammar note",
|
||||
"Grammar notes show \"Used in tenses\" chips that jump back",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Course
|
||||
|
||||
Section("Course") {
|
||||
featureRow(
|
||||
icon: "list.clipboard", color: .orange,
|
||||
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
|
||||
details: [
|
||||
"Vocabulary from specific course weeks",
|
||||
"Multiple quiz types: MC, typing, handwriting, cloze",
|
||||
"Focus Area mode for missed words",
|
||||
"Not filtered by Level (uses course structure)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "star.circle.fill", color: .yellow,
|
||||
title: "Extra Study",
|
||||
details: [
|
||||
"Star a card during a course flashcard session to mark it for extra study",
|
||||
"Each week shows an \"Extra Study\" row when it has starred cards",
|
||||
"Launches a session of just the starred cards for that week",
|
||||
"Marks are iCloud-synced across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checkmark.seal", color: .orange,
|
||||
title: "Checkpoint Exams",
|
||||
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Dashboard
|
||||
|
||||
Section("Dashboard") {
|
||||
featureRow(
|
||||
icon: "clock.fill", color: .mint,
|
||||
title: "Study Time",
|
||||
details: [
|
||||
"Tracks time the app is in the foreground",
|
||||
"Starts when app becomes active, stops on background",
|
||||
"Shows today's time and all-time total",
|
||||
"Shows today's time and an all-time total",
|
||||
"7-day bar chart of daily study time",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
Section("Settings That Affect Practice") {
|
||||
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
|
||||
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
|
||||
settingRow(name: "Level", affects: "Conjugation practice, Vocab Flashcards & Multiple Choice, Stories, Conversation. Shared with the Verbs tab filter. Full Table ignores level.")
|
||||
settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Conjugation practice, Full Table, Common Tenses")
|
||||
settingRow(name: "Cards per session", affects: "How many verbs a Vocab Flashcards / Multiple Choice session draws")
|
||||
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ struct SettingsView: View {
|
||||
@State private var showVosotros: Bool = true
|
||||
@State private var autoFillStem: Bool = false
|
||||
|
||||
/// Cards per vocab-practice session. 999 = "All" (no cap).
|
||||
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
|
||||
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.spelling, .stemChange, .uniqueIrregular
|
||||
@@ -42,6 +46,18 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Cards per session", selection: $vocabSessionCardLimit) {
|
||||
ForEach(vocabSessionSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Vocab Flashcards")
|
||||
} footer: {
|
||||
Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
@@ -97,6 +113,20 @@ struct SettingsView: View {
|
||||
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Reflexive verbs only", isOn: Binding(
|
||||
get: { progress?.showReflexiveVerbsOnly ?? false },
|
||||
set: { enabled in
|
||||
progress?.showReflexiveVerbsOnly = enabled
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
|
||||
}
|
||||
|
||||
Section("Stats") {
|
||||
if let progress {
|
||||
LabeledContent("Total Reviewed", value: "\(progress.totalReviewed)")
|
||||
@@ -109,6 +139,9 @@ struct SettingsView: View {
|
||||
NavigationLink("How Features Work") {
|
||||
FeatureReferenceView()
|
||||
}
|
||||
NavigationLink("Downloaded Videos") {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
|
||||
@@ -4,14 +4,40 @@ import SwiftData
|
||||
|
||||
struct VerbDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var speechService = SpeechService()
|
||||
let verb: Verb
|
||||
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
|
||||
|
||||
@State private var examples: [VerbExample] = []
|
||||
@State private var examplesState: ExamplesState = .idle
|
||||
|
||||
private enum ExamplesState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case unavailable
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private static let exampleTenseIds: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
|
||||
private var formsForTense: [VerbForm] {
|
||||
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
|
||||
}
|
||||
|
||||
private var reflexiveEntries: [ReflexiveVerb] {
|
||||
reflexiveStore.entries(for: verb.infinitive)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
|
||||
Text("Info")
|
||||
}
|
||||
|
||||
if !reflexiveEntries.isEmpty {
|
||||
reflexiveSection
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Tense", selection: $selectedTense) {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
|
||||
} header: {
|
||||
Text("Conjugation")
|
||||
}
|
||||
|
||||
examplesSection
|
||||
}
|
||||
.navigationTitle(verb.infinitive)
|
||||
.toolbar {
|
||||
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: verb.id) {
|
||||
await loadExamples()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reflexive
|
||||
|
||||
private var reflexiveSection: some View {
|
||||
Section {
|
||||
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(entry.infinitive)
|
||||
.font(.body.weight(.semibold))
|
||||
.italic()
|
||||
if let hint = entry.usageHint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
Text(entry.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
|
||||
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Examples
|
||||
|
||||
@ViewBuilder
|
||||
private var examplesSection: some View {
|
||||
Section {
|
||||
switch examplesState {
|
||||
case .idle, .loading:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Generating examples…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .unavailable:
|
||||
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .failed(let message):
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .loaded:
|
||||
if examples.isEmpty {
|
||||
Label("No examples available.", systemImage: "text.quote")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let info = TenseInfo.find(example.tenseId) {
|
||||
Text(info.english)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text(example.spanish)
|
||||
.font(.body)
|
||||
.italic()
|
||||
Text(example.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Examples")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExamples() async {
|
||||
// Reset state when navigating between verbs via NavigationSplitView.
|
||||
examples = []
|
||||
examplesState = .idle
|
||||
|
||||
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
|
||||
examples = cached
|
||||
examplesState = .loaded
|
||||
return
|
||||
}
|
||||
|
||||
guard VerbExampleGenerator.isAvailable else {
|
||||
examplesState = .unavailable
|
||||
return
|
||||
}
|
||||
|
||||
examplesState = .loading
|
||||
do {
|
||||
let formsByTense = ReferenceStore(context: modelContext)
|
||||
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
|
||||
let generated = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: verb.infinitive,
|
||||
verbEnglish: verb.english,
|
||||
tenseIds: Self.exampleTenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
guard !generated.isEmpty else {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
return
|
||||
}
|
||||
exampleCache.setExamples(generated, for: verb.id)
|
||||
examples = generated
|
||||
examplesState = .loaded
|
||||
} catch {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +241,6 @@ struct VerbDetailView: View {
|
||||
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
|
||||
}
|
||||
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
|
||||
.environment(VerbExampleCache())
|
||||
.environment(ReflexiveVerbStore())
|
||||
}
|
||||
|
||||
@@ -22,17 +22,38 @@ enum IrregularityCategory: String, CaseIterable, Identifiable {
|
||||
|
||||
struct VerbListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var verbs: [Verb] = []
|
||||
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
|
||||
@State private var searchText = ""
|
||||
@State private var selectedLevel: String?
|
||||
@State private var progress: UserProgress?
|
||||
@State private var selectedIrregularity: IrregularityCategory?
|
||||
@State private var reflexiveOnly: Bool = false
|
||||
@State private var selectedVerb: Verb?
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// Levels currently enabled in `UserProgress` — the same set that drives
|
||||
/// what Practice picks from. The Verbs tab reads and writes the same
|
||||
/// state so changes here propagate to Practice and vice versa.
|
||||
private var selectedLevels: Set<VerbLevel> {
|
||||
progress?.selectedVerbLevels ?? []
|
||||
}
|
||||
|
||||
/// True when the user has every available level enabled (or none, which
|
||||
/// we treat as "no filter applied" on the Verbs list specifically).
|
||||
private var allLevelsActive: Bool {
|
||||
selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count
|
||||
}
|
||||
|
||||
private var filteredVerbs: [Verb] {
|
||||
var result = verbs
|
||||
if let level = selectedLevel {
|
||||
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
|
||||
if !allLevelsActive {
|
||||
let activeRaws = Set(selectedLevels.map(\.rawValue))
|
||||
result = result.filter { verb in
|
||||
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
|
||||
}
|
||||
}
|
||||
if let category = selectedIrregularity {
|
||||
result = result.filter { verb in
|
||||
@@ -40,6 +61,9 @@ struct VerbListView: View {
|
||||
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
let query = searchText.lowercased()
|
||||
result = result.filter {
|
||||
@@ -50,7 +74,7 @@ struct VerbListView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"]
|
||||
private let levels: [VerbLevel] = VerbLevel.allCases
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
@@ -71,15 +95,15 @@ struct VerbListView: View {
|
||||
Menu {
|
||||
Section("Level") {
|
||||
Button {
|
||||
selectedLevel = nil
|
||||
setAllLevels(enabled: true)
|
||||
} label: {
|
||||
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "")
|
||||
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button {
|
||||
selectedLevel = level
|
||||
toggleLevel(level)
|
||||
} label: {
|
||||
Label(level.capitalized, systemImage: selectedLevel == level ? "checkmark" : "")
|
||||
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +112,7 @@ struct VerbListView: View {
|
||||
Button {
|
||||
selectedIrregularity = nil
|
||||
} label: {
|
||||
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "")
|
||||
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(IrregularityCategory.allCases) { category in
|
||||
Button {
|
||||
@@ -98,13 +122,27 @@ struct VerbListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Reflexive") {
|
||||
Button {
|
||||
reflexiveOnly.toggle()
|
||||
} label: {
|
||||
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadVerbs() }
|
||||
.onAppear { loadVerbs() }
|
||||
.task {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
.onAppear {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
} detail: {
|
||||
if let verb = selectedVerb {
|
||||
VerbDetailView(verb: verb)
|
||||
@@ -115,15 +153,15 @@ struct VerbListView: View {
|
||||
}
|
||||
|
||||
private var hasActiveFilter: Bool {
|
||||
selectedLevel != nil || selectedIrregularity != nil
|
||||
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var activeFilterBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
if let level = selectedLevel {
|
||||
filterChip(text: level.capitalized, systemImage: "graduationcap") {
|
||||
selectedLevel = nil
|
||||
if !allLevelsActive {
|
||||
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
|
||||
setAllLevels(enabled: true)
|
||||
}
|
||||
}
|
||||
if let cat = selectedIrregularity {
|
||||
@@ -131,6 +169,11 @@ struct VerbListView: View {
|
||||
selectedIrregularity = nil
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
|
||||
reflexiveOnly = false
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(filteredVerbs.count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
@@ -174,6 +217,40 @@ struct VerbListView: View {
|
||||
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
|
||||
}
|
||||
|
||||
// MARK: - Level filter (shared with Practice via UserProgress)
|
||||
|
||||
private var levelChipLabel: String {
|
||||
let names = selectedLevels
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
.map(\.displayName)
|
||||
if names.isEmpty { return "No levels" }
|
||||
if names.count == 1 { return names[0] }
|
||||
return "\(names.count) levels"
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
}
|
||||
|
||||
private func toggleLevel(_ level: VerbLevel) {
|
||||
guard let progress else { return }
|
||||
let enabled = !progress.selectedVerbLevels.contains(level)
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func setAllLevels(enabled: Bool) {
|
||||
guard let progress else { return }
|
||||
if enabled {
|
||||
progress.selectedVerbLevels = Set(VerbLevel.allCases)
|
||||
} else {
|
||||
// Practice treats an empty set as "no verbs", so guard against
|
||||
// leaving the user with nothing — keep at least `basic`.
|
||||
progress.selectedVerbLevels = [.basic]
|
||||
}
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
|
||||
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
|
||||
var index: [Int: Set<IrregularityCategory>] = [:]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
[
|
||||
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
|
||||
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
|
||||
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
|
||||
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
|
||||
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
|
||||
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
|
||||
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
|
||||
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
|
||||
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
|
||||
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
|
||||
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
|
||||
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
|
||||
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
|
||||
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
|
||||
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
|
||||
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
|
||||
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
|
||||
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
|
||||
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
|
||||
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
|
||||
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
|
||||
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
|
||||
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
|
||||
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
|
||||
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
|
||||
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
|
||||
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
|
||||
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
|
||||
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
|
||||
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
|
||||
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
|
||||
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
|
||||
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
|
||||
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
|
||||
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
|
||||
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
|
||||
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
|
||||
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
|
||||
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
|
||||
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
|
||||
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
|
||||
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
|
||||
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
|
||||
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
|
||||
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
|
||||
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
|
||||
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
|
||||
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
|
||||
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
|
||||
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
|
||||
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
|
||||
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
|
||||
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
|
||||
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
|
||||
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
|
||||
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
|
||||
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
|
||||
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
|
||||
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
|
||||
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
|
||||
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
|
||||
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
|
||||
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
|
||||
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
|
||||
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
|
||||
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
|
||||
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
|
||||
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
|
||||
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
|
||||
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
|
||||
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
|
||||
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
|
||||
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
|
||||
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
|
||||
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
|
||||
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
|
||||
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
|
||||
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
|
||||
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
|
||||
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
|
||||
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
|
||||
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
|
||||
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
|
||||
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
|
||||
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
|
||||
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
|
||||
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
|
||||
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
|
||||
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
|
||||
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
|
||||
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
|
||||
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
|
||||
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
|
||||
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
|
||||
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
|
||||
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
|
||||
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
|
||||
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
+21009
-9633
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app. TheLanguageTutor videos preferred where a matching lesson exists.",
|
||||
"tenseGuides": {
|
||||
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"ind_preterito": {"videoId": "0PR7G81gKEc", "title": "Past Tense Verbs You Must Learn in Spanish — The Language Tutor Lesson 64"},
|
||||
"ind_imperfecto": {"videoId": "AmnTX30VliE", "title": "Mastering the Imperfect Tense in Spanish — The Language Tutor Lesson 50"},
|
||||
"ind_futuro": {"videoId": "U42loE1zhdw", "title": "Spanish Verbs in Future Tense — The Language Tutor Lesson 51"},
|
||||
"ind_perfecto": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"ind_pluscuamperfecto": {"videoId": "T_M3h88BUUw", "title": "Understanding Past Perfect Indicative Tense in Spanish — The Language Tutor Lesson 57"},
|
||||
"ind_futuro_perfecto": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"},
|
||||
"ind_preterito_anterior": {"videoId": "OCCgeYLlqck", "title": "The Past Anterior Tense in Spanish — The Language Tutor Lesson 65"},
|
||||
"cond_presente": {"videoId": "nRaMf1Y1TCM", "title": "Understanding the Conditional Tense in Spanish — The Language Tutor Lesson 52"},
|
||||
"cond_perfecto": {"videoId": "nlF-8kg-xaM", "title": "Mastering the Conditional Perfect Tense in Spanish — The Language Tutor Lesson 63"},
|
||||
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
|
||||
"subj_imperfecto_1": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_imperfecto_2": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_perfecto": {"videoId": "Sm2DDq99Uzw", "title": "Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! — The Language Tutor"},
|
||||
"subj_pluscuamperfecto_1": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_pluscuamperfecto_2": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"},
|
||||
"subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"},
|
||||
"imp_afirmativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"imp_negativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"}
|
||||
},
|
||||
"grammarNotes": {
|
||||
"ser-vs-estar": {"videoId": "tFXGCUi2yls", "title": "Ser vs Estar — BaseLang Spanish Lesson #3"},
|
||||
"por-vs-para": {"videoId": "02Am4LlPGjU", "title": "When to Use Por or Para in Spanish — The Language Tutor Lesson 47"},
|
||||
"preterite-vs-imperfect": {"videoId": "LvhO2-azzig", "title": "Spanish Past Tense: When to Use the Preterite vs Imperfect — BaseLang"},
|
||||
"subjunctive-triggers": {"videoId": "pMAMDgNNPB0", "title": "Spanish Subjunctive Simplified For Beginners — BaseLang"},
|
||||
"reflexive-verbs": {"videoId": "_uH_tosBLyo", "title": "Reflexive Verbs in Spanish — The Language Tutor Lesson 37"},
|
||||
"object-pronouns": {"videoId": "gEe4NW1FXx4", "title": "Intro To Spanish Direct And Indirect Object Pronouns — BaseLang"},
|
||||
"gustar-like-verbs": {"videoId": "SAfXpyZlz-I", "title": "How to Use Gustar in Spanish — The Language Tutor Lesson 121"},
|
||||
"comparatives-superlatives": {"videoId": "U74ClJsbfb0", "title": "Comparatives and Superlatives in Spanish — The Language Tutor Lesson 123"},
|
||||
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained — BaseLang"},
|
||||
"commands-imperative": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
|
||||
"double-negatives": {"videoId": "Y887wmI0O_o", "title": "Understanding Negation Words in Spanish — The Language Tutor Lesson 67"},
|
||||
"adjective-placement": {"videoId": "2B_TK_aun8E", "title": "Adjectives and Nouns Working Together in Spanish — The Language Tutor Lesson 66"},
|
||||
"tener-expressions": {"videoId": "189Qg68VCmo", "title": "The Verb Tener — BaseLang Spanish Lesson #17"},
|
||||
"personal-a": {"videoId": "B38CwLxgmOc", "title": "Learn Spanish — Preposition 'A' vs. Personal 'A'"},
|
||||
"relative-pronouns": {"videoId": "fnD1VaLpsCA", "title": "How to Use Relative Pronouns in Spanish — The Language Tutor Lesson 73"},
|
||||
"future-vs-ir-a": {"videoId": "2nxynOxr_h4", "title": "Future Tense in Spanish: 3 Ways To Speak About The Future — BaseLang"},
|
||||
"accent-marks-stress": {"videoId": "mXro8ngx07A", "title": "Spanish Accent Marks: When and How to Use Them — The Language Tutor"},
|
||||
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
|
||||
"estar-gerund-progressive": {"videoId": "s6HeVBv-ctM", "title": "How to Use Gerunds '-ing' in Spanish — The Language Tutor Lesson 113"},
|
||||
"spanish-suffixes": {"videoId": "o88gkstA0ds", "title": "Diminutives and Augmentatives in Spanish — The Language Tutor Lesson 78"},
|
||||
"common-irregular-verbs": {"videoId": "l0rOmomHSxg", "title": "Irregular Verbs in Spanish — The Language Tutor Lesson 21"},
|
||||
"types-of-irregular-verbs": {"videoId": "-XogD_S7pY4", "title": "Master IRREGULAR VERBS in the PRESENT TENSE | Complete Spanish Lesson"},
|
||||
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"articles-and-gender": {"videoId": "YeTIwDcKwZ4", "title": "Definite and Indefinite Articles in Spanish — The Language Tutor Lesson 11"},
|
||||
"possessive-adjectives": {"videoId": "Y4Aw1jy0ohE", "title": "Possessive Adjectives in Spanish — BaseLang"},
|
||||
"demonstrative-adjectives": {"videoId": "g4UzE8c2wik", "title": "How to Use This, These, That and Those in Spanish — The Language Tutor Lesson 56"},
|
||||
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang) — The Language Tutor"},
|
||||
"poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"},
|
||||
"al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"},
|
||||
"prepositional-pronouns": {"videoId": "quzRXk0oKp8", "title": "Mastering Prepositional Pronouns in Spanish — The Language Tutor Lesson 71"},
|
||||
"irregular-yo-verbs": {"videoId": "bM9NLgaeUvw", "title": "What are YO GO verbs in Spanish? — BaseLang"},
|
||||
"stem-changing-verbs": {"videoId": "WB2ThQauaWo", "title": "Stem Changing Verbs in Spanish: Explained For Beginners — BaseLang"},
|
||||
"stressed-possessives": {"videoId": "OL86D_omkSQ", "title": "Learning Possessive Pronouns in Spanish — The Language Tutor Lesson 68"},
|
||||
"present-perfect-tense": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"future-perfect-tense": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Curated YouTube Videos
|
||||
|
||||
Every tense guide and grammar note in the app can be tied to a single curated YouTube video. This file is generated from `Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` — regenerate when you add or change entries.
|
||||
|
||||
- Total tense-guide entries: **20** of 20
|
||||
- Total grammar-note entries: **36** of 36
|
||||
- Last verified: **2026-04-23** (run `python3 Scripts/generate_videos_markdown.py` to refresh)
|
||||
|
||||
Like counts are often blank because YouTube hides the public count on most videos for signed-out requests. Titles and durations are pulled live from YouTube; unavailable entries mean the video has been taken down, made private, or region-locked. A few entries marked "not available on this app" are a transient yt-dlp extraction limit — the video itself still plays fine when tapping Stream in the app.
|
||||
|
||||
## Tense guides
|
||||
|
||||
Tied to `TenseGuide.tenseId` in the Guide tab.
|
||||
|
||||
| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ind_presente` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `ind_preterito` | Past Tense Verbs You Must Learn in Spanish \| The Language Tutor *Lesson 64* | The Language Tutor - Spanish | 2020-04-05 | 9:03 | 50,837 | 1,433 | [watch](https://www.youtube.com/watch?v=0PR7G81gKEc) |
|
||||
| `ind_imperfecto` | Mastering the Imperfect Tense in Spanish \| The Language Tutor *Lesson 50* | The Language Tutor - Spanish | 2019-12-22 | 19:43 | 385,678 | 8,420 | [watch](https://www.youtube.com/watch?v=AmnTX30VliE) |
|
||||
| `ind_futuro` | Spanish Verbs in Future Tense \| The Language Tutor *Lesson 51* | The Language Tutor - Spanish | 2019-12-30 | 13:58 | 389,408 | 9,542 | [watch](https://www.youtube.com/watch?v=U42loE1zhdw) |
|
||||
| `ind_perfecto` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `ind_pluscuamperfecto` | Understanding Past Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 57* | The Language Tutor - Spanish | 2020-02-09 | 7:46 | 132,330 | 3,400 | [watch](https://www.youtube.com/watch?v=T_M3h88BUUw) |
|
||||
| `ind_futuro_perfecto` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
| `ind_preterito_anterior` | The Past Anterior Tense in Spanish \| The Language Tutor *Lesson 65* | The Language Tutor - Spanish | 2020-04-12 | 8:37 | 45,389 | 1,124 | [watch](https://www.youtube.com/watch?v=OCCgeYLlqck) |
|
||||
| `cond_presente` | Understanding the Conditional Tense in Spanish \| The Language Tutor *Lesson 52* | The Language Tutor - Spanish | 2020-01-05 | 10:01 | 222,400 | 5,338 | [watch](https://www.youtube.com/watch?v=nRaMf1Y1TCM) |
|
||||
| `cond_perfecto` | Mastering the Conditional Perfect Tense in Spanish \| The Language Tutor *Lesson 63* | The Language Tutor - Spanish | 2020-03-29 | 6:07 | 44,304 | 1,300 | [watch](https://www.youtube.com/watch?v=nlF-8kg-xaM) |
|
||||
| `subj_presente` | The Subjunctive in Spanish \| The Language Tutor *Lesson 58* | The Language Tutor - Spanish | 2020-02-23 | 16:30 | 424,782 | 11,272 | [watch](https://www.youtube.com/watch?v=CRvXpo45oHw) |
|
||||
| `subj_imperfecto_1` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_imperfecto_2` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_perfecto` | Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! | The Language Tutor - Spanish | 2020-03-08 | 6:47 | 89,060 | 2,105 | [watch](https://www.youtube.com/watch?v=Sm2DDq99Uzw) |
|
||||
| `subj_pluscuamperfecto_1` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_pluscuamperfecto_2` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_futuro` | Spanish Answers, Episode 10: Future Subjunctive | Spanish Answers | 2019-05-14 | 12:52 | 2,580 | 60 | [watch](https://www.youtube.com/watch?v=YPWJsmD3hN4) |
|
||||
| `subj_futuro_perfecto` | Free Spanish Lessons 151-Spanish Subjunctive tense:Future Perfect-Video 1/2 | Aprender Idiomas y Cultura General con Rodrigo | 2014-05-18 | 10:00 | 2,025 | 20 | [watch](https://www.youtube.com/watch?v=9vmo2C-0iuQ) |
|
||||
| `imp_afirmativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `imp_negativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
|
||||
## Grammar notes
|
||||
|
||||
Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.
|
||||
|
||||
| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ser-vs-estar` | Ser vs Estar: Spanish Lesson #3 | BaseLang | 2016-04-15 | 4:38 | 129,144 | 785 | [watch](https://www.youtube.com/watch?v=tFXGCUi2yls) |
|
||||
| `por-vs-para` | When to Use Por or Para in Spanish \| The Language Tutor *Lesson 47* | The Language Tutor - Spanish | 2019-12-01 | 14:01 | 261,212 | 7,552 | [watch](https://www.youtube.com/watch?v=02Am4LlPGjU) |
|
||||
| `preterite-vs-imperfect` | Spanish Past Tense: When to Use the Preterite vs Imperfect | BaseLang | 2025-01-14 | 4:20 | 37,948 | — | [watch](https://www.youtube.com/watch?v=LvhO2-azzig) |
|
||||
| `subjunctive-triggers` | Spanish Subjunctive Simplified For Beginners | BaseLang | 2022-01-14 | 9:58 | 72,998 | — | [watch](https://www.youtube.com/watch?v=pMAMDgNNPB0) |
|
||||
| `reflexive-verbs` | Reflexive Verbs in Spanish \| The Language Tutor *Lesson 37* | The Language Tutor - Spanish | 2019-09-22 | 10:37 | 324,262 | 7,345 | [watch](https://www.youtube.com/watch?v=_uH_tosBLyo) |
|
||||
| `object-pronouns` | Intro To Spanish Direct And Indirect Object Pronouns | BaseLang | 2022-01-12 | 9:44 | 169,076 | — | [watch](https://www.youtube.com/watch?v=gEe4NW1FXx4) |
|
||||
| `gustar-like-verbs` | How to Use Gustar in Spanish \| The Language Tutor *Lesson 121* | The Language Tutor - Spanish | 2021-12-12 | 14:06 | 173,299 | — | [watch](https://www.youtube.com/watch?v=SAfXpyZlz-I) |
|
||||
| `comparatives-superlatives` | Comparatives and Superlatives in Spanish \| The Language Tutor *Lesson 123* | The Language Tutor - Spanish | 2022-05-10 | 15:57 | 90,246 | 2,085 | [watch](https://www.youtube.com/watch?v=U74ClJsbfb0) |
|
||||
| `conditional-if-clauses` | Si Clauses: The Spanish Hypothetical Explained | BaseLang | 2022-05-13 | 8:59 | 16,328 | — | [watch](https://www.youtube.com/watch?v=thvW8qVsqkE) |
|
||||
| `commands-imperative` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `saber-vs-conocer` | _(unavailable — Private video)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=j87i7MVCvIE) |
|
||||
| `double-negatives` | Understanding Negation Words in Spanish \| The Language Tutor *Lesson 67* | The Language Tutor - Spanish | 2020-04-26 | 21:36 | 150,024 | 3,888 | [watch](https://www.youtube.com/watch?v=Y887wmI0O_o) |
|
||||
| `adjective-placement` | Adjectives and Nouns Working Together in Spanish \| The Language Tutor *Lesson 66* | The Language Tutor - Spanish | 2020-04-19 | 17:17 | 70,340 | 1,980 | [watch](https://www.youtube.com/watch?v=2B_TK_aun8E) |
|
||||
| `tener-expressions` | The Verb Tener: Spanish Lesson #17 | BaseLang | 2016-05-03 | 4:16 | 7,948 | 76 | [watch](https://www.youtube.com/watch?v=189Qg68VCmo) |
|
||||
| `personal-a` | Learn Spanish-Preposition "A" vs. Personal “A” | castellano4U | 2017-04-04 | 11:45 | 1,976 | — | [watch](https://www.youtube.com/watch?v=B38CwLxgmOc) |
|
||||
| `relative-pronouns` | How to Use Relative Pronouns in Spanish \| The Language Tutor *Lesson 73* | The Language Tutor - Spanish | 2020-06-07 | 11:02 | 57,363 | — | [watch](https://www.youtube.com/watch?v=fnD1VaLpsCA) |
|
||||
| `future-vs-ir-a` | Future Tense in Spanish: 3 Ways To Speak About The Future | BaseLang | 2022-01-05 | 9:57 | 44,300 | — | [watch](https://www.youtube.com/watch?v=2nxynOxr_h4) |
|
||||
| `accent-marks-stress` | Spanish Accent Marks: When and How to Use Them | The Language Tutor - Spanish | 2020-06-14 | 14:09 | 98,134 | 3,242 | [watch](https://www.youtube.com/watch?v=mXro8ngx07A) |
|
||||
| `se-constructions` | Understanding "SE" in Spanish: Reflexive, Passive, and Impersonal Constructions EXPLAINED | Learn Spanish with Spanish55 | 2024-08-16 | 3:57 | 1,271 | 61 | [watch](https://www.youtube.com/watch?v=ndxsrGD7b-8) |
|
||||
| `estar-gerund-progressive` | How to Use Gerunds "-ing" in Spanish \| The Language Tutor *Lesson 113* | The Language Tutor - Spanish | 2021-06-20 | 10:29 | 54,552 | 2,091 | [watch](https://www.youtube.com/watch?v=s6HeVBv-ctM) |
|
||||
| `spanish-suffixes` | Diminutives and Augmentatives in Spanish \| The Language Tutor *Lesson 78* | The Language Tutor - Spanish | 2020-07-12 | 17:03 | 42,033 | — | [watch](https://www.youtube.com/watch?v=o88gkstA0ds) |
|
||||
| `common-irregular-verbs` | Irregular Verbs in Spanish \| The Language Tutor *Lesson 21* | The Language Tutor - Spanish | 2019-06-09 | 12:21 | 301,865 | 6,338 | [watch](https://www.youtube.com/watch?v=l0rOmomHSxg) |
|
||||
| `types-of-irregular-verbs` | _(unavailable — The following content is not available on this app)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=-XogD_S7pY4) |
|
||||
| `present-indicative-conjugation` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `articles-and-gender` | Definite and Indefinite Articles in Spanish \| The Language Tutor *Lesson 11* | The Language Tutor - Spanish | 2019-03-31 | 5:37 | 355,893 | 7,396 | [watch](https://www.youtube.com/watch?v=YeTIwDcKwZ4) |
|
||||
| `possessive-adjectives` | Possessive Adjectives in Spanish | BaseLang | 2022-06-27 | 13:40 | 15,925 | — | [watch](https://www.youtube.com/watch?v=Y4Aw1jy0ohE) |
|
||||
| `demonstrative-adjectives` | How to Use This, These, That and Those in Spanish \| The Language Tutor *Lesson 56* | The Language Tutor - Spanish | 2020-02-02 | 13:34 | 185,671 | 6,689 | [watch](https://www.youtube.com/watch?v=g4UzE8c2wik) |
|
||||
| `greetings-farewells` | Every Spanish Greeting You Need (Formal, Casual & Slang) | The Language Tutor - Spanish | 2019-03-10 | 9:54 | 673,751 | 17,161 | [watch](https://www.youtube.com/watch?v=AqfQQZVmTUw) |
|
||||
| `poder-infinitive` | Spanish - The Verb “Poder“ Explained In 3 Minutes | TheLanguageBro | 2023-07-01 | 3:16 | 4,913 | 106 | [watch](https://www.youtube.com/watch?v=hCUbz5942EY) |
|
||||
| `al-del-contractions` | Spanish Contractions AL and DEL \| The Language Tutor * Lesson 15 * | The Language Tutor - Spanish | 2019-04-28 | 3:34 | 211,085 | 5,457 | [watch](https://www.youtube.com/watch?v=nWPZZWIwWxg) |
|
||||
| `prepositional-pronouns` | Mastering Prepositional Pronouns in Spanish \| The Language Tutor *Lesson 71* | The Language Tutor - Spanish | 2020-05-24 | 13:34 | 93,929 | — | [watch](https://www.youtube.com/watch?v=quzRXk0oKp8) |
|
||||
| `irregular-yo-verbs` | What are YO GO verbs in Spanish? | BaseLang | 2022-04-21 | 5:08 | 17,894 | — | [watch](https://www.youtube.com/watch?v=bM9NLgaeUvw) |
|
||||
| `stem-changing-verbs` | Stem Changing Verbs in Spanish: Explained For Beginners | BaseLang | 2022-05-17 | 7:31 | 59,959 | — | [watch](https://www.youtube.com/watch?v=WB2ThQauaWo) |
|
||||
| `stressed-possessives` | Learning Possessive Pronouns in Spanish \| The Language Tutor *Lesson 68* | The Language Tutor - Spanish | 2020-05-03 | 10:01 | 129,223 | 3,671 | [watch](https://www.youtube.com/watch?v=OL86D_omkSQ) |
|
||||
| `present-perfect-tense` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `future-perfect-tense` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
@@ -41,24 +41,19 @@ struct CombinedProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 7 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the
|
||||
// store on open, dropping the entities not listed here (this is how we
|
||||
// previously lost all TextbookChapter rows on every widget refresh).
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
@@ -32,24 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 7 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the
|
||||
// store on open, dropping the entities not listed here (this is how we
|
||||
// previously lost all TextbookChapter rows on every widget refresh).
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,102 @@
|
||||
# Books pipeline
|
||||
|
||||
Turns any EPUB into a chapter-structured JSON file the app bundles and reads.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
cd Conjuga/Scripts/books
|
||||
./run.sh /path/to/book.epub --slug my-book-slug
|
||||
```
|
||||
|
||||
This runs Phase 1 (extract) and Phase 2 (manifest jobs), then stops and tells you how many translation jobs are pending. Run those via Claude Code subagents (Phase 2.5 below), then re-run `./run.sh` to bundle the final file.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Script | What it does | Output |
|
||||
|---|---|---|---|
|
||||
| 1 | `extract_epub.py` | Unzip the EPUB, walk `content.opf` spine + `toc.ncx` navMap, group HTML files into chapters, strip HTML→text. | `build/<slug>/chapters.json` |
|
||||
| 2 | `translate_chapters.py` | Split each chapter into ~30-paragraph translation batches. Each batch becomes a job with its own input/output file. **Resumable**: jobs whose output file already exists are skipped. | `build/<slug>/jobs/<jobid>.input.json` + `_pending.txt` |
|
||||
| 2b | `build_glossary.py` | Tokenize every Spanish paragraph the same way the app does, collect the distinct words with example sentences, split into ~150-word glossary batches. **Resumable** the same way. | `build/<slug>/glossary/<jobid>.input.json` + `_pending.txt` |
|
||||
| 2.5 | Claude Code subagents | Drain **both** manifests: translate the chapter jobs *and* the glossary jobs, writing each job's `<jobid>.output.json`. See "Running translations" below. | `build/<slug>/{jobs,glossary}/<jobid>.output.json` |
|
||||
| 3 | `bundle_book.py` | Merge `chapters.json` + every translation `*.output.json` + every glossary `*.output.json` into the final bundled JSON the app reads. | `Conjuga/Conjuga/book_<slug>.json` |
|
||||
|
||||
`run.sh` chains 1 → 2 → 2b → 3. If Phase 2 or 2b produces pending jobs, Phase 3 still runs but bundles with placeholders so you can preview app structure before the LLM passes complete. Re-running `run.sh` after subagents fill in the outputs gives you the real bundled file.
|
||||
|
||||
The glossary is the book reader's primary word-lookup source: every distinct word translated once, in context, so taps are instant, cover the whole book, and don't mis-resolve homographs (e.g. "como" as the conjunction vs. the verb *comer*). This phase is a permanent part of the pipeline — every book imported this way gets a glossary.
|
||||
|
||||
## Adding a new book
|
||||
|
||||
1. **Drop the EPUB** anywhere on disk.
|
||||
2. **Run Phase 1+2**:
|
||||
```bash
|
||||
cd Conjuga/Scripts/books
|
||||
./run.sh /path/to/book.epub --slug my-book
|
||||
```
|
||||
Sanity-check the chapter list it prints. If chapter grouping looks wrong (e.g. an EPUB without a usable `toc.ncx`), `extract_epub.py` will need a fallback heuristic — see "Open assumptions" below.
|
||||
|
||||
3. **Run translations** (Phase 2.5). The default approach is to spawn Claude Code subagents from inside a Claude Code session pointed at this repo:
|
||||
|
||||
There are **two** manifests to drain — translation and glossary:
|
||||
- `build/<slug>/jobs/_pending.txt` with prompt `build/<slug>/jobs/_prompt_template.md`
|
||||
- `build/<slug>/glossary/_pending.txt` with prompt `build/<slug>/glossary/_prompt_template.md`
|
||||
|
||||
For each pending job ID, hand a subagent the matching prompt with `<JOB_INPUT_PATH>` / `<JOB_OUTPUT_PATH>` filled in. The subagent reads the input, produces the translation/glossary, and writes the output. Resumable — interrupted runs just leave the missing job IDs in `_pending.txt`.
|
||||
|
||||
Cluster jobs into agent batches of ~5–10 jobs each to keep per-agent context manageable. ~5 parallel agents is a good throughput target.
|
||||
|
||||
4. **Bundle**:
|
||||
```bash
|
||||
./run.sh /path/to/book.epub --slug my-book # re-running pulls in the new outputs
|
||||
# or directly:
|
||||
python3 bundle_book.py my-book --require-all
|
||||
```
|
||||
`--require-all` will fail loudly if any job is still missing.
|
||||
|
||||
5. **Bump `bookDataVersion`** in `DataLoader.swift` so the in-app store re-seeds the new book on next launch (or any time you re-run with new translations).
|
||||
|
||||
6. **Verify the file is bundled** in `Conjuga.xcodeproj`. The script writes `book_<slug>.json` into `Conjuga/Conjuga/Resources/`; if that folder is part of a recursive group reference, Xcode picks it up automatically. Otherwise, add it manually or via the `xcodeproj` ruby gem.
|
||||
|
||||
## File layout
|
||||
|
||||
```
|
||||
Conjuga/Scripts/books/
|
||||
├── extract_epub.py # Phase 1
|
||||
├── translate_chapters.py # Phase 2
|
||||
├── build_glossary.py # Phase 2b
|
||||
├── bundle_book.py # Phase 3
|
||||
├── run.sh # Orchestrator
|
||||
└── build/ # gitignored
|
||||
└── <slug>/
|
||||
├── chapters.json
|
||||
├── jobs/ # translation jobs
|
||||
│ ├── _pending.txt
|
||||
│ ├── _prompt_template.md
|
||||
│ ├── ch01_b00.input.json
|
||||
│ ├── ch01_b00.output.json
|
||||
│ └── ...
|
||||
└── glossary/ # glossary jobs (Phase 2b)
|
||||
├── _pending.txt
|
||||
├── _prompt_template.md
|
||||
├── gloss_b00.input.json
|
||||
├── gloss_b00.output.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
The final output (`book_<slug>.json`) lives at `Conjuga/Conjuga/book_<slug>.json` so the iOS app bundle includes it. (Existing `textbook_data.json` / `conjuga_data.json` use the same layout — files in the app target root rather than a Resources subgroup.)
|
||||
|
||||
## Open assumptions
|
||||
|
||||
- **TOC drives chapter boundaries.** If an EPUB ships without a usable `toc.ncx`, or the navMap is too granular (e.g. one navPoint per page), `extract_epub.py` will need a fallback that groups by `<h1>` headings in spine order.
|
||||
- **Spanish bold tags = inline emphasis.** The Olly Richards books bold vocab hints inside paragraphs. We strip the bold and let the in-app dictionary lookup handle definitions instead. If a future book uses bold for something else (titles, etc.), revisit.
|
||||
- **Translation is per-paragraph 1:1.** Subagents must preserve paragraph count and order. `bundle_book.py` will warn + pad/truncate if a job's output array length doesn't match its input — but that's a sign the subagent misbehaved.
|
||||
|
||||
## Out of scope (intentional)
|
||||
|
||||
- OCR of vocab image tables (use `Scripts/textbook/` if your book is image-heavy).
|
||||
- Exercise extraction (textbook pipeline).
|
||||
- Per-occurrence word sense disambiguation. The glossary has one entry per
|
||||
distinct word, translated in context; a word genuinely used in two senses in
|
||||
the same book gets its dominant sense. The runtime `DictionaryService` + the
|
||||
on-device LLM remain as fallbacks for anything the glossary misses.
|
||||
- Cover image extraction (covers are derived from a color hash in the app for now).
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 2b — build a per-book glossary job manifest.
|
||||
|
||||
Scans chapters.json, tokenizes every Spanish paragraph the SAME way the iOS app
|
||||
does (whitespace split, lowercase, strip leading/trailing punctuation), collects
|
||||
the distinct words with a few example sentences each, and writes batched
|
||||
glossary jobs that Claude Code subagents can translate in parallel. Resumable:
|
||||
jobs whose output file already exists are skipped.
|
||||
|
||||
Usage:
|
||||
python3 build_glossary.py <slug> [--batch-size N] [--max-examples N]
|
||||
[--build BUILD_DIR]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
|
||||
|
||||
Outputs:
|
||||
BUILD_DIR/<slug>/glossary/<jobid>.input.json (one per batch — read by subagents)
|
||||
BUILD_DIR/<slug>/glossary/_pending.txt (job IDs still missing output)
|
||||
BUILD_DIR/<slug>/glossary/_prompt_template.md (prompt for each subagent)
|
||||
|
||||
Job input shape (.input.json):
|
||||
{"jobId": "gloss_b00",
|
||||
"words": [{"word": "taza", "examples": ["...", "..."]}, ...]}
|
||||
|
||||
Subagents must write <jobid>.output.json with shape:
|
||||
{"jobId": "gloss_b00",
|
||||
"entries": [{"word": "taza", "baseForm": "taza",
|
||||
"english": "cup", "partOfSpeech": "noun"}, ...]}
|
||||
|
||||
`entries` must contain exactly one object per input word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = """\
|
||||
You are building a Spanish->English glossary for a language-learning app.
|
||||
|
||||
Input file: {input_path}
|
||||
Output file: {output_path}
|
||||
|
||||
Read the input file. It contains a JSON object with a `words` array; each item
|
||||
has a `word` (a lowercase Spanish word exactly as it appears in a book) and
|
||||
`examples` (sentences from the book that use that word).
|
||||
|
||||
For EACH word, produce one entry:
|
||||
- baseForm: the dictionary base form -- infinitive for verbs, masculine
|
||||
singular for nouns/adjectives, the word itself for invariant words.
|
||||
- english: a concise English translation (1-4 words). Use the sense the word
|
||||
carries in the example sentences. Many Spanish words are both a verb form
|
||||
AND a function word -- e.g. "como" is "I eat" (verb) and "as/like"
|
||||
(conjunction). Choose the meaning shown in the examples, not the most common
|
||||
dictionary sense.
|
||||
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
|
||||
conjunction, article, interjection, numeral, proper noun, other.
|
||||
|
||||
Write the output file as JSON with this exact shape:
|
||||
{{"jobId": "<the jobId from the input>", "entries": [
|
||||
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
|
||||
]}}
|
||||
|
||||
`entries` MUST contain exactly one object per input word, cover every word, and
|
||||
echo each `word` back verbatim. Write nothing else to disk and produce no other
|
||||
output.
|
||||
"""
|
||||
|
||||
SENTENCE_SPLIT = re.compile(r"(?<=[.!?…])\s+")
|
||||
|
||||
|
||||
def is_punct(ch: str) -> bool:
|
||||
"""True for any Unicode punctuation — matches Swift's .punctuationCharacters."""
|
||||
return unicodedata.category(ch).startswith("P")
|
||||
|
||||
|
||||
def clean_word(token: str) -> str:
|
||||
"""Mirror BookReaderView.cleanWord: lowercase, strip leading/trailing
|
||||
punctuation, trim whitespace. Accents are preserved (no folding)."""
|
||||
t = token.lower()
|
||||
start, end = 0, len(t)
|
||||
while start < end and is_punct(t[start]):
|
||||
start += 1
|
||||
while end > start and is_punct(t[end - 1]):
|
||||
end -= 1
|
||||
return t[start:end].strip()
|
||||
|
||||
|
||||
def has_letter(s: str) -> bool:
|
||||
return any(c.isalpha() for c in s)
|
||||
|
||||
|
||||
def split_sentences(paragraph: str) -> list[str]:
|
||||
parts = SENTENCE_SPLIT.split(paragraph.strip())
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def is_english_front_matter(chapter: dict, threshold: float = 0.5) -> bool:
|
||||
"""True when most of a chapter's paragraphs are untranslated — i.e. it is
|
||||
English front matter (Preface, reading guide, …) rather than Spanish story
|
||||
content. Story chapters still have *some* identical lines (verbatim
|
||||
`word = meaning` vocab entries), so a majority threshold separates them:
|
||||
front matter runs ~70-100% identical, stories ~25-35%. Only detectable once
|
||||
paragraphsEN is populated; raw extracted chapters carry none, so nothing is
|
||||
skipped on a fresh book's first pass."""
|
||||
es = [p.strip() for p in chapter.get("paragraphsES", [])]
|
||||
en = [p.strip() for p in chapter.get("paragraphsEN", [])]
|
||||
if not en or len(en) != len(es) or not es:
|
||||
return False
|
||||
identical = sum(1 for a, b in zip(es, en) if a == b)
|
||||
return identical / len(es) > threshold
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--batch-size", type=int, default=150)
|
||||
parser.add_argument("--max-examples", type=int, default=3)
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
|
||||
gloss_dir = base / "glossary"
|
||||
gloss_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
examples: dict[str, list[str]] = {}
|
||||
first_seen: dict[str, int] = {}
|
||||
order = 0
|
||||
|
||||
skipped_front_matter = 0
|
||||
for ch in chapters["chapters"]:
|
||||
if is_english_front_matter(ch):
|
||||
skipped_front_matter += 1
|
||||
continue
|
||||
for paragraph in ch.get("paragraphsES", []):
|
||||
for sentence in split_sentences(paragraph):
|
||||
cleaned = {clean_word(tok) for tok in sentence.split()}
|
||||
for w in cleaned:
|
||||
if not w or not has_letter(w):
|
||||
continue
|
||||
if w not in first_seen:
|
||||
first_seen[w] = order
|
||||
order += 1
|
||||
examples[w] = []
|
||||
bucket = examples[w]
|
||||
if len(bucket) < args.max_examples and sentence not in bucket:
|
||||
bucket.append(sentence)
|
||||
|
||||
words = sorted(examples.keys(), key=lambda w: first_seen[w])
|
||||
|
||||
pending: list[str] = []
|
||||
completed: list[str] = []
|
||||
total_jobs = 0
|
||||
|
||||
for offset in range(0, len(words), args.batch_size):
|
||||
chunk = words[offset : offset + args.batch_size]
|
||||
job_id = f"gloss_b{offset // args.batch_size:02d}"
|
||||
input_path = gloss_dir / f"{job_id}.input.json"
|
||||
output_path = gloss_dir / f"{job_id}.output.json"
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"jobId": job_id,
|
||||
"words": [{"word": w, "examples": examples[w]} for w in chunk],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
total_jobs += 1
|
||||
(completed if output_path.exists() else pending).append(job_id)
|
||||
|
||||
(gloss_dir / "_pending.txt").write_text(
|
||||
"\n".join(pending) + ("\n" if pending else ""), encoding="utf-8"
|
||||
)
|
||||
(gloss_dir / "_prompt_template.md").write_text(
|
||||
PROMPT_TEMPLATE.format(
|
||||
input_path="<JOB_INPUT_PATH>", output_path="<JOB_OUTPUT_PATH>"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"Skipped front matter: {skipped_front_matter} chapter(s)")
|
||||
print(f"Distinct words: {len(words)}")
|
||||
print(f"Total glossary jobs: {total_jobs}")
|
||||
print(f" Completed: {len(completed)}")
|
||||
print(f" Pending: {len(pending)}")
|
||||
print(f"Manifest at: {gloss_dir / '_pending.txt'}")
|
||||
print(f"Prompt template at: {gloss_dir / '_prompt_template.md'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge chapters.json + per-job translation outputs into the final bundled
|
||||
book_<slug>.json that the iOS app reads from its bundle.
|
||||
|
||||
Usage:
|
||||
python3 bundle_book.py <slug> [--build BUILD_DIR] [--dest DEST_DIR] [--require-all]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json
|
||||
BUILD_DIR/<slug>/jobs/*.output.json (from translation subagents)
|
||||
BUILD_DIR/<slug>/glossary/*.output.json (from glossary subagents, Phase 2b)
|
||||
|
||||
Output:
|
||||
DEST_DIR/book_<slug>.json
|
||||
{
|
||||
"slug": "...",
|
||||
"title": "...",
|
||||
"author": "...",
|
||||
"language": "...",
|
||||
"chapters": [
|
||||
{"id": "ch1", "number": 1, "title": "Preface",
|
||||
"paragraphsES": ["...", ...],
|
||||
"paragraphsEN": ["...", ...]},
|
||||
...
|
||||
],
|
||||
"glossary": {
|
||||
"taza": {"baseForm": "taza", "english": "cup", "partOfSpeech": "noun"},
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
If --require-all is passed, the script fails if any translation OR glossary job
|
||||
is missing its output. Otherwise it fills missing translations with empty
|
||||
strings, leaves missing glossary entries out, and warns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_DEST = Path("../../Conjuga")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
parser.add_argument("--dest", type=Path, default=None)
|
||||
parser.add_argument("--require-all", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters = json.loads((base / "chapters.json").read_text(encoding="utf-8"))
|
||||
jobs_dir = base / "jobs"
|
||||
|
||||
# Index translation jobs by chapter -> ordered (offset, paragraphsEN).
|
||||
chapter_translations: dict[int, list[tuple[int, list[str]]]] = {}
|
||||
missing: list[str] = []
|
||||
|
||||
for input_path in sorted(jobs_dir.glob("*.input.json")):
|
||||
job_id = input_path.stem.removesuffix(".input")
|
||||
input_data = json.loads(input_path.read_text(encoding="utf-8"))
|
||||
output_path = jobs_dir / f"{job_id}.output.json"
|
||||
if not output_path.exists():
|
||||
missing.append(job_id)
|
||||
continue
|
||||
output_data = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
paragraphs_en = output_data.get("paragraphsEN", [])
|
||||
expected = len(input_data["paragraphsES"])
|
||||
if len(paragraphs_en) != expected:
|
||||
print(
|
||||
f"WARN: {job_id} length mismatch — got {len(paragraphs_en)}, "
|
||||
f"expected {expected}. Padding/truncating.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if len(paragraphs_en) < expected:
|
||||
paragraphs_en = paragraphs_en + [""] * (expected - len(paragraphs_en))
|
||||
else:
|
||||
paragraphs_en = paragraphs_en[:expected]
|
||||
chapter_translations.setdefault(input_data["chapter"], []).append(
|
||||
(input_data["rangeStart"], paragraphs_en)
|
||||
)
|
||||
|
||||
if missing:
|
||||
msg = f"{len(missing)} translation job(s) missing output: {missing[:5]}{'...' if len(missing) > 5 else ''}"
|
||||
if args.require_all:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: {msg} — using empty strings for those paragraphs.", file=sys.stderr)
|
||||
|
||||
# Glossary (Phase 2b) — merge every glossary job's entries into one map
|
||||
# keyed by the cleaned word the app looks up.
|
||||
glossary_dir = base / "glossary"
|
||||
glossary: dict[str, dict] = {}
|
||||
glossary_missing: list[str] = []
|
||||
if glossary_dir.exists():
|
||||
for input_path in sorted(glossary_dir.glob("*.input.json")):
|
||||
job_id = input_path.stem.removesuffix(".input")
|
||||
output_path = glossary_dir / f"{job_id}.output.json"
|
||||
if not output_path.exists():
|
||||
glossary_missing.append(job_id)
|
||||
continue
|
||||
output_data = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
for entry in output_data.get("entries", []):
|
||||
word = (entry.get("word") or "").strip()
|
||||
if not word:
|
||||
continue
|
||||
glossary[word] = {
|
||||
"baseForm": entry.get("baseForm") or word,
|
||||
"english": entry.get("english") or "",
|
||||
"partOfSpeech": entry.get("partOfSpeech") or "",
|
||||
}
|
||||
if glossary_missing:
|
||||
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
|
||||
if args.require_all:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: {msg} — glossary will be incomplete.", file=sys.stderr)
|
||||
|
||||
bundled_chapters: list[dict] = []
|
||||
for ch in chapters["chapters"]:
|
||||
translations = sorted(chapter_translations.get(ch["number"], []))
|
||||
paragraphs_en: list[str] = []
|
||||
for _, en_chunk in translations:
|
||||
paragraphs_en.extend(en_chunk)
|
||||
# Pad to match ES length if jobs were missing for parts of this chapter.
|
||||
if len(paragraphs_en) < len(ch["paragraphsES"]):
|
||||
paragraphs_en += [""] * (len(ch["paragraphsES"]) - len(paragraphs_en))
|
||||
elif len(paragraphs_en) > len(ch["paragraphsES"]):
|
||||
paragraphs_en = paragraphs_en[: len(ch["paragraphsES"])]
|
||||
bundled_chapters.append(
|
||||
{
|
||||
"id": ch["id"],
|
||||
"number": ch["number"],
|
||||
"title": ch["title"],
|
||||
"paragraphsES": ch["paragraphsES"],
|
||||
"paragraphsEN": paragraphs_en,
|
||||
}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"slug": chapters["slug"],
|
||||
"title": chapters["title"],
|
||||
"author": chapters["author"],
|
||||
"language": chapters["language"],
|
||||
"chapters": bundled_chapters,
|
||||
"glossary": glossary,
|
||||
}
|
||||
|
||||
dest_dir = (args.dest or DEFAULT_DEST).resolve()
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = dest_dir / f"book_{args.slug}.json"
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" Chapters: {len(bundled_chapters)}")
|
||||
print(f" Translated jobs: {sum(len(v) for v in chapter_translations.values())} / {sum(len(v) for v in chapter_translations.values()) + len(missing)}")
|
||||
print(f" Glossary words: {len(glossary)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse an EPUB into chapters.json for the in-app Books feature.
|
||||
|
||||
Usage:
|
||||
python3 extract_epub.py <epub_path> [--slug SLUG] [--out OUT_DIR]
|
||||
|
||||
Defaults:
|
||||
SLUG derived from the EPUB filename (lowercased, dashed)
|
||||
OUT_DIR ./build/<slug>
|
||||
|
||||
Output:
|
||||
OUT_DIR/chapters.json
|
||||
{
|
||||
"title": "...",
|
||||
"author": "...",
|
||||
"language": "...",
|
||||
"slug": "...",
|
||||
"chapters": [
|
||||
{"id": "ch1", "number": 1, "title": "Preface",
|
||||
"paragraphsES": ["...", "..."]},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
How chapter grouping works:
|
||||
1. Read content.opf manifest (id -> href) and spine (ordered idrefs).
|
||||
2. Read toc.ncx navMap to get the ordered list of chapter (title, first-href).
|
||||
3. For each chapter, claim every spine file from its first href up to (but
|
||||
not including) the next chapter's first href.
|
||||
4. For each file in the chapter's range, parse <p> elements, strip tags,
|
||||
normalise whitespace + smart quotes, drop empties.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from bs4 import BeautifulSoup, XMLParsedAsHTMLWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
|
||||
NS = {
|
||||
"opf": "http://www.idpf.org/2007/opf",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
"ncx": "http://www.daisy.org/z3986/2005/ncx/",
|
||||
"xhtml": "http://www.w3.org/1999/xhtml",
|
||||
}
|
||||
|
||||
|
||||
def _slugify(s: str) -> str:
|
||||
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode("ascii")
|
||||
s = re.sub(r"[^a-zA-Z0-9]+", "-", s).strip("-").lower()
|
||||
return s or "book"
|
||||
|
||||
|
||||
def _normalise(text: str) -> str:
|
||||
# Collapse runs of whitespace, normalise smart quotes to plain ones.
|
||||
text = text.replace(" ", " ")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
text = re.sub(r"\s+([.,;:!?…])", r"\1", text)
|
||||
text = re.sub(r"([¡¿])\s+", r"\1", text)
|
||||
return text
|
||||
|
||||
|
||||
def _read_zip_text(zf: zipfile.ZipFile, path: str) -> str:
|
||||
return zf.read(path).decode("utf-8")
|
||||
|
||||
|
||||
def _container_root(zf: zipfile.ZipFile) -> str:
|
||||
container = ET.fromstring(_read_zip_text(zf, "META-INF/container.xml"))
|
||||
rootfile = container.find(".//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile")
|
||||
if rootfile is None:
|
||||
raise RuntimeError("Missing rootfile entry in META-INF/container.xml")
|
||||
return rootfile.attrib["full-path"]
|
||||
|
||||
|
||||
def _parse_opf(zf: zipfile.ZipFile, opf_path: str):
|
||||
text = _read_zip_text(zf, opf_path)
|
||||
root = ET.fromstring(text)
|
||||
|
||||
title = (root.findtext(".//dc:title", default="", namespaces=NS) or "").strip()
|
||||
author = (root.findtext(".//dc:creator", default="", namespaces=NS) or "").strip()
|
||||
language = (root.findtext(".//dc:language", default="", namespaces=NS) or "").strip()
|
||||
|
||||
manifest: dict[str, str] = {}
|
||||
for item in root.findall("opf:manifest/opf:item", NS):
|
||||
manifest[item.attrib["id"]] = item.attrib["href"]
|
||||
|
||||
spine: list[str] = []
|
||||
for itemref in root.findall("opf:spine/opf:itemref", NS):
|
||||
spine.append(itemref.attrib["idref"])
|
||||
|
||||
ncx_id = root.find("opf:spine", NS).attrib.get("toc") if root.find("opf:spine", NS) is not None else None
|
||||
ncx_href = manifest.get(ncx_id) if ncx_id else None
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"author": author,
|
||||
"language": language,
|
||||
"manifest": manifest,
|
||||
"spine": spine,
|
||||
"ncx_href": ncx_href,
|
||||
"opf_dir": str(Path(opf_path).parent) if "/" in opf_path else "",
|
||||
}
|
||||
|
||||
|
||||
def _parse_ncx(zf: zipfile.ZipFile, ncx_path: str) -> list[dict]:
|
||||
text = _read_zip_text(zf, ncx_path)
|
||||
root = ET.fromstring(text)
|
||||
chapters: list[dict] = []
|
||||
for nav in root.findall("ncx:navMap/ncx:navPoint", NS):
|
||||
title = (nav.findtext("ncx:navLabel/ncx:text", default="", namespaces=NS) or "").strip()
|
||||
content = nav.find("ncx:content", NS)
|
||||
src = content.attrib.get("src", "") if content is not None else ""
|
||||
# Strip the anchor — we want the file path only.
|
||||
href = src.split("#", 1)[0]
|
||||
chapters.append({"title": title, "href": href})
|
||||
return chapters
|
||||
|
||||
|
||||
def _resolve_zip_path(base_dir: str, href: str) -> str:
|
||||
if not base_dir:
|
||||
return href
|
||||
return f"{base_dir}/{href}".lstrip("/")
|
||||
|
||||
|
||||
def _extract_paragraphs(zf: zipfile.ZipFile, zip_path: str) -> list[str]:
|
||||
try:
|
||||
html = _read_zip_text(zf, zip_path)
|
||||
except KeyError:
|
||||
return []
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
paragraphs: list[str] = []
|
||||
# Walk <p> and <li> in document order so vocab bullets (rendered as
|
||||
# <ul><li>...</li></ul> in this EPUB family) are kept alongside narrative
|
||||
# paragraphs. `<li>` rolls up its inline <b>/<span> children via get_text.
|
||||
for el in soup.find_all(["p", "li"]):
|
||||
text = _normalise(el.get_text(" ", strip=True))
|
||||
if not text:
|
||||
continue
|
||||
paragraphs.append(text)
|
||||
return paragraphs
|
||||
|
||||
|
||||
def _chapter_files(
|
||||
spine_files: list[str], chapter_hrefs: list[str]
|
||||
) -> list[list[str]]:
|
||||
"""Slice the spine into one list of files per chapter, using the chapter's
|
||||
first href as the chapter boundary. Files before the first chapter (e.g.
|
||||
cover, titlepage) are dropped."""
|
||||
boundaries: list[int] = []
|
||||
for href in chapter_hrefs:
|
||||
try:
|
||||
idx = spine_files.index(href)
|
||||
except ValueError:
|
||||
boundaries.append(-1)
|
||||
continue
|
||||
boundaries.append(idx)
|
||||
|
||||
ranges: list[list[str]] = []
|
||||
for i, start in enumerate(boundaries):
|
||||
if start < 0:
|
||||
ranges.append([])
|
||||
continue
|
||||
end = len(spine_files)
|
||||
for next_start in boundaries[i + 1:]:
|
||||
if next_start >= 0:
|
||||
end = next_start
|
||||
break
|
||||
ranges.append(spine_files[start:end])
|
||||
return ranges
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("epub", type=Path)
|
||||
parser.add_argument("--slug", default=None)
|
||||
parser.add_argument("--out", type=Path, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.epub.exists():
|
||||
print(f"EPUB not found: {args.epub}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
with zipfile.ZipFile(args.epub) as zf:
|
||||
opf_path = _container_root(zf)
|
||||
opf = _parse_opf(zf, opf_path)
|
||||
|
||||
if not opf["ncx_href"]:
|
||||
print("No NCX found in spine; cannot derive chapter structure.", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
|
||||
ncx_path = _resolve_zip_path(opf["opf_dir"], opf["ncx_href"])
|
||||
toc = _parse_ncx(zf, ncx_path)
|
||||
|
||||
spine_files = [
|
||||
_resolve_zip_path(opf["opf_dir"], opf["manifest"].get(idref, ""))
|
||||
for idref in opf["spine"]
|
||||
]
|
||||
chapter_hrefs = [_resolve_zip_path(opf["opf_dir"], c["href"]) for c in toc]
|
||||
chapter_file_ranges = _chapter_files(spine_files, chapter_hrefs)
|
||||
|
||||
chapters_out: list[dict] = []
|
||||
for i, (meta, files) in enumerate(zip(toc, chapter_file_ranges), start=1):
|
||||
paragraphs: list[str] = []
|
||||
for f in files:
|
||||
paragraphs.extend(_extract_paragraphs(zf, f))
|
||||
# Drop leading paragraph(s) that just echo the chapter title — the
|
||||
# title is already stored separately.
|
||||
title_norm = _normalise(meta["title"]).lower()
|
||||
while paragraphs and _normalise(paragraphs[0]).lower() == title_norm:
|
||||
paragraphs.pop(0)
|
||||
chapters_out.append(
|
||||
{
|
||||
"id": f"ch{i}",
|
||||
"number": i,
|
||||
"title": meta["title"],
|
||||
"paragraphsES": paragraphs,
|
||||
}
|
||||
)
|
||||
|
||||
slug = args.slug or _slugify(opf["title"]) or args.epub.stem
|
||||
out_dir = args.out or (Path("build") / slug)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = out_dir / "chapters.json"
|
||||
|
||||
payload = {
|
||||
"title": opf["title"],
|
||||
"author": opf["author"],
|
||||
"language": opf["language"],
|
||||
"slug": slug,
|
||||
"chapters": chapters_out,
|
||||
}
|
||||
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
total_paragraphs = sum(len(c["paragraphsES"]) for c in chapters_out)
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" Title: {opf['title']}")
|
||||
print(f" Author: {opf['author']}")
|
||||
print(f" Chapters: {len(chapters_out)}")
|
||||
print(f" Paragraphs: {total_paragraphs}")
|
||||
for ch in chapters_out:
|
||||
print(f" ch{ch['number']:02d} {len(ch['paragraphsES']):4d} ¶ {ch['title']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrate the books pipeline: EPUB -> chapters.json -> per-chapter job
|
||||
# manifest -> (translation by Claude Code subagents) -> bundled book_<slug>.json.
|
||||
#
|
||||
# This script DOES NOT run the LLM translation pass. After Phase 2 it stops
|
||||
# and prints how many jobs are pending. Use Claude Code subagents (or a fresh
|
||||
# session per the README) to fill in build/<slug>/jobs/*.output.json, then
|
||||
# re-run this script — it will pick up where it left off via Phase 3.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh <epub_path> [--slug SLUG] [--batch-size N]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "usage: $0 <epub_path> [--slug SLUG] [--batch-size N]"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
EPUB="$1"; shift
|
||||
SLUG=""
|
||||
BATCH_SIZE="30"
|
||||
GLOSSARY_BATCH_SIZE="150"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--slug) SLUG="$2"; shift 2 ;;
|
||||
--batch-size) BATCH_SIZE="$2"; shift 2 ;;
|
||||
--glossary-batch-size) GLOSSARY_BATCH_SIZE="$2"; shift 2 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
EPUB_ABS="$(cd "$(dirname "$EPUB")" && pwd)/$(basename "$EPUB")"
|
||||
|
||||
echo "=== Phase 1: extract_epub.py ==="
|
||||
if [[ -n "$SLUG" ]]; then
|
||||
python3 extract_epub.py "$EPUB_ABS" --slug "$SLUG"
|
||||
else
|
||||
python3 extract_epub.py "$EPUB_ABS"
|
||||
fi
|
||||
|
||||
# If --slug wasn't passed, recover the slug from the chapters file just written.
|
||||
if [[ -z "$SLUG" ]]; then
|
||||
SLUG=$(python3 -c "import json,glob; p=sorted(glob.glob('build/*/chapters.json'), key=lambda x: -__import__('os').path.getmtime(x))[0]; print(json.load(open(p))['slug'])")
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Phase 2: translate_chapters.py ==="
|
||||
python3 translate_chapters.py "$SLUG" --batch-size "$BATCH_SIZE"
|
||||
|
||||
PENDING_FILE="build/$SLUG/jobs/_pending.txt"
|
||||
PENDING_COUNT=$(wc -l < "$PENDING_FILE" | tr -d ' ')
|
||||
|
||||
echo
|
||||
echo "=== Phase 2b: build_glossary.py ==="
|
||||
python3 build_glossary.py "$SLUG" --batch-size "$GLOSSARY_BATCH_SIZE"
|
||||
|
||||
GLOSS_PENDING_FILE="build/$SLUG/glossary/_pending.txt"
|
||||
GLOSS_PENDING_COUNT=$(wc -l < "$GLOSS_PENDING_FILE" | tr -d ' ')
|
||||
TOTAL_PENDING=$((PENDING_COUNT + GLOSS_PENDING_COUNT))
|
||||
|
||||
echo
|
||||
echo "=== Phase 3: bundle_book.py ==="
|
||||
if [[ "$TOTAL_PENDING" -gt 0 ]]; then
|
||||
echo " $PENDING_COUNT translation job(s) and $GLOSS_PENDING_COUNT glossary job(s) still pending."
|
||||
echo " Run the Claude Code subagent step (see README.md) for BOTH manifests:"
|
||||
echo " build/$SLUG/jobs/_pending.txt (translation)"
|
||||
echo " build/$SLUG/glossary/_pending.txt (glossary)"
|
||||
echo " then re-run this script. Bundling with placeholders so you can preview now."
|
||||
python3 bundle_book.py "$SLUG"
|
||||
else
|
||||
python3 bundle_book.py "$SLUG" --require-all
|
||||
fi
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split chapters.json into translation jobs that Claude Code subagents can
|
||||
process in parallel. Resumable: jobs whose output file already exists are
|
||||
skipped.
|
||||
|
||||
Usage:
|
||||
python3 translate_chapters.py <slug> [--batch-size N] [--build BUILD_DIR]
|
||||
|
||||
Inputs:
|
||||
BUILD_DIR/<slug>/chapters.json (from extract_epub.py)
|
||||
|
||||
Outputs:
|
||||
BUILD_DIR/<slug>/jobs/<jobid>.input.json (one per batch — read by subagents)
|
||||
BUILD_DIR/<slug>/jobs/_pending.txt (list of job IDs still missing output)
|
||||
BUILD_DIR/<slug>/jobs/_prompt_template.md (prompt the orchestrator hands each subagent)
|
||||
|
||||
Job layout (.input.json):
|
||||
{
|
||||
"jobId": "ch06_b00",
|
||||
"chapter": 6,
|
||||
"chapterTitle": "1. El Castillo",
|
||||
"rangeStart": 0,
|
||||
"rangeEnd": 30,
|
||||
"paragraphsES": ["...", "..."]
|
||||
}
|
||||
|
||||
Subagents must write `<jobid>.output.json` with shape:
|
||||
{"jobId": "ch06_b00", "paragraphsEN": ["...", "..."]}
|
||||
|
||||
The output array MUST have the same length as paragraphsES, in the same order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = """\
|
||||
You are translating a chunk of a Spanish-language book into English for a
|
||||
language-learning app.
|
||||
|
||||
Input file: {input_path}
|
||||
Output file: {output_path}
|
||||
|
||||
Read the input file. It contains a JSON object with a `paragraphsES` array.
|
||||
Translate each paragraph into natural English. Preserve meaning, tone, and
|
||||
dialogue markers (—, –, ¡, ¿) as appropriate for the English output. Keep
|
||||
the same number of paragraphs in the same order.
|
||||
|
||||
Notes for translation quality:
|
||||
- This is a beginner Spanish reader, so prefer plain natural English over
|
||||
literary flourish.
|
||||
- Preserve proper nouns (character names, place names) verbatim.
|
||||
- Convert Spanish dialogue dashes (–, —) to English-style quotation marks
|
||||
ONLY if it reads more naturally; otherwise keep them as em-dashes.
|
||||
- Do NOT add explanatory parentheticals; the in-app dictionary handles
|
||||
per-word lookup.
|
||||
- Some paragraphs are vocabulary entries shaped like `palabra = meaning`
|
||||
(e.g. `alto = tall`, `el dueño = owner`). Keep these verbatim — both the
|
||||
Spanish word and its English gloss already coexist on the line, and the
|
||||
bilingual reader UI shows the same line in both views.
|
||||
|
||||
Write the output as JSON with shape:
|
||||
{{"jobId": "<the jobId from the input>", "paragraphsEN": [...]}}
|
||||
|
||||
The `paragraphsEN` array MUST be the same length and order as `paragraphsES`
|
||||
in the input. Write nothing else to disk and produce no other output.
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("slug")
|
||||
parser.add_argument("--batch-size", type=int, default=30)
|
||||
parser.add_argument("--build", type=Path, default=Path("build"))
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.build / args.slug
|
||||
chapters_path = base / "chapters.json"
|
||||
jobs_dir = base / "jobs"
|
||||
jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = json.loads(chapters_path.read_text(encoding="utf-8"))
|
||||
|
||||
pending: list[str] = []
|
||||
completed: list[str] = []
|
||||
total_jobs = 0
|
||||
|
||||
for ch in data["chapters"]:
|
||||
paragraphs = ch["paragraphsES"]
|
||||
if not paragraphs:
|
||||
continue
|
||||
for offset in range(0, len(paragraphs), args.batch_size):
|
||||
chunk = paragraphs[offset : offset + args.batch_size]
|
||||
job_id = f"ch{ch['number']:02d}_b{offset // args.batch_size:02d}"
|
||||
input_path = jobs_dir / f"{job_id}.input.json"
|
||||
output_path = jobs_dir / f"{job_id}.output.json"
|
||||
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"jobId": job_id,
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"rangeStart": offset,
|
||||
"rangeEnd": offset + len(chunk),
|
||||
"paragraphsES": chunk,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
total_jobs += 1
|
||||
if output_path.exists():
|
||||
completed.append(job_id)
|
||||
else:
|
||||
pending.append(job_id)
|
||||
|
||||
(jobs_dir / "_pending.txt").write_text("\n".join(pending) + ("\n" if pending else ""))
|
||||
|
||||
(jobs_dir / "_prompt_template.md").write_text(
|
||||
PROMPT_TEMPLATE.format(
|
||||
input_path="<JOB_INPUT_PATH>",
|
||||
output_path="<JOB_OUTPUT_PATH>",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"Total translation jobs: {total_jobs}")
|
||||
print(f" Completed: {len(completed)}")
|
||||
print(f" Pending: {len(pending)}")
|
||||
print(f"Manifest at: {jobs_dir / '_pending.txt'}")
|
||||
print(f"Prompt template at: {jobs_dir / '_prompt_template.md'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown report of every curated YouTube video referenced by the app.
|
||||
|
||||
Reads Conjuga/youtube_videos.json, queries yt-dlp for metadata on each video,
|
||||
and emits Conjuga/youtube_videos.md with tables for tense guides and grammar
|
||||
notes plus a list of topics with no curated video.
|
||||
|
||||
Usage:
|
||||
python3 Scripts/generate_videos_markdown.py
|
||||
|
||||
Requires `yt-dlp` on PATH. Videos that have been taken down or made private
|
||||
appear in the tables with an "(unavailable)" marker in the title column.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VIDEOS_JSON = REPO_ROOT / "Conjuga" / "youtube_videos.json"
|
||||
OUTPUT_MD = REPO_ROOT / "Conjuga" / "youtube_videos.md"
|
||||
|
||||
# The curated ids we expect — anything in the source file that's missing from
|
||||
# the JSON shows up in the "missing" section at the bottom.
|
||||
EXPECTED_TENSE_IDS = [
|
||||
"ind_presente", "ind_preterito", "ind_imperfecto", "ind_futuro",
|
||||
"ind_perfecto", "ind_pluscuamperfecto", "ind_futuro_perfecto",
|
||||
"ind_preterito_anterior",
|
||||
"cond_presente", "cond_perfecto",
|
||||
"subj_presente", "subj_imperfecto_1", "subj_imperfecto_2",
|
||||
"subj_perfecto", "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2",
|
||||
"subj_futuro", "subj_futuro_perfecto",
|
||||
"imp_afirmativo", "imp_negativo",
|
||||
]
|
||||
|
||||
EXPECTED_GRAMMAR_IDS = [
|
||||
"ser-vs-estar", "por-vs-para", "preterite-vs-imperfect",
|
||||
"subjunctive-triggers", "reflexive-verbs", "object-pronouns",
|
||||
"gustar-like-verbs", "comparatives-superlatives",
|
||||
"conditional-if-clauses", "commands-imperative", "saber-vs-conocer",
|
||||
"double-negatives", "adjective-placement", "tener-expressions",
|
||||
"personal-a", "relative-pronouns", "future-vs-ir-a",
|
||||
"accent-marks-stress", "se-constructions", "estar-gerund-progressive",
|
||||
"spanish-suffixes", "common-irregular-verbs", "types-of-irregular-verbs",
|
||||
"present-indicative-conjugation", "articles-and-gender",
|
||||
"possessive-adjectives", "demonstrative-adjectives",
|
||||
"greetings-farewells", "poder-infinitive", "al-del-contractions",
|
||||
"prepositional-pronouns", "irregular-yo-verbs", "stem-changing-verbs",
|
||||
"stressed-possessives", "present-perfect-tense", "future-perfect-tense",
|
||||
]
|
||||
|
||||
|
||||
def fetch_metadata(video_id: str) -> dict:
|
||||
"""Return a dict of useful metadata fields for a single video.
|
||||
|
||||
On any yt-dlp failure (video removed, network issue, extraction break)
|
||||
returns a dict with `unavailable=True` so the caller can mark the row.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--skip-download", "--dump-json", "--no-warnings", "--", video_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"unavailable": True, "reason": "timeout"}
|
||||
|
||||
if result.returncode != 0:
|
||||
# yt-dlp errors look like:
|
||||
# "ERROR: [youtube] ID: <reason>. <cookie/help nag with URLs…>"
|
||||
# Extract just <reason> and drop everything after the first "." so the
|
||||
# markdown table stays readable. Help URLs contain colons so a naive
|
||||
# split-on-colon grabs the wrong chunk.
|
||||
reason = "yt-dlp failed"
|
||||
pattern = re.compile(r"ERROR:\s*\[[^\]]+\]\s*[^:]+:\s*(.+)")
|
||||
for line in result.stderr.strip().splitlines():
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
reason = m.group(1).split(". ")[0].rstrip(".")
|
||||
break
|
||||
return {"unavailable": True, "reason": reason}
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {"unavailable": True, "reason": "invalid json"}
|
||||
|
||||
return {
|
||||
"unavailable": False,
|
||||
"title": data.get("title") or "",
|
||||
"uploader": data.get("uploader") or data.get("channel") or "",
|
||||
"upload_date": data.get("upload_date") or "", # YYYYMMDD
|
||||
"duration": data.get("duration"), # seconds
|
||||
"view_count": data.get("view_count"),
|
||||
"like_count": data.get("like_count"),
|
||||
}
|
||||
|
||||
|
||||
def fmt_duration(seconds: int | None) -> str:
|
||||
if not seconds:
|
||||
return "—"
|
||||
h, rem = divmod(int(seconds), 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
def fmt_date(raw: str) -> str:
|
||||
if not raw or len(raw) != 8:
|
||||
return "—"
|
||||
return f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}"
|
||||
|
||||
|
||||
def fmt_int(n: int | None) -> str:
|
||||
if n is None:
|
||||
return "—"
|
||||
return f"{n:,}"
|
||||
|
||||
|
||||
def render_row(key: str, curated: dict, meta: dict) -> str:
|
||||
video_id = curated["videoId"]
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
if meta.get("unavailable"):
|
||||
title = f"_(unavailable — {meta.get('reason', 'unknown')})_"
|
||||
channel = "—"
|
||||
uploaded = "—"
|
||||
duration = "—"
|
||||
views = "—"
|
||||
likes = "—"
|
||||
else:
|
||||
title = meta.get("title") or curated.get("title") or ""
|
||||
# Escape pipes in titles so table rendering doesn't break.
|
||||
title = title.replace("|", "\\|")
|
||||
channel = (meta.get("uploader") or "—").replace("|", "\\|")
|
||||
uploaded = fmt_date(meta.get("upload_date", ""))
|
||||
duration = fmt_duration(meta.get("duration"))
|
||||
views = fmt_int(meta.get("view_count"))
|
||||
likes = fmt_int(meta.get("like_count"))
|
||||
|
||||
return f"| `{key}` | {title} | {channel} | {uploaded} | {duration} | {views} | {likes} | [watch]({url}) |"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with VIDEOS_JSON.open() as f:
|
||||
data = json.load(f)
|
||||
|
||||
tense_entries = data.get("tenseGuides", {})
|
||||
grammar_entries = data.get("grammarNotes", {})
|
||||
|
||||
# Collect all unique videoIds so we only call yt-dlp once per video
|
||||
# (several grammar notes reuse tense-guide videos).
|
||||
video_ids = {e["videoId"] for e in tense_entries.values()} | {
|
||||
e["videoId"] for e in grammar_entries.values()
|
||||
}
|
||||
|
||||
print(f"Fetching metadata for {len(video_ids)} unique videos…", file=sys.stderr)
|
||||
|
||||
metadata: dict[str, dict] = {}
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
future_to_id = {pool.submit(fetch_metadata, vid): vid for vid in video_ids}
|
||||
for future in as_completed(future_to_id):
|
||||
vid = future_to_id[future]
|
||||
metadata[vid] = future.result()
|
||||
status = "✗" if metadata[vid].get("unavailable") else "✓"
|
||||
print(f" {status} {vid}", file=sys.stderr)
|
||||
|
||||
missing_tenses = [tid for tid in EXPECTED_TENSE_IDS if tid not in tense_entries]
|
||||
missing_grammar = [gid for gid in EXPECTED_GRAMMAR_IDS if gid not in grammar_entries]
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Curated YouTube Videos")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Every tense guide and grammar note in the app can be tied to a single "
|
||||
"curated YouTube video. This file is generated from "
|
||||
"`Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` "
|
||||
"— regenerate when you add or change entries."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"- Total tense-guide entries: **{len(tense_entries)}** of {len(EXPECTED_TENSE_IDS)}")
|
||||
lines.append(f"- Total grammar-note entries: **{len(grammar_entries)}** of {len(EXPECTED_GRAMMAR_IDS)}")
|
||||
lines.append(f"- Last verified: **{today}** (run `python3 Scripts/generate_videos_markdown.py` to refresh)")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Like counts are often blank because YouTube hides the public count on "
|
||||
"most videos for signed-out requests. Titles and durations are pulled "
|
||||
"live from YouTube; unavailable entries mean the video has been taken "
|
||||
"down, made private, or region-locked. A few entries marked "
|
||||
"\"not available on this app\" are a transient yt-dlp extraction limit "
|
||||
"— the video itself still plays fine when tapping Stream in the app."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Tense guides section
|
||||
lines.append("## Tense guides")
|
||||
lines.append("")
|
||||
lines.append("Tied to `TenseGuide.tenseId` in the Guide tab.")
|
||||
lines.append("")
|
||||
lines.append("| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for tid in EXPECTED_TENSE_IDS:
|
||||
if tid not in tense_entries:
|
||||
continue
|
||||
entry = tense_entries[tid]
|
||||
lines.append(render_row(tid, entry, metadata.get(entry["videoId"], {})))
|
||||
lines.append("")
|
||||
|
||||
# Grammar notes section
|
||||
lines.append("## Grammar notes")
|
||||
lines.append("")
|
||||
lines.append("Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.")
|
||||
lines.append("")
|
||||
lines.append("| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |")
|
||||
lines.append("|---|---|---|---|---|---|---|---|")
|
||||
for gid in EXPECTED_GRAMMAR_IDS:
|
||||
if gid not in grammar_entries:
|
||||
continue
|
||||
entry = grammar_entries[gid]
|
||||
lines.append(render_row(gid, entry, metadata.get(entry["videoId"], {})))
|
||||
lines.append("")
|
||||
|
||||
# Missing section
|
||||
if missing_tenses or missing_grammar:
|
||||
lines.append("## Topics without a curated video")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"These show a \"No video yet\" label in the app. Add entries to "
|
||||
"`Conjuga/youtube_videos.json` to fill them in."
|
||||
)
|
||||
lines.append("")
|
||||
if missing_tenses:
|
||||
lines.append("**Tense guides:**")
|
||||
lines.append("")
|
||||
for tid in missing_tenses:
|
||||
lines.append(f"- `{tid}`")
|
||||
lines.append("")
|
||||
if missing_grammar:
|
||||
lines.append("**Grammar notes:**")
|
||||
lines.append("")
|
||||
for gid in missing_grammar:
|
||||
lines.append(f"- `{gid}`")
|
||||
lines.append("")
|
||||
|
||||
OUTPUT_MD.write_text("\n".join(lines))
|
||||
print(f"\nWrote {OUTPUT_MD.relative_to(REPO_ROOT)}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
in/
|
||||
out/
|
||||
@@ -0,0 +1,119 @@
|
||||
# Guide enrichment plan
|
||||
|
||||
**Trigger**: WEIRDO was missing from the present-subjunctive guide. That's a perfect example of a deeper problem — most tense guides are surface-level reference cards (2-3 usages + examples), missing the mnemonics, contrast tables, and exception lists a real Spanish teacher would hand out.
|
||||
|
||||
**Goal**: bring every tense guide and grammar note up to "teacher-handout" depth — enough that a learner could study from it alone and pass a quiz.
|
||||
|
||||
## Current state (audit, 2026-05-11)
|
||||
|
||||
| Surface | Items | Source of truth | Typical body length | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| Tense guides | 20 | `Conjuga/Conjuga/conjuga_data.json` → `tenseGuides[]` | 500–1500 chars | **Shallow** — bare *Usages* + examples |
|
||||
| Grammar notes | ~36 | `Conjuga/Conjuga/Models/GrammarNote.swift` (`GrammarNote.allNotes`, `generatedNotes`) | 1500–3000 chars | **Decent** — most have mnemonics and contrast examples |
|
||||
| Reference store | — | `Conjuga/Conjuga/Services/ReferenceStore.swift` | varies | Not in scope for this pass |
|
||||
|
||||
Tense guides are the bulk of the work. Grammar notes need a smaller audit-and-fill pass.
|
||||
|
||||
## What "thorough" looks like
|
||||
|
||||
Every tense guide should include, at minimum:
|
||||
|
||||
1. **Quick TL;DR** — one sentence: what is this tense for?
|
||||
2. **When to use it** — numbered usages, each with 2 contrast examples (a clear case and a borderline / common-mistake case).
|
||||
3. **How to form it** — conjugation pattern for regular verbs (one table per AR/ER/IR if it differs), plus the irregular pattern callout if applicable. Cross-reference the conjugator screens if relevant.
|
||||
4. **Common irregulars** — top 5–10 irregular verbs that learners will hit immediately in this tense (ser, estar, ir, tener, haber, dar, ver, decir, hacer, querer, poder, poner, saber, salir, traer, venir).
|
||||
5. **Triggers / mnemonics** — words and structures that signal this tense. WEIRDO and ESCAPA for subjunctive; "yesterday / last X / specific time" for preterite; "used to / when I was a kid" for imperfect; etc.
|
||||
6. **Pitfalls** — the top 3–5 mistakes English speakers make. e.g. preterite vs imperfect mixups, ir vs venir, ser vs estar overlap.
|
||||
7. **Tense-vs-tense contrast** — pair with the closest neighbour and show 2 minimal pairs (preterite ↔ imperfect, present ↔ present-progressive, future ↔ ir-a + infinitive, subjunctive-presente ↔ subjunctive-imperfecto).
|
||||
8. **Real-world feel** — 2–3 dialogue-style examples showing the tense in natural use, not just isolated sentences.
|
||||
|
||||
Every grammar note should include, at minimum:
|
||||
1. The core distinction in one line.
|
||||
2. Each side of the distinction with 4–6 clear examples covering different positions in a sentence.
|
||||
3. A mnemonic if one is standard in the language (DOCTOR/PLACE, WEIRDO, ESCAPA, etc.).
|
||||
4. Edge cases / verbs that change meaning (e.g. ser/estar adjectives, conocer/saber overlap).
|
||||
5. A practice prompt: "Try translating these 3 sentences, then check below."
|
||||
|
||||
## Priority order
|
||||
|
||||
Triaged by learner impact (frequency of use × typical confusion):
|
||||
|
||||
**Tier 1 — most-used, most-confused** (do first):
|
||||
1. `ind_presente` (Present indicative) — already 1324 chars, the longest tense guide. Audit for gaps; probably needs irregular tables.
|
||||
2. `ind_preterito` (Preterite) — currently 492 chars, the shortest. **Highest priority** — every learner hits this and gets it wrong.
|
||||
3. `ind_imperfecto` (Imperfect) — 774 chars. Always taught alongside preterite; the contrast is the entire game.
|
||||
4. `subj_presente` (Present subjunctive) — ✅ done in this pass.
|
||||
5. `imp_afirmativo` + `imp_negativo` (Imperative pair) — combined 2037 chars. Needs the tú/usted/nosotros/vosotros table and the negative-flips-to-subjunctive rule highlighted.
|
||||
|
||||
**Tier 2 — common but often skimped**:
|
||||
6. `ind_futuro` (Simple future) — needs contrast with ir-a + infinitive (already covered in grammar notes; cross-link).
|
||||
7. `cond_presente` (Conditional) — needs the "if-clause" patterns and the "softening request" usage ("¿Podrías…?").
|
||||
8. `ind_perfecto` (Present perfect) — needs the haber + past participle conjugation table and the "ya / todavía / alguna vez" trigger words.
|
||||
9. `subj_imperfecto_1` + `subj_imperfecto_2` (Past subjunctive -ra / -se) — needs the if-clause + condicional pairing.
|
||||
|
||||
**Tier 3 — compound and less-frequent** (still must be thorough):
|
||||
10. `ind_pluscuamperfecto`, `ind_futuro_perfecto`, `ind_preterito_anterior` (literary)
|
||||
11. `cond_perfecto`, `subj_perfecto`, `subj_pluscuamperfecto_1`, `subj_pluscuamperfecto_2`
|
||||
12. `subj_futuro`, `subj_futuro_perfecto` (largely archaic — note they're rare but explain why they exist)
|
||||
|
||||
**Grammar notes audit**:
|
||||
- Pass through all 36, score each on the "thorough" criteria above.
|
||||
- Fill the gaps. Most already have mnemonics; some don't.
|
||||
|
||||
## Research sources
|
||||
|
||||
Cite explicitly in each draft so reviewers can verify. Order of trust:
|
||||
|
||||
1. **Real Academia Española (RAE) — Nueva gramática de la lengua española** — authoritative reference. Free online: `rae.es`.
|
||||
2. **Studyspanish.com** and **SpanishDict.com** grammar references — best free per-topic explanations, well-curated example sentences.
|
||||
3. **Practice Makes Perfect: Complete Spanish Grammar** (Dorothy Richmond, McGraw-Hill) — standard teaching reference. The PDF is already at the repo root for cross-reference.
|
||||
4. **Lawless Spanish** (Laura Lawless) — accurate, concise, good on subjunctive nuances.
|
||||
5. **The user's existing textbook** — *Complete Spanish Step-by-Step* (Bregstein) is already bundled. Cross-reference its chapter on each tense to keep voice consistent.
|
||||
6. **YouTube — Butterfly Spanish (Ana), Spring Spanish, Dreaming Spanish (Pablo)** — for natural-use examples and the "feel" of when a native reaches for the tense. The repo already has a curated YouTube list at `Conjuga/Conjuga/youtube_videos.json` — pull from there when a topic has a matching video.
|
||||
|
||||
For mnemonics specifically: WEIRDO, ESCAPA, DOCTOR, PLACE are standard. Don't invent new ones unless we can't find a known one.
|
||||
|
||||
## Workflow per topic
|
||||
|
||||
This is what an enrichment "unit of work" looks like:
|
||||
|
||||
1. **Draft** — A research agent (Claude Code subagent, no API key, same pattern as the book translation pipeline) reads the current guide body, consults the sources listed above, drafts a new body following the "thorough" structure. Writes to `Conjuga/Scripts/guide-enrichment/drafts/<topicId>.md`.
|
||||
2. **Self-review** — same agent re-reads its own draft against the checklist (TL;DR present? mnemonic present? contrast pair? top 3 pitfalls?). Notes anything it couldn't find a source for.
|
||||
3. **Integrate** — a script reads the draft, swaps it into `conjuga_data.json` (for tense guides) or `GrammarNote.swift` (for grammar notes), bumps `courseDataVersion`, runs build to verify.
|
||||
4. **Spot-check** — user opens the topic in the app on device, reads it, flags anything that feels wrong or missing.
|
||||
5. **Commit** — one commit per topic, message: "Guide enrichment — <topic> (tier N)".
|
||||
|
||||
Batching: do tier-1 topics one at a time so the user can review and shape what "thorough enough" looks like. Tiers 2 and 3 can batch 3–5 topics per session once the format is dialed in.
|
||||
|
||||
## Tooling
|
||||
|
||||
Two small scripts will speed this up:
|
||||
|
||||
- **`enrich_topic.py <topicId>`** — opens the current body, writes a Markdown template at `drafts/<topicId>.md` with the section headers pre-filled, and prints a research prompt the user can hand to a subagent.
|
||||
- **`apply_draft.py <topicId>`** — reads `drafts/<topicId>.md`, validates the section structure, swaps it into `conjuga_data.json` (or `GrammarNote.swift` for grammar notes), bumps `courseDataVersion`.
|
||||
|
||||
Build both when starting tier 1. Don't build them speculatively now.
|
||||
|
||||
## Effort estimate
|
||||
|
||||
- Tier 1 (5 topics): ~30 min research + 30 min draft + 15 min integrate = **~75 min per topic, ~6 hours total**.
|
||||
- Tier 2 (4 topics): faster once the format is dialed in. ~45 min each, ~3 hours.
|
||||
- Tier 3 (11 topics): ~30 min each (most are compound tenses with similar structure), ~5 hours.
|
||||
- Grammar notes audit + fill: ~10 min audit each × 36 = 6 hours; ~30 min fill on the ~10 that need it = 5 hours. Total ~11 hours.
|
||||
|
||||
**Total scoped at ~25 hours.** Spread across sessions: maybe one tier-1 topic per session, two tier-2 or three tier-3 per session once the format's locked in.
|
||||
|
||||
## Ship plan
|
||||
|
||||
- Each commit is one topic enriched. Small, reviewable diffs.
|
||||
- `courseDataVersion` bumps per commit so the change propagates on next launch.
|
||||
- The user can preview new bodies via the in-app Guide tab without needing a redeploy after the commit hits gitea — they just need to rebuild + reinstall.
|
||||
- The plan doc itself lives here so future sessions can pick up where this one left off without needing to re-derive the structure.
|
||||
|
||||
## Out of scope (intentional)
|
||||
|
||||
- Audio recordings of example sentences (could be a future TTS pre-bake).
|
||||
- Per-region variants (Latin American vs Castilian usage notes) — flag when they matter (vosotros, leísmo), don't comprehensively document.
|
||||
- Interactive exercises tied to each guide (separate Tests/Quiz infrastructure exists; cross-link instead of duplicate).
|
||||
- Translation of the guides into Spanish (current guides are English-explanation, Spanish-examples; keep that asymmetry).
|
||||
- A complete grammar-textbook rewrite. Stop at "depth a teacher would hand out as supplementary material."
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply enriched bodies from drafts/out/ back into the live source files.
|
||||
|
||||
Tense guides → Conjuga/Conjuga/conjuga_data.json (tenseGuides[].body)
|
||||
Grammar notes → Conjuga/Conjuga/Models/GrammarNote.swift (body: \"\"\"...\"\"\")
|
||||
|
||||
Filename conventions in drafts/out/:
|
||||
tense__<tenseId>.md — body to drop into the matching tenseGuide
|
||||
note__<noteId>.md — body to drop into the matching GrammarNote(...) declaration
|
||||
|
||||
Run from anywhere; uses absolute paths anchored at the repo root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path('/Users/m4mini/Desktop/code/Spanish')
|
||||
OUT_DIR = REPO / 'Conjuga/Scripts/guide-enrichment/out'
|
||||
TENSE_JSON = REPO / 'Conjuga/Conjuga/conjuga_data.json'
|
||||
NOTES_SWIFT = REPO / 'Conjuga/Conjuga/Models/GrammarNote.swift'
|
||||
|
||||
|
||||
def read_draft(path: Path) -> str:
|
||||
"""Drafts may start with comment blocks like `# Title: ...`, `# Category:
|
||||
...`, and `# ENRICHED BODY` separated by blank lines. Strip every leading
|
||||
line that is blank or starts with `#` until we reach the actual body."""
|
||||
raw = path.read_text(encoding='utf-8')
|
||||
lines = raw.splitlines()
|
||||
start = 0
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == '' or stripped.startswith('#'):
|
||||
start = i + 1
|
||||
continue
|
||||
# Hit the first real content line — keep everything from here.
|
||||
start = i
|
||||
break
|
||||
body = '\n'.join(lines[start:]).strip()
|
||||
if not body:
|
||||
raise ValueError(f'Empty body after stripping header in {path}')
|
||||
return body
|
||||
|
||||
|
||||
def apply_tense_guides() -> int:
|
||||
data = json.loads(TENSE_JSON.read_text(encoding='utf-8'))
|
||||
drafts = sorted(OUT_DIR.glob('tense__*.md'))
|
||||
by_id = {g['tenseId']: g for g in data['tenseGuides']}
|
||||
applied = 0
|
||||
for path in drafts:
|
||||
tense_id = path.stem.removeprefix('tense__')
|
||||
if tense_id not in by_id:
|
||||
print(f' SKIP {tense_id}: not in tenseGuides', file=sys.stderr)
|
||||
continue
|
||||
body = read_draft(path)
|
||||
by_id[tense_id]['body'] = body
|
||||
applied += 1
|
||||
print(f' applied tense: {tense_id} ({len(body)} chars)')
|
||||
TENSE_JSON.write_text(
|
||||
json.dumps(data, ensure_ascii=False, separators=(',', ':')),
|
||||
encoding='utf-8'
|
||||
)
|
||||
return applied
|
||||
|
||||
|
||||
# Match each GrammarNote(...) declaration. Body uses """...""" — may contain
|
||||
# anything except a triple-quote.
|
||||
NOTE_PATTERN = re.compile(
|
||||
r'(GrammarNote\(\s*id:\s*"([^"]+)",\s*'
|
||||
r'title:\s*"(?:[^"\\]|\\.)*",\s*'
|
||||
r'category:\s*"[^"]+",\s*'
|
||||
r'body:\s*""")(.*?)(""")',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def apply_grammar_notes() -> int:
|
||||
src = NOTES_SWIFT.read_text(encoding='utf-8')
|
||||
drafts = sorted(OUT_DIR.glob('note__*.md'))
|
||||
by_id = {p.stem.removeprefix('note__'): p for p in drafts}
|
||||
applied = [0]
|
||||
|
||||
def replace_match(m):
|
||||
prefix, note_id, _, suffix = m.group(1), m.group(2), m.group(3), m.group(4) if m.lastindex and m.lastindex >= 4 else m.group(3)
|
||||
return m.group(0) # placeholder, see real callback below
|
||||
|
||||
def real_replace(m):
|
||||
prefix = m.group(1)
|
||||
note_id = m.group(2)
|
||||
suffix = m.group(4)
|
||||
if note_id not in by_id:
|
||||
return m.group(0)
|
||||
body = read_draft(by_id[note_id])
|
||||
if '"""' in body:
|
||||
raise ValueError(f'Body for {note_id} contains triple-quote — would break Swift parser')
|
||||
# Re-indent to match the existing Swift block. The existing format uses
|
||||
# 8 spaces of leading indent inside body lines. We don't enforce that —
|
||||
# the Swift compiler handles multiline string indentation by stripping
|
||||
# the leading whitespace common to all lines based on the closing """.
|
||||
# Just write the body verbatim.
|
||||
applied[0] += 1
|
||||
print(f' applied note: {note_id} ({len(body)} chars)')
|
||||
return f'{prefix}\n{body}\n{suffix}'
|
||||
|
||||
new_src = NOTE_PATTERN.sub(real_replace, src)
|
||||
NOTES_SWIFT.write_text(new_src, encoding='utf-8')
|
||||
return applied[0]
|
||||
|
||||
|
||||
def main():
|
||||
if not OUT_DIR.exists():
|
||||
print(f'No drafts/out directory at {OUT_DIR}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f'=== Tense guides ===')
|
||||
tense_count = apply_tense_guides()
|
||||
print(f'\n=== Grammar notes ===')
|
||||
note_count = apply_grammar_notes()
|
||||
|
||||
print(f'\nTotal applied: {tense_count} tense guides + {note_count} grammar notes')
|
||||
if tense_count == 0 and note_count == 0:
|
||||
print('Nothing applied — drafts/out/ was empty.', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -46,6 +46,9 @@ SPANISH_ARTICLES = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
|
||||
ENGLISH_STARTERS = {"the", "a", "an", "to", "my", "his", "her", "our", "their"}
|
||||
|
||||
|
||||
HABER_FORMS = {"he", "has", "ha", "hemos", "habéis", "han"}
|
||||
|
||||
|
||||
def language_score(s: str) -> "tuple[int, int]":
|
||||
"""Return (es_score, en_score) for a string."""
|
||||
es = 0
|
||||
@@ -56,9 +59,17 @@ def language_score(s: str) -> "tuple[int, int]":
|
||||
if not words:
|
||||
return (es, en)
|
||||
first = words[0].strip(",.;:")
|
||||
if first in SPANISH_ARTICLES:
|
||||
second = words[1].strip(",.;:") if len(words) > 1 else ""
|
||||
# Spanish present-perfect ("he tenido", "Ha andado") starts with a haber
|
||||
# form followed by an -ado/-ido past participle. Recognise this pattern
|
||||
# before the bare-pronoun check so "he" isn't mistaken for English "he".
|
||||
if first in HABER_FORMS and (
|
||||
second.endswith(("ado", "ido", "to", "cho", "sto", "esto"))
|
||||
):
|
||||
es += 3
|
||||
elif first in SPANISH_ARTICLES:
|
||||
es += 2
|
||||
if first in ENGLISH_STARTERS:
|
||||
elif first in ENGLISH_STARTERS:
|
||||
en += 2
|
||||
# Spanish-likely endings on later words
|
||||
for w in words:
|
||||
|
||||
@@ -33,7 +33,8 @@ CHAPTERS_JSON = HERE / "chapters.json"
|
||||
ANSWERS_JSON = HERE / "answers.json"
|
||||
OCR_JSON = HERE / "ocr.json"
|
||||
PDF_OCR_JSON = HERE / "pdf_ocr.json"
|
||||
PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (preferred)
|
||||
PAIRED_VOCAB_JSON = HERE / "paired_vocab.json" # bounding-box pairs (fallback)
|
||||
PAIRED_VOCAB_LLM_JSON = HERE / "paired_vocab_llm.json" # LLM vision pairs (preferred)
|
||||
OUT_BOOK = HERE / "book.json"
|
||||
OUT_VOCAB = HERE / "vocab_cards.json"
|
||||
|
||||
@@ -224,8 +225,10 @@ def main() -> None:
|
||||
pdf_ocr_raw = load(PDF_OCR_JSON) if PDF_OCR_JSON.exists() else {}
|
||||
pdf_pages = build_pdf_page_index(pdf_ocr_raw) if pdf_ocr_raw else {}
|
||||
paired_vocab = load(PAIRED_VOCAB_JSON) if PAIRED_VOCAB_JSON.exists() else {}
|
||||
paired_llm = load(PAIRED_VOCAB_LLM_JSON) if PAIRED_VOCAB_LLM_JSON.exists() else {}
|
||||
print(f"Mapped {len(pdf_pages)} PDF pages to book page numbers")
|
||||
print(f"Loaded bounding-box pairs for {len(paired_vocab)} vocab images")
|
||||
print(f"Loaded LLM-vision pairs for {len(paired_llm)} vocab images")
|
||||
|
||||
# Build a global set of EPUB narrative lines (for subtraction when pulling vocab)
|
||||
narrative_set = set()
|
||||
@@ -282,28 +285,60 @@ def main() -> None:
|
||||
if repairs > 0:
|
||||
merged_pages += 1
|
||||
|
||||
# Prefer bounding-box pairs (from paired_vocab.json) when
|
||||
# present. Fall back to the block-alternation heuristic.
|
||||
# Source priority:
|
||||
# 1) LLM-vision pairs (paired_vocab_llm.json) — semantic
|
||||
# classification (pair_table / reference_only / hybrid)
|
||||
# with correct orientation.
|
||||
# 2) Bounding-box pairs (paired_vocab.json) — Vision OCR
|
||||
# with X-gap row splitting.
|
||||
# 3) Block-alternation heuristic — flat OCR fallback.
|
||||
llm_entry = paired_llm.get(src, {}) if isinstance(paired_llm.get(src), dict) else {}
|
||||
llm_kind = llm_entry.get("kind")
|
||||
llm_pairs = llm_entry.get("pairs", []) if llm_entry else []
|
||||
|
||||
bbox = paired_vocab.get(src, {})
|
||||
bbox_pairs = bbox.get("pairs", []) if isinstance(bbox, dict) else []
|
||||
|
||||
heuristic = build_vocab_cards_for_block(
|
||||
{"src": src},
|
||||
{"lines": merged_lines, "confidence": merged_conf},
|
||||
ch, current_section_title, bi
|
||||
)
|
||||
|
||||
if bbox_pairs:
|
||||
# Choose pair source. For reference_only (Spanish-only tables)
|
||||
# we deliberately produce no cards — the UI will fall back to
|
||||
# rendering the flat OCR lines as a reference list. Same for
|
||||
# hybrid images where the LLM determined no genuine pair rows
|
||||
# exist (e.g. estar conjugations with English glosses on the
|
||||
# header row only).
|
||||
if llm_kind == "reference_only" or (llm_kind == "hybrid" and not llm_pairs):
|
||||
cards_for_block = []
|
||||
pair_source = "llm-no-pairs"
|
||||
elif llm_pairs:
|
||||
cards_for_block = [
|
||||
{"front": p["es"], "back": p["en"]}
|
||||
for p in llm_pairs
|
||||
if p.get("es") and p.get("en")
|
||||
]
|
||||
for c in cards_for_block:
|
||||
all_vocab_cards.append({
|
||||
"front": c["front"], "back": c["back"],
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"section": current_section_title,
|
||||
"sourceImage": src,
|
||||
})
|
||||
pair_source = "llm-" + (llm_kind or "pairs")
|
||||
elif bbox_pairs:
|
||||
cards_for_block = [
|
||||
{"front": p["es"], "back": p["en"]}
|
||||
for p in bbox_pairs
|
||||
if p.get("es") and p.get("en")
|
||||
]
|
||||
# Also feed the flashcard deck
|
||||
for p in bbox_pairs:
|
||||
if p.get("es") and p.get("en"):
|
||||
all_vocab_cards.append({
|
||||
"front": p["es"],
|
||||
"back": p["en"],
|
||||
"front": p["es"], "back": p["en"],
|
||||
"chapter": ch["number"],
|
||||
"chapterTitle": ch["title"],
|
||||
"section": current_section_title,
|
||||
@@ -326,6 +361,7 @@ def main() -> None:
|
||||
"source": pair_source,
|
||||
"bookPage": book_page,
|
||||
"repairs": repairs,
|
||||
"tableKind": llm_kind,
|
||||
})
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A long-form bilingual book bundled with the app. Chapter content lives in
|
||||
/// `BookChapter` rows; this model carries the per-book metadata.
|
||||
@Model
|
||||
public final class Book {
|
||||
@Attribute(.unique) public var id: String = "" // matches `slug`
|
||||
public var slug: String = ""
|
||||
public var title: String = ""
|
||||
public var author: String = ""
|
||||
public var language: String = ""
|
||||
public var chapterCount: Int = 0
|
||||
public var accentColorHex: String = ""
|
||||
/// JSON-encoded `[String: WordGloss]` — the book reader's primary word
|
||||
/// lookup, keyed by the cleaned (lowercased, punctuation-trimmed) word.
|
||||
/// Pre-computed at import time so taps resolve instantly and in context.
|
||||
public var glossaryJSON: Data = Data()
|
||||
|
||||
public init(
|
||||
slug: String,
|
||||
title: String,
|
||||
author: String,
|
||||
language: String,
|
||||
chapterCount: Int,
|
||||
accentColorHex: String,
|
||||
glossaryJSON: Data = Data()
|
||||
) {
|
||||
self.id = slug
|
||||
self.slug = slug
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.language = language
|
||||
self.chapterCount = chapterCount
|
||||
self.accentColorHex = accentColorHex
|
||||
self.glossaryJSON = glossaryJSON
|
||||
}
|
||||
|
||||
/// The decoded per-book glossary. Decode once and cache at the call site —
|
||||
/// this re-decodes on every call.
|
||||
public func glossary() -> [String: WordGloss] {
|
||||
(try? JSONDecoder().decode([String: WordGloss].self, from: glossaryJSON)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// One glossary entry: a word's dictionary base form, English meaning, and
|
||||
/// part of speech, translated in the book's context at import time.
|
||||
public struct WordGloss: Codable, Hashable, Sendable {
|
||||
public let baseForm: String
|
||||
public let english: String
|
||||
public let partOfSpeech: String
|
||||
|
||||
public init(baseForm: String, english: String, partOfSpeech: String) {
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.partOfSpeech = partOfSpeech
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// One chapter of a `Book`. Spanish + English paragraphs are stored as JSON-
|
||||
/// encoded `[String]` so SwiftData doesn't have to manage variable-length
|
||||
/// arrays directly.
|
||||
@Model
|
||||
public final class BookChapter {
|
||||
@Attribute(.unique) public var id: String = "" // "<bookSlug>-ch<number>"
|
||||
public var bookSlug: String = ""
|
||||
public var number: Int = 0
|
||||
public var title: String = ""
|
||||
/// Spanish paragraph count, stored at seed time so chapter lists can show
|
||||
/// it without decoding the full `paragraphsESJSON` blob on every render.
|
||||
public var paragraphCount: Int = 0
|
||||
public var paragraphsESJSON: Data = Data()
|
||||
public var paragraphsENJSON: Data = Data()
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
bookSlug: String,
|
||||
number: Int,
|
||||
title: String,
|
||||
paragraphCount: Int,
|
||||
paragraphsESJSON: Data,
|
||||
paragraphsENJSON: Data
|
||||
) {
|
||||
self.id = id
|
||||
self.bookSlug = bookSlug
|
||||
self.number = number
|
||||
self.title = title
|
||||
self.paragraphCount = paragraphCount
|
||||
self.paragraphsESJSON = paragraphsESJSON
|
||||
self.paragraphsENJSON = paragraphsENJSON
|
||||
}
|
||||
|
||||
public func paragraphsES() -> [String] {
|
||||
(try? JSONDecoder().decode([String].self, from: paragraphsESJSON)) ?? []
|
||||
}
|
||||
|
||||
public func paragraphsEN() -> [String] {
|
||||
(try? JSONDecoder().decode([String].self, from: paragraphsENJSON)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Persistent record of a YouTube video downloaded to the device (Issue #21).
|
||||
/// Files live in the app's documents directory under `videos/<videoId>.mp4`;
|
||||
/// this model tracks the metadata needed to locate, display, and manage them.
|
||||
///
|
||||
/// Lives in the local store, not CloudKit — downloads are per-device.
|
||||
@Model
|
||||
public final class DownloadedVideo {
|
||||
/// YouTube video ID — the primary key (unique).
|
||||
@Attribute(.unique) public var videoId: String = ""
|
||||
public var title: String = ""
|
||||
public var filename: String = ""
|
||||
public var byteCount: Int = 0
|
||||
public var downloadedAt: Date = Date()
|
||||
|
||||
public init(videoId: String, title: String, filename: String, byteCount: Int) {
|
||||
self.videoId = videoId
|
||||
self.title = title
|
||||
self.filename = filename
|
||||
self.byteCount = byteCount
|
||||
self.downloadedAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// User mark on a vocab card for "extra study" focus. Cloud-synced so the set
|
||||
/// follows the user across devices. Denormalises card identity (deckId/front/
|
||||
/// back) so the Course tab can resolve a marked-cards view without joining
|
||||
/// against the local-only `VocabCard` store.
|
||||
///
|
||||
/// CloudKit forbids `@Attribute(.unique)`, so callers must fetch-or-create
|
||||
/// by `id` to maintain uniqueness in code.
|
||||
@Model
|
||||
public final class ExtraStudyMark {
|
||||
/// Stable hash of (deckId, front, back, examplesES, examplesEN). Same
|
||||
/// shape as `CourseCardStore.reviewKey(for:)` so a mark and a review
|
||||
/// card describe the same logical card.
|
||||
public var id: String = ""
|
||||
|
||||
public var deckId: String = ""
|
||||
public var courseName: String = ""
|
||||
public var weekNumber: Int = 0
|
||||
|
||||
public var front: String = ""
|
||||
public var back: String = ""
|
||||
|
||||
public var markedAt: Date = Date()
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
deckId: String,
|
||||
courseName: String,
|
||||
weekNumber: Int,
|
||||
front: String,
|
||||
back: String,
|
||||
markedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.deckId = deckId
|
||||
self.courseName = courseName
|
||||
self.weekNumber = weekNumber
|
||||
self.front = front
|
||||
self.back = back
|
||||
self.markedAt = markedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Decides whether a (verb, tense) combo is eligible for the Full Table
|
||||
/// practice mode. Pulled out of `PracticeSessionService` so the rule can be
|
||||
/// unit tested in isolation.
|
||||
public enum FullTableEligibility {
|
||||
|
||||
/// `VerbForm.regularity` values understood by the data set.
|
||||
public enum Regularity: String, Sendable {
|
||||
case regular // paradigm-teaching verbs (small curated set)
|
||||
case ordinary // pattern-following verbs (the bulk of the dataset)
|
||||
case irregular
|
||||
case orto // orthographic spelling change (e.g. busqué)
|
||||
}
|
||||
|
||||
/// Returns true when the given (verb, tense) combo is a candidate for
|
||||
/// Full Table — i.e. it follows the regular pattern with no irregularity
|
||||
/// and no orthographic spelling change.
|
||||
///
|
||||
/// The conjugation must be present in all 6 person slots; missing forms
|
||||
/// disqualify the combo.
|
||||
///
|
||||
/// Accepted: `regular` (paradigm-teaching verbs) and `ordinary`
|
||||
/// (pattern-following verbs — `hablar`, `comer`, `vivir`, etc.).
|
||||
/// Rejected: `irregular` and `orto` (orthographic spelling changes).
|
||||
public static func isFullyRegular(regularities: [String]) -> Bool {
|
||||
guard regularities.count == 6 else { return false }
|
||||
let acceptable: Set<String> = ["regular", "ordinary"]
|
||||
return regularities.allSatisfy { acceptable.contains($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
/// A single entry from the curated "100 most common reflexive verbs" list
|
||||
/// (Gitea issue #28). Sourced from spanishwithdaniel.com.
|
||||
///
|
||||
/// `baseInfinitive` is the stem without the reflexive "-se" suffix, used to
|
||||
/// match this entry to the app's Verb records (which store bare infinitives).
|
||||
/// `usageHint` captures trailing prepositions or set-phrase completions — e.g.,
|
||||
/// "a" for `acercarse a`, "de acuerdo" for `ponerse de acuerdo`. Nil when the
|
||||
/// reflexive form has no commonly paired preposition.
|
||||
public struct ReflexiveVerb: Codable, Hashable, Sendable {
|
||||
public let infinitive: String
|
||||
public let baseInfinitive: String
|
||||
public let english: String
|
||||
public let usageHint: String?
|
||||
|
||||
public init(infinitive: String, baseInfinitive: String, english: String, usageHint: String? = nil) {
|
||||
self.infinitive = infinitive
|
||||
self.baseInfinitive = baseInfinitive
|
||||
self.english = english
|
||||
self.usageHint = usageHint
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,22 @@ public enum SharedStore {
|
||||
/// and hit the exact container used for seeding.
|
||||
@MainActor
|
||||
public static var localContainer: ModelContainer?
|
||||
|
||||
/// The canonical model list for the local reference-data store.
|
||||
///
|
||||
/// The main app AND the widget extension MUST build their `ModelContainer`
|
||||
/// from this exact set. Opening the store with a *subset* schema makes
|
||||
/// SwiftData destructively migrate it — silently dropping every table that
|
||||
/// isn't listed. That is how widget refreshes repeatedly wiped the bundled
|
||||
/// `Book`/`BookChapter` rows (and `TextbookChapter` before them). Keeping
|
||||
/// one shared list means a newly-added model can't be forgotten in the
|
||||
/// widget and quietly nuke its own data.
|
||||
public static var localSchemaModels: [any PersistentModel.Type] {
|
||||
[
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self, DownloadedVideo.self,
|
||||
Book.self, BookChapter.self,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,23 @@ public struct WordAnnotation: Codable, Identifiable, Hashable {
|
||||
public let baseForm: String
|
||||
public let english: String
|
||||
public let partOfSpeech: String
|
||||
/// Human-readable name of the resource that produced this definition
|
||||
/// (e.g. "Book glossary", "Dictionary", "AI guess"). Defaulted so older
|
||||
/// persisted annotations without the field still decode.
|
||||
public var source: String = ""
|
||||
|
||||
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
|
||||
public init(
|
||||
word: String,
|
||||
baseForm: String,
|
||||
english: String,
|
||||
partOfSpeech: String,
|
||||
source: String = ""
|
||||
) {
|
||||
self.word = word
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.partOfSpeech = partOfSpeech
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
/// A single Spanish / English example sentence pair for a verb at a specific tense.
|
||||
/// Used by the Verb detail view (Issue #27). Generated at runtime via Foundation
|
||||
/// Models and cached to disk; shape is intentionally simple Codable for easy
|
||||
/// JSON persistence and cross-module sharing.
|
||||
public struct VerbExample: Codable, Hashable, Sendable {
|
||||
public let tenseId: String
|
||||
public let spanish: String
|
||||
public let english: String
|
||||
|
||||
public init(tenseId: String, spanish: String, english: String) {
|
||||
self.tenseId = tenseId
|
||||
self.spanish = spanish
|
||||
self.english = english
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Testing
|
||||
@testable import SharedModels
|
||||
|
||||
@Suite("FullTableEligibility")
|
||||
struct FullTableEligibilityTests {
|
||||
|
||||
// MARK: - Should be eligible
|
||||
|
||||
@Test("all-ordinary verb (e.g. hablar present) is eligible")
|
||||
func allOrdinary() {
|
||||
// hablar present: hablo / hablas / habla / hablamos / habláis / hablan
|
||||
let r = Array(repeating: "ordinary", count: 6)
|
||||
#expect(FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
@Test("all-regular paradigm verb (e.g. vivir present) is eligible")
|
||||
func allRegular() {
|
||||
// vivir present is tagged "regular" in the curated subset
|
||||
let r = Array(repeating: "regular", count: 6)
|
||||
#expect(FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
@Test("mixed regular + ordinary across persons is eligible")
|
||||
func mixedRegularOrdinary() {
|
||||
// The data never actually mixes these, but it shouldn't matter if it
|
||||
// did — both labels mean "follows the regular pattern".
|
||||
let r = ["regular", "ordinary", "regular", "ordinary", "regular", "ordinary"]
|
||||
#expect(FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
// MARK: - Should NOT be eligible
|
||||
|
||||
@Test("any irregular form rejects the combo")
|
||||
func anyIrregular() {
|
||||
var r = Array(repeating: "ordinary", count: 6)
|
||||
r[3] = "irregular"
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
@Test("orthographic spelling change rejects the combo (excluded by design)")
|
||||
func anyOrto() {
|
||||
// buscar preterite: yo "busqué" carries an orto tag for the c→qu shift.
|
||||
// Per design choice, orto verbs are NOT eligible for Full Table.
|
||||
var r = Array(repeating: "ordinary", count: 6)
|
||||
r[0] = "orto"
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
@Test("all-irregular rejects")
|
||||
func allIrregular() {
|
||||
let r = Array(repeating: "irregular", count: 6)
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
// MARK: - Edge cases
|
||||
|
||||
@Test("incomplete forms (fewer than 6 persons) are rejected")
|
||||
func incompleteForms() {
|
||||
let r = Array(repeating: "ordinary", count: 5)
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
|
||||
@Test("empty input is rejected")
|
||||
func empty() {
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: []))
|
||||
}
|
||||
|
||||
@Test("unknown regularity value is rejected (defensive default)")
|
||||
func unknownValue() {
|
||||
var r = Array(repeating: "ordinary", count: 6)
|
||||
r[2] = "garbage_value"
|
||||
#expect(!FullTableEligibility.isFullyRegular(regularities: r))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ schemes:
|
||||
packages:
|
||||
SharedModels:
|
||||
path: SharedModels
|
||||
YouTubeKit:
|
||||
url: https://github.com/alexeichhorn/YouTubeKit.git
|
||||
from: 0.3.0
|
||||
|
||||
settings:
|
||||
base:
|
||||
@@ -47,6 +50,14 @@ targets:
|
||||
buildPhase: resources
|
||||
- path: Conjuga/course_data.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/reflexive_verbs.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/youtube_videos.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/textbook_data.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/textbook_vocab.json
|
||||
buildPhase: resources
|
||||
info:
|
||||
path: Conjuga/Info.plist
|
||||
properties:
|
||||
@@ -78,6 +89,7 @@ targets:
|
||||
dependencies:
|
||||
- target: ConjugaWidgetExtension
|
||||
- package: SharedModels
|
||||
- package: YouTubeKit
|
||||
|
||||
ConjugaWidgetExtension:
|
||||
type: app-extension
|
||||
|
||||
@@ -9,15 +9,18 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
|
||||
- **Focus modes** — weak verbs (SM-2 spaced repetition), irregularity drills (spelling / stem / unique), common tenses
|
||||
- **1,750 verbs** across 5 levels (Basic → Expert) with 209 K pre-conjugated forms and 23 K irregular-span annotations
|
||||
- **20 tenses** — every indicative, subjunctive, conditional, and imperative tense, each with character-level irregular highlighting
|
||||
- **Irregularity filter** — search the verb list by Any Irregular / Spelling Change / Stem Change / Unique Irregular, combinable with level filter
|
||||
- **Verb list filters** — search by level, by irregularity (Any / Spelling Change / Stem Change / Unique Irregular), or by reflexive only (curated 100-verb list); filters compose
|
||||
- **Practice pool filters** (Settings) — multi-select per level, per tense, per irregular-type, plus a "reflexive verbs only" toggle. Practice pool = intersection of all four.
|
||||
- **Verb detail pages** — conjugation table, six AI-generated example sentences (one per core tense), and reflexive infinitive + preposition hint for curated reflexive verbs
|
||||
- **Text-to-speech** on any form
|
||||
|
||||
### Content & study
|
||||
- **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), 931 OCR'd vocab tables rendered as Spanish→English grids (~3 100 paired cards extracted via bounding-box OCR)
|
||||
- **Course decks** — weekly vocab decks with example sentences, week tests, and cumulative checkpoint exams
|
||||
- **Stem-change toggle** on Week 4 flashcard decks (E-IE, E-I, O-UE, U-UE) showing inline present-tense conjugations
|
||||
- **Grammar guide** — 20 tense guides with usage rules and examples + 20+ grammar topic notes (ser/estar, por/para, preterite/imperfect, etc.), each with 100+ practice exercises
|
||||
- **Grammar guide** — 20 tense guides with usage rules and examples + 36 grammar topic notes (ser/estar, por/para, preterite/imperfect, reflexives, subjunctive triggers, etc.), each with 100+ practice exercises
|
||||
- **Grammar exercises** — interactive quizzes for 5 core topics (ser/estar, por/para, preterite/imperfect, subjunctive, personal *a*)
|
||||
- **Curated YouTube videos** — 54 hand-picked videos attached to guide and grammar items (preference for The Language Tutor's numbered lessons + BaseLang). Each item offers three actions: **Stream** (opens YouTube app / Safari), **Download** (YouTubeKit extraction + AVFoundation mux for modern adaptive-stream videos), **Play** (full-screen AVPlayer from local MP4). Settings → Downloaded Videos lists all downloads with total size, per-item delete, and a 500 MB warning.
|
||||
|
||||
### AI & speech
|
||||
- **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenario types; chat bubbles have tappable words that open dictionary / on-demand AI lookup
|
||||
@@ -29,7 +32,7 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
|
||||
- **Offline dictionary** — reverse index of 175 K verb forms + 200 common words, cached to disk for instant lookups
|
||||
- **Vocab SRS review** — spaced repetition over course vocabulary with Again / Hard / Good / Easy rating
|
||||
- **Cloze practice** — fill-in-the-blank sentences with distractor generation
|
||||
- **Lyrics practice** — search, translate, and read Spanish song lyrics
|
||||
- **Lyrics practice** — search, translate, and read Spanish song lyrics; long-press any word for an instant definition + tense/person readout (for verbs)
|
||||
|
||||
### Tracking & sync
|
||||
- **Progress** — streaks, daily goals, accuracy stats, achievement badges, study-time tracking per day
|
||||
@@ -39,18 +42,21 @@ A Spanish-learning iOS app that combines verb conjugation practice, a full textb
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** + **SwiftData** with a dual-store configuration:
|
||||
- **Local store** (App Group `group.com.conjuga.app`) — reference data: verbs, forms, irregular spans, tense guides, course decks, vocab cards, textbook chapters. Seeded from bundled JSON on first launch. Self-healing re-seeds trigger on version bumps *or* if rows are missing on disk.
|
||||
- **Local store** (App Group `group.com.conjuga.app`) — reference data + per-device artifacts: verbs, forms, irregular spans, tense guides, course decks, vocab cards, textbook chapters, and downloaded videos. Seeded from bundled JSON on first launch. Self-healing re-seeds trigger on version bumps *or* if rows are missing on disk.
|
||||
- **Cloud store** (CloudKit `iCloud.com.conjuga.app`, private database) — user data: review cards, course reviews, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts.
|
||||
- **SharedModels** Swift Package shared between the app and widget extension. Widget schema must include every local-store entity or SwiftData destructively migrates the shared store.
|
||||
- **Foundation Models** for on-device AI generation (`@Generable` structs for typed output).
|
||||
- **Foundation Models** for on-device AI generation (`@Generable` structs for typed output) — conversation partner, short stories, verb-detail example sentences.
|
||||
- **Vision** framework for OCR of textbook pages and vocabulary images.
|
||||
- **Speech** framework for recognition and pronunciation scoring.
|
||||
- **YouTubeKit** (SPM) for video stream URL extraction, paired with **AVFoundation** (`AVMutableComposition` + `AVAssetExportSession` passthrough) to mux separate DASH video + audio tracks into a single MP4 when progressive streams aren't available.
|
||||
- **Textbook extraction pipeline** (`Conjuga/Scripts/textbook/`) — XHTML and answer-key parsers, macOS Vision image OCR + PDF page OCR, bounding-box vocab pair extractor, NSSpellChecker-based validator, and language-aware auto-fixer.
|
||||
- **Video curation tooling** (`Conjuga/Scripts/generate_videos_markdown.py`) — regenerates `Conjuga/youtube_videos.md` with per-video channel, upload date, duration, view count, and like count (pulled via yt-dlp).
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 18+ (iOS 26 for Foundation Models features)
|
||||
- Xcode 16+
|
||||
- iOS 26+ (Foundation Models, Liquid Glass, modern Swift concurrency)
|
||||
- Xcode 26+
|
||||
- Apple Intelligence-capable device for AI features (conversation partner, AI stories, verb-detail examples); other features degrade gracefully
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
+34
-17
@@ -10,17 +10,30 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
|
||||
**Monetization:** —
|
||||
**Tech stack:** SwiftUI + SwiftData (dual local / CloudKit stores), SharedModels Swift Package, Foundation Models, Vision, Speech, WidgetKit
|
||||
|
||||
### Practice Modes
|
||||
The Practice tab is organised into three sections — **Conjugation**, **Vocabulary**, and **Reading**.
|
||||
|
||||
### Practice — Conjugation
|
||||
|
||||
- **Six core conjugation modes** — flashcards, typing, multiple choice, handwriting (Apple Pencil + finger), sentence builder, full table (all persons at once)
|
||||
- **Focus modes** — weak verbs (SM-2 SRS), irregularity drills (spelling / stem / unique, selectable), common tenses
|
||||
- **Quick answer review** with per-form irregular-span highlighting
|
||||
- **Vocab SRS Review** — spaced repetition over course vocabulary with Again / Hard / Good / Easy rating
|
||||
- **Cloze practice** — fill-in-the-blank with distractor generation from vocab pool
|
||||
- **Listening practice** — listen-and-type + pronunciation scoring via Speech framework, word-by-word match
|
||||
|
||||
### Practice — Vocabulary
|
||||
|
||||
- **Vocab Flashcards** — English → Spanish verb recall over the verb table, filtered by enabled Levels (shared with the Verbs-tab filter). Two-layer SRS: a position-based in-session learning queue (Again/Hard requeue 5–10 cards later, Good moves ~20 ahead, a second Good or Easy graduates) on top of the long-term SM-2 schedule. Due-first session ordering, session size configurable in Settings.
|
||||
- **Quiz mode** — tap-to-reveal + Again/Hard/Good/Easy rating
|
||||
- **Learn mode** — both sides shown at once, Next/Previous browsing on a loop, no rating
|
||||
- **Vocab Multiple Choice** — same pool/SRS; pick the Spanish verb from 4 options, distractors prefer matching part of speech
|
||||
- **Vocab SRS Review** — spaced repetition over *course* vocabulary (distinct from the verb-table flashcards) with Again / Hard / Good / Easy rating
|
||||
|
||||
### Practice — Reading
|
||||
|
||||
- **AI short stories** — generated stories with tappable words + comprehension quiz
|
||||
- **Books** — full-length bilingual EPUB-imported books; chapter reader with tap-to-define, Spanish/English toggle, and read-aloud (TTS with active-word highlighting, tap-to-pause-and-define, voice + speed picker)
|
||||
- **Lyrics practice** — search Spanish songs, translate line by line
|
||||
- **Conversational practice** — on-device AI chat partner (Apple Foundation Models) with 10 scenarios, tappable words that open dictionary or on-demand AI lookup
|
||||
- **AI short stories** — generated stories with tappable words + comprehension quiz
|
||||
- **Listening practice** — listen-and-type + pronunciation scoring via Speech framework, word-by-word match
|
||||
- **Cloze practice** — fill-in-the-blank with distractor generation from vocab pool
|
||||
|
||||
### Verb Reference
|
||||
|
||||
@@ -32,13 +45,15 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
|
||||
|
||||
### Grammar & Content
|
||||
|
||||
- **20 tense guides** with usage rules and examples
|
||||
- **20+ grammar topic notes** (ser/estar, por/para, preterite/imperfect, subjunctive, personal *a*, suffixes, irregular yo forms, stem-changers, etc.) each with 100+ practice exercises
|
||||
- **Grammar exercises** — interactive quizzes for 5 core topics
|
||||
- **20 tense guides** — teacher-handout depth: usage cases, conjugation tables, common irregulars, mnemonics (WEIRDO, ESCAPA, etc.), pitfalls, and contrast with neighbouring tenses
|
||||
- **36 grammar topic notes** (ser/estar, por/para, preterite/imperfect, subjunctive triggers, personal *a*, suffixes, irregular yo forms, stem-changers, etc.) — each with a mnemonic, contrast examples, and a common-pitfalls section
|
||||
- **Guide cross-links** — tense guides and grammar notes link bidirectionally ("Related grammar" / "Used in tenses" chips)
|
||||
- **Grammar exercises** — interactive quizzes for core topics
|
||||
- **Course decks** — weekly vocabulary with example sentences, week tests, cumulative checkpoint exams
|
||||
- **Stem-change toggle** on Week 4 decks (E-IE, E-I, O-UE, U-UE) with inline present-tense conjugations
|
||||
- **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), 931 OCR'd vocab tables rendered as paired Spanish→English grids (~3 100 cards)
|
||||
- **Textbook extraction pipeline** — XHTML + answer-key parsers, macOS Vision image OCR, PDF page OCR, bounding-box vocab pair extractor, NSSpellChecker validator, language-aware auto-fixer
|
||||
- **Extra Study** — star cards during course flashcard review; each week surfaces an "Extra Study" row to drill just the starred cards (iCloud-synced)
|
||||
- **Textbook reader** — 30 chapters of *Complete Spanish Step-by-Step* with 251 interactive exercises (keyboard + Apple Pencil), vocab tables rendered as paired Spanish→English grids
|
||||
- **Textbook extraction pipeline** — XHTML + answer-key parsers, macOS Vision image OCR, PDF page OCR, LLM-vision vocab-pair pass, NSSpellChecker validator, language-aware auto-fixer
|
||||
- **Books pipeline** (`Scripts/books/`) — EPUB → chapter JSON extractor, Claude-subagent translation pass, bundler
|
||||
|
||||
### Offline Dictionary
|
||||
|
||||
@@ -47,10 +62,11 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
|
||||
|
||||
### Progress & Sync
|
||||
|
||||
- **SM-2 spaced repetition** for verb review
|
||||
- **SM-2 spaced repetition** for conjugation review, course vocab, and verb-table vocab (separate `VerbReviewCard` schedule)
|
||||
- **In-session learning queue** for vocab practice — position-based requeue layered on top of the SM-2 schedule
|
||||
- **Streaks, daily goals, accuracy stats, achievement badges**
|
||||
- **Study-time tracking** per day (foreground time)
|
||||
- **CloudKit private-database sync** — review cards, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts
|
||||
- **CloudKit private-database sync** — review cards, verb review cards, user progress, test results, daily logs, saved songs, stories, conversations, textbook exercise attempts, extra-study marks
|
||||
- **Background app refresh** for widget data
|
||||
|
||||
### Widgets
|
||||
@@ -61,9 +77,10 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
|
||||
|
||||
### Settings & Filters
|
||||
|
||||
- **Selectable verb level** and **enabled tenses**
|
||||
- **Selectable verb level** (shared between Settings and the Verbs-tab filter) and **enabled tenses**
|
||||
- **Include vosotros** toggle
|
||||
- **Auto-fill verb stem** toggle for Full Table practice
|
||||
- **Cards per session** — vocab flashcard / multiple-choice session size (10–50 or All)
|
||||
- **Feature reference** page in Settings documenting every feature and which settings affect it
|
||||
|
||||
### Data (in repo)
|
||||
@@ -74,12 +91,12 @@ Side-by-side feature analysis of **Conjuga** (this project), **ConjuGato**, and
|
||||
| Verb forms (pre-conjugated) | 209,014 |
|
||||
| Irregular span annotations | 23,795 |
|
||||
| Tenses | 20 |
|
||||
| Tense guides | 20 |
|
||||
| Grammar notes | 20+ (each with 100+ exercises) |
|
||||
| Tense guides | 20 (enriched to teacher-handout depth) |
|
||||
| Grammar notes | 36 |
|
||||
| Textbook chapters | 30 |
|
||||
| Textbook exercises | 251 |
|
||||
| Textbook vocab pairs | ~3,118 |
|
||||
| Offline dictionary forms | 175,425 |
|
||||
| Bundled books | 1 (Olly Richards — *Spanish Short Stories Vol 2*, 13 chapters) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user