Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32395bac5d | |||
| b97da5e85e | |||
| aab64116b3 | |||
| 179400b90d | |||
| 696eafa64f | |||
| 7da98d786c |
@@ -45,6 +45,9 @@ scrape/
|
||||
*.pdf
|
||||
*.epub
|
||||
epub_extract/
|
||||
# Exception: weekly course-material PDFs are bundled into the app and must
|
||||
# travel with the repo so fresh clones build with the feature working.
|
||||
!Conjuga/Conjuga/CourseMaterials/*.pdf
|
||||
|
||||
# Textbook extraction artifacts — regenerate locally via run_pipeline.sh.
|
||||
# Scripts are committed; their generated outputs are not.
|
||||
|
||||
@@ -10,75 +10,89 @@
|
||||
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 */; };
|
||||
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */; };
|
||||
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */ = {isa = PBXBuildFile; fileRef = 7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */; };
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC3247457109FC6BF00D85B /* TenseInfo.swift */; };
|
||||
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 */; };
|
||||
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC782D61A0763072E4964B6 /* VerbReviewStore.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 */; };
|
||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313D24F96E6A0039C34341F /* DailyLog.swift */; };
|
||||
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */; };
|
||||
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */; };
|
||||
218E982FC4267949F82AABAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = 4A4D7B02884EBA9ACD93F0FD /* SharedModels */; };
|
||||
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */; };
|
||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */; };
|
||||
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 */; };
|
||||
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */; };
|
||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
||||
2CB1E7454C1C04C2A9A06D57 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */; };
|
||||
33BFEC0F0DEFC8A0E1FD8009 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A661ADF1141176EE96774138 /* BookSpeechController.swift */; };
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
||||
345AB6723C15590031B75A01 /* Beginner_I_W2.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */; };
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||
3535A6B73D03486EB2E43823 /* Beginner_I_W1.pdf in Resources */ = {isa = PBXBuildFile; fileRef = CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */; };
|
||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */; };
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.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 */; };
|
||||
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */; };
|
||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731614CACCB73B6FD592D34A /* SentenceBuilderView.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 */; };
|
||||
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */; };
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */; };
|
||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.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 */; };
|
||||
5224FD701320B7DBCEFDD95B /* Beginner_I_W4.pdf in Resources */ = {isa = PBXBuildFile; fileRef = B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */; };
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5D8A5E1ED1BE4F86A9907FA7 /* VerbReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2ACC4C35491174257770941 /* VerbReviewStore.swift */; };
|
||||
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */; };
|
||||
5C1C0011594A2C06BCD777A4 /* Beginner_I_W7.pdf in Resources */ = {isa = PBXBuildFile; fileRef = E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */; };
|
||||
5CBAD967B3545EA7560761C6 /* Beginner_I_W3.pdf in Resources */ = {isa = PBXBuildFile; fileRef = EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */; };
|
||||
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.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 */; };
|
||||
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 */; };
|
||||
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA4750E84A7FA51532407CF /* BookLibraryView.swift */; };
|
||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||
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 */; };
|
||||
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4365AC54DB1DA5D7017CB42 /* NounReviewView.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 */; };
|
||||
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 */; };
|
||||
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */; };
|
||||
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */; };
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = 03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */; };
|
||||
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20423155763A77A050727EC /* BookReaderView.swift */; };
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */; };
|
||||
8BD4B5A2DDDD4BE4B4A94962 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79576893566932D2BE207528 /* ChatView.swift */; };
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.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 */; };
|
||||
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.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 */; };
|
||||
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */; };
|
||||
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 */; };
|
||||
@@ -89,7 +103,7 @@
|
||||
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 */; };
|
||||
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.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 */; };
|
||||
@@ -98,29 +112,35 @@
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
|
||||
C0DF369A6E30F01514A78CA1 /* Beginner_I_W5.pdf in Resources */ = {isa = PBXBuildFile; fileRef = EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */; };
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5983A534E4836F30B5281ACB /* MainTabView.swift */; };
|
||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */; };
|
||||
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */; };
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */; };
|
||||
C3F567971CE8379CCC0AA9ED /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */; };
|
||||
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */; };
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADCA82DDD34DF36D59BB283 /* DataLoader.swift */; };
|
||||
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 */; };
|
||||
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */; };
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||
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 */; };
|
||||
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */; };
|
||||
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */ = {isa = PBXBuildFile; fileRef = EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */; };
|
||||
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C168499F60BC7AFE5100C572 /* BookChapterListView.swift */; };
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||
EB7CF33BA416BD7B5D995FF4 /* Beginner_I_W8.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */; };
|
||||
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 */; };
|
||||
F22FD38D5CD6A89CC5940B0E /* CourseMaterialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */; };
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */ = {isa = PBXBuildFile; fileRef = 6658C35E454C137B53FC05A4 /* youtube_videos.json */; };
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A014EEC3EE08E945FBBA5335 /* Assets.xcassets */; };
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */; };
|
||||
@@ -155,12 +175,14 @@
|
||||
/* Begin PBXFileReference section */
|
||||
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>"; };
|
||||
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
|
||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveReviewView.swift; sourceTree = "<group>"; };
|
||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewCard.swift; sourceTree = "<group>"; };
|
||||
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
|
||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
|
||||
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; };
|
||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
||||
@@ -171,16 +193,14 @@
|
||||
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>"; };
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||
221920B9BD6DC6F084093975 /* ExtraStudyStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
|
||||
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
||||
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -200,7 +220,9 @@
|
||||
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>"; };
|
||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePickerSheet.swift; sourceTree = "<group>"; };
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadService.swift; sourceTree = "<group>"; };
|
||||
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjectiveFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_data.json; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -212,7 +234,6 @@
|
||||
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>"; };
|
||||
@@ -223,58 +244,77 @@
|
||||
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>"; };
|
||||
79576893566932D2BE207528 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = vocab_lexemes.json; sourceTree = "<group>"; };
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W8.pdf; sourceTree = "<group>"; };
|
||||
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabSessionQueue.swift; sourceTree = "<group>"; };
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabMultipleChoicePracticeView.swift; sourceTree = "<group>"; };
|
||||
8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W6.pdf; 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>"; };
|
||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReflexiveVerbStore.swift; sourceTree = "<group>"; };
|
||||
9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W2.pdf; sourceTree = "<group>"; };
|
||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
A2ACC4C35491174257770941 /* VerbReviewStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
||||
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyStore.swift; sourceTree = "<group>"; };
|
||||
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||
A661ADF1141176EE96774138 /* BookSpeechController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
|
||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = youtube_videos.md; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeStudyGroup.swift; sourceTree = "<group>"; };
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoStore.swift; sourceTree = "<group>"; };
|
||||
B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W4.pdf; 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>"; };
|
||||
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewCard.swift; sourceTree = "<group>"; };
|
||||
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterListView.swift; sourceTree = "<group>"; };
|
||||
C20423155763A77A050727EC /* BookReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
|
||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSpeechController.swift; sourceTree = "<group>"; };
|
||||
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryView.swift; sourceTree = "<group>"; };
|
||||
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardPracticeView.swift; sourceTree = "<group>"; };
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbReviewStore.swift; sourceTree = "<group>"; };
|
||||
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W1.pdf; sourceTree = "<group>"; };
|
||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeReviewStore.swift; sourceTree = "<group>"; };
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseMaterialView.swift; sourceTree = "<group>"; };
|
||||
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NounReviewView.swift; sourceTree = "<group>"; };
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
||||
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideCrossLinks.swift; sourceTree = "<group>"; };
|
||||
E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W7.pdf; 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>"; };
|
||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "book_olly-vol2.json"; sourceTree = "<group>"; };
|
||||
EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W5.pdf; sourceTree = "<group>"; };
|
||||
EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = Beginner_I_W3.pdf; sourceTree = "<group>"; };
|
||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbExampleCache.swift; sourceTree = "<group>"; };
|
||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookReaderView.swift; sourceTree = "<group>"; };
|
||||
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraStudyView.swift; sourceTree = "<group>"; };
|
||||
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LexemeSessionQueue.swift; sourceTree = "<group>"; };
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabStudyGroup.swift; sourceTree = "<group>"; };
|
||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StemChangeConjugationView.swift; sourceTree = "<group>"; };
|
||||
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 */
|
||||
@@ -302,6 +342,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
|
||||
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
|
||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||
@@ -310,13 +351,15 @@
|
||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */,
|
||||
539736EB2AB8D149ED0F9C39 /* textbook_data.json */,
|
||||
3540936F058728CFD87B1A1E /* textbook_vocab.json */,
|
||||
7C60B9668AC8A5B3424E9F9C /* vocab_lexemes.json */,
|
||||
6658C35E454C137B53FC05A4 /* youtube_videos.json */,
|
||||
A6EC7C278E4287D91A0DB355 /* youtube_videos.md */,
|
||||
2610354CB0D62BD8A19BEC20 /* CourseMaterials */,
|
||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||
23B49FBE9B44D8734D96625F /* Scripts */,
|
||||
1994867BC8E985795A172854 /* Services */,
|
||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||
A81CA75762B08D35D5B7A44D /* Views */,
|
||||
EBC046A3733791C29DAA6AC3 /* book_olly-vol2.json */,
|
||||
);
|
||||
path = Conjuga;
|
||||
sourceTree = "<group>";
|
||||
@@ -345,11 +388,15 @@
|
||||
children = (
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||
B3EFFA19D0AB2528A868E8ED /* AnswerChecker.swift */,
|
||||
C57C20F4AF2CC20C80367124 /* BookSpeechController.swift */,
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */,
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */,
|
||||
A2E72744C1DAF2AC07FA614F /* ExtraStudyStore.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
D4B8AB6EC4F3EB86C732243D /* LexemeReviewStore.swift */,
|
||||
EE79D3EC48233CAEC2E39F81 /* LexemeSessionQueue.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||
@@ -365,25 +412,36 @@
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.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 */ = {
|
||||
23B49FBE9B44D8734D96625F /* Scripts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6A19F8006EC8E6C3ACDEE3C6 /* VocabFlashcardPracticeView.swift */,
|
||||
15405FC4775B3DE5B76A0061 /* VocabMultipleChoicePracticeView.swift */,
|
||||
6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
|
||||
);
|
||||
name = Vocab;
|
||||
path = Vocab;
|
||||
path = Scripts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2610354CB0D62BD8A19BEC20 /* CourseMaterials */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CF62E9D108E2E564BA61A694 /* Beginner_I_W1.pdf */,
|
||||
9518665E390E8BE4D7D957FE /* Beginner_I_W2.pdf */,
|
||||
EBDEA606F038A4A3DF1967E3 /* Beginner_I_W3.pdf */,
|
||||
B07767E9BC347964B98C0F3D /* Beginner_I_W4.pdf */,
|
||||
EBBA697AFA8988BBB15C4717 /* Beginner_I_W5.pdf */,
|
||||
8B54119366CF052443A8C080 /* Beginner_I_W6.pdf */,
|
||||
E8CEEA98D1805C5BA20A4559 /* Beginner_I_W7.pdf */,
|
||||
83AA457D61B9D2C86C301236 /* Beginner_I_W8.pdf */,
|
||||
);
|
||||
path = CourseMaterials;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
29F9EEAED5A6969FEDEAE227 /* Onboarding */ = {
|
||||
@@ -400,14 +458,16 @@
|
||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||
F0A3099BE24A56F9B1F179E0 /* GrammarExercise.swift */,
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||
E6BA2ABC841F9C987AB15F67 /* GuideCrossLinks.swift */,
|
||||
C03CC5EAD0DDE98C43FF91BA /* LexemeReviewCard.swift */,
|
||||
AEC6005F9D3D462B33803E7A /* LexemeStudyGroup.swift */,
|
||||
626873572466403C0288090D /* QuizType.swift */,
|
||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
A3FCE2E42939FB4CF491B9F1 /* GuideCrossLinks.swift */,
|
||||
DFDD93A97B111843D80C0EB9 /* VocabStudyGroup.swift */,
|
||||
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -462,6 +522,7 @@
|
||||
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
|
||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||
@@ -469,30 +530,48 @@
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||
9CD612E55440D22B877EA8FE /* Books */,
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
43E4D263B0AF47E401A51601 /* Stories */,
|
||||
74AC8A0D381958D2A14316C3 /* Books */,
|
||||
1ECAF79E2138DF73BB1F6403 /* Vocab */,
|
||||
730BD7F59F4C97D87EF98FB1 /* Vocab */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
74AC8A0D381958D2A14316C3 /* Books */ = {
|
||||
6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
340B1F22929DC7C1DEB0EA8A /* BookLibraryView.swift */,
|
||||
FF3475931F1AD16054741E65 /* BookChapterListView.swift */,
|
||||
EDD4AF96186662567525F8C4 /* BookReaderView.swift */,
|
||||
1C5F851F5C2C71C293DD9938 /* BookVoicePickerSheet.swift */,
|
||||
7DE0F6354CF73BDA0CE728BA /* in */,
|
||||
C36A0F3B1A4B759412ADB4E5 /* out */,
|
||||
);
|
||||
name = Books;
|
||||
path = Books;
|
||||
path = "guide-enrichment";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
730BD7F59F4C97D87EF98FB1 /* Vocab */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
53893D3A637F0F99CAAB8F31 /* AdjectiveFlashcardPracticeView.swift */,
|
||||
256A39C9B72D1042408C157A /* AdjectiveMultipleChoicePracticeView.swift */,
|
||||
A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */,
|
||||
0F1F30E0A920FD7AA4F952CE /* NounMultipleChoicePracticeView.swift */,
|
||||
CB6453FB9DCFAEC1C4E42D83 /* VocabFlashcardPracticeView.swift */,
|
||||
878D04B21589BAB7CF8EA0AF /* VocabMultipleChoicePracticeView.swift */,
|
||||
);
|
||||
path = Vocab;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7DE0F6354CF73BDA0CE728BA /* in */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = in;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8102F7FA5BFE6D38B2212AD3 /* Guide */ = {
|
||||
@@ -526,6 +605,17 @@
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9CD612E55440D22B877EA8FE /* Books */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C168499F60BC7AFE5100C572 /* BookChapterListView.swift */,
|
||||
CAA4750E84A7FA51532407CF /* BookLibraryView.swift */,
|
||||
C20423155763A77A050727EC /* BookReaderView.swift */,
|
||||
50663C791D9673958B4D6011 /* BookVoicePickerSheet.swift */,
|
||||
);
|
||||
path = Books;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A591A3B6F1F13D23D68D7A9D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -565,20 +655,28 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */,
|
||||
E42513164C858C8CBE1A70AF /* CourseMaterialView.swift */,
|
||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */,
|
||||
833516C5D57F164C8660A479 /* CourseView.swift */,
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||
EE00D37419072ED6AC1AC63A /* ExtraStudyView.swift */,
|
||||
F92BCE1A6720E47FCD26BADC /* StemChangeConjugationView.swift */,
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */,
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */,
|
||||
854EA2A8D6CF203958BA3C24 /* TextbookExerciseView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
8C6D48A27B47609BFE04C80C /* ExtraStudyView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C36A0F3B1A4B759412ADB4E5 /* out */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = out;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -690,14 +788,23 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F59655A8B8FCE6264315DD33 /* Assets.xcassets in Resources */,
|
||||
3535A6B73D03486EB2E43823 /* Beginner_I_W1.pdf in Resources */,
|
||||
345AB6723C15590031B75A01 /* Beginner_I_W2.pdf in Resources */,
|
||||
5CBAD967B3545EA7560761C6 /* Beginner_I_W3.pdf in Resources */,
|
||||
5224FD701320B7DBCEFDD95B /* Beginner_I_W4.pdf in Resources */,
|
||||
C0DF369A6E30F01514A78CA1 /* Beginner_I_W5.pdf in Resources */,
|
||||
995C466AE3C95A95DB9457A1 /* Beginner_I_W6.pdf in Resources */,
|
||||
5C1C0011594A2C06BCD777A4 /* Beginner_I_W7.pdf in Resources */,
|
||||
EB7CF33BA416BD7B5D995FF4 /* Beginner_I_W8.pdf in Resources */,
|
||||
877B0306A15A0AD680B361F8 /* book_olly-vol2.json in Resources */,
|
||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */,
|
||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */,
|
||||
97A2088134FC6CB41C507182 /* reflexive_verbs.json in Resources */,
|
||||
F15E2E02DD6261323BB75789 /* textbook_data.json in Resources */,
|
||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */,
|
||||
0A29763DF0ED8F24730BCE5A /* vocab_lexemes.json in Resources */,
|
||||
F26F3BF58CF557D5A65EE901 /* youtube_videos.json in Resources */,
|
||||
983988CE911C0FC5D869C516 /* youtube_videos.md in Resources */,
|
||||
E3D9D82E54E37F9D38103FB9 /* book_olly-vol2.json in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -710,14 +817,23 @@
|
||||
files = (
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */,
|
||||
261E582449BED6EF41881B04 /* AdaptiveContainer.swift in Sources */,
|
||||
067996B3D0D768ADBED1E52F /* AdjectiveFlashcardPracticeView.swift in Sources */,
|
||||
44C6084491E3056A4C3A890B /* AdjectiveMultipleChoicePracticeView.swift in Sources */,
|
||||
2088B73D83D4D6A6CABEFBCE /* AdjectiveReviewView.swift in Sources */,
|
||||
48967E05C65E32F7082716CD /* AnswerChecker.swift in Sources */,
|
||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||
E7B7799BF0C2C5B77FB52987 /* BookChapterListView.swift in Sources */,
|
||||
6C0C71E0D56C199C375609AC /* BookLibraryView.swift in Sources */,
|
||||
8B393A61B32D7D3488618ADE /* BookReaderView.swift in Sources */,
|
||||
C7952958B55BAD218CB3ABC5 /* BookSpeechController.swift in Sources */,
|
||||
848BADEF0FC04EDB4644E923 /* BookVoicePickerSheet.swift in Sources */,
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */,
|
||||
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 */,
|
||||
F22FD38D5CD6A89CC5940B0E /* CourseMaterialView.swift in Sources */,
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||
@@ -728,6 +844,8 @@
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||
381D007E8E35E5D91CD549A3 /* ExtraStudyStore.swift in Sources */,
|
||||
DCA3805DCC1D03BEEDED73A8 /* ExtraStudyView.swift in Sources */,
|
||||
14242FD1F500D296D41E927C /* FeatureReferenceView.swift in Sources */,
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */,
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */,
|
||||
@@ -735,11 +853,16 @@
|
||||
8DC1CB93333F94C5297D33BF /* GrammarExerciseView.swift in Sources */,
|
||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */,
|
||||
F84706B47A2156B2138FB8D5 /* GrammarNotesView.swift in Sources */,
|
||||
D3328F0C9CF6A2CB154A85B3 /* GuideCrossLinks.swift in Sources */,
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */,
|
||||
3F4F0C07BE61512CBFBBB203 /* HandwritingCanvas.swift in Sources */,
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
AEFD25146A64DF9A07FE32B4 /* LexemeReviewCard.swift in Sources */,
|
||||
55FBABFE026058CFE8B40CB6 /* LexemeReviewStore.swift in Sources */,
|
||||
C384F12E910EE909AA97ECD9 /* LexemeSessionQueue.swift in Sources */,
|
||||
25A02F8B96285407EFD30817 /* LexemeStudyGroup.swift in Sources */,
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */,
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||
@@ -748,6 +871,9 @@
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */,
|
||||
2B76DE203AEEBAE4536BD4F0 /* NounMultipleChoicePracticeView.swift in Sources */,
|
||||
7321D046BB60D40B1D27121E /* NounReviewView.swift in Sources */,
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||
@@ -787,26 +913,18 @@
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
0E49DCF141F81D1C3A94C459 /* VerbReviewStore.swift in Sources */,
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */,
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */,
|
||||
5EA89448E97D76CFC97DC4E2 /* VocabFlashcardPracticeView.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||
5BB8A0C1A8A6374B04F50781 /* VocabMultipleChoicePracticeView.swift in Sources */,
|
||||
78FE99C5D511737B6877EDD5 /* VocabReviewView.swift in Sources */,
|
||||
962B9B4FF15EADF2A30C5743 /* VocabSessionQueue.swift in Sources */,
|
||||
84795E8F0111A3045285D579 /* VocabStudyGroup.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.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;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,7 @@ struct ConjugaApp: App {
|
||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
@@ -80,6 +81,7 @@ struct ConjugaApp: App {
|
||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
@@ -253,7 +255,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 = 5 // bump: Book/BookChapter added to local container
|
||||
let resetVersion = 6 // bump: Lexeme added to local container
|
||||
let key = "localStoreResetVersion"
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
/// SRS record for non-verb vocab cards (nouns, adjectives, …). Keyed by
|
||||
/// `(partOfSpeech, lexemeId, drillMode)` so a noun's gender drill and its
|
||||
/// English-recall drill progress independently. Lives in the cloud container
|
||||
/// alongside `VerbReviewCard` so vocab progress syncs across devices.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||
/// `LexemeReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||
@Model
|
||||
final class LexemeReviewCard {
|
||||
var id: String = ""
|
||||
var lexemeId: String = ""
|
||||
var partOfSpeech: String = ""
|
||||
var drillMode: String = ""
|
||||
|
||||
var easeFactor: Double = 2.5
|
||||
var interval: Int = 0
|
||||
var repetitions: Int = 0
|
||||
var dueDate: Date = Date()
|
||||
var lastReviewDate: Date?
|
||||
|
||||
init(lexemeId: String, partOfSpeech: String, drillMode: String) {
|
||||
self.id = Self.makeId(
|
||||
lexemeId: lexemeId,
|
||||
partOfSpeech: partOfSpeech,
|
||||
drillMode: drillMode
|
||||
)
|
||||
self.lexemeId = lexemeId
|
||||
self.partOfSpeech = partOfSpeech
|
||||
self.drillMode = drillMode
|
||||
}
|
||||
|
||||
static func makeId(lexemeId: String, partOfSpeech: String, drillMode: String) -> String {
|
||||
"\(partOfSpeech)|\(lexemeId)|\(drillMode)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Per-(POS, drillMode) active study group, mirroring `VocabStudyGroup`.
|
||||
/// Keying by drill mode means a noun gender drill and an adjective agreement
|
||||
/// drill can each have their own resumable session at the same time.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create.
|
||||
@Model
|
||||
final class LexemeStudyGroup {
|
||||
var id: String = ""
|
||||
var partOfSpeech: String = ""
|
||||
var drillMode: String = ""
|
||||
/// JSON-encoded `[StoredLexemeEntry]` — the in-session queue in order.
|
||||
var entriesJSON: Data = Data()
|
||||
var learnedCount: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
entriesJSON: Data,
|
||||
learnedCount: Int
|
||||
) {
|
||||
self.id = Self.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
|
||||
self.partOfSpeech = partOfSpeech
|
||||
self.drillMode = drillMode
|
||||
self.entriesJSON = entriesJSON
|
||||
self.learnedCount = learnedCount
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
static func activeID(partOfSpeech: String, drillMode: String) -> String {
|
||||
"active-\(partOfSpeech)-\(drillMode)"
|
||||
}
|
||||
|
||||
var entries: [StoredLexemeEntry] {
|
||||
(try? JSONDecoder().decode([StoredLexemeEntry].self, from: entriesJSON)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// One lexeme's spot in the persisted study group.
|
||||
struct StoredLexemeEntry: Codable {
|
||||
var lexemeId: String
|
||||
/// Raw value of `LexemeSessionQueue.CardState`.
|
||||
var state: String
|
||||
}
|
||||
|
||||
/// Fetch / persist / clear the active group for one `(POS, drillMode)` pair.
|
||||
struct LexemeStudyGroupStore {
|
||||
let context: ModelContext
|
||||
let partOfSpeech: String
|
||||
let drillMode: String
|
||||
|
||||
private var activeID: String {
|
||||
LexemeStudyGroup.activeID(partOfSpeech: partOfSpeech, drillMode: drillMode)
|
||||
}
|
||||
|
||||
func activeGroup() -> LexemeStudyGroup? {
|
||||
let id = activeID
|
||||
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
func persist(entries: [StoredLexemeEntry], learnedCount: Int) {
|
||||
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
||||
let id = activeID
|
||||
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||
predicate: #Predicate<LexemeStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\LexemeStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
let existing = (try? context.fetch(descriptor)) ?? []
|
||||
if let newest = existing.first {
|
||||
newest.entriesJSON = data
|
||||
newest.learnedCount = learnedCount
|
||||
for duplicate in existing.dropFirst() { context.delete(duplicate) }
|
||||
} else {
|
||||
context.insert(LexemeStudyGroup(
|
||||
partOfSpeech: partOfSpeech,
|
||||
drillMode: drillMode,
|
||||
entriesJSON: data,
|
||||
learnedCount: learnedCount
|
||||
))
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
let id = activeID
|
||||
let descriptor = FetchDescriptor<LexemeStudyGroup>(
|
||||
predicate: #Predicate<LexemeStudyGroup> { $0.id == id }
|
||||
)
|
||||
for group in (try? context.fetch(descriptor)) ?? [] {
|
||||
context.delete(group)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ final class UserProgress {
|
||||
var selectedLevelsBlob: String = ""
|
||||
var enabledIrregularCategoriesBlob: String = ""
|
||||
|
||||
// Multi-select CEFR levels for the noun/adjective vocab catalog —
|
||||
// separate from the verb levels above so the two are independent.
|
||||
var selectedLexemeLevelsBlob: String = ""
|
||||
|
||||
init() {}
|
||||
|
||||
var selectedVerbLevel: VerbLevel {
|
||||
@@ -107,6 +111,35 @@ final class UserProgress {
|
||||
selectedVerbLevels = values
|
||||
}
|
||||
|
||||
/// CEFR-style levels currently enabled for noun + adjective flashcards.
|
||||
/// First-ever read (blob empty) defaults to A1+A2 — a beginner-friendly
|
||||
/// starting point. Once the user touches any toggle, the blob is no
|
||||
/// longer empty and exactly reflects their selection (including the
|
||||
/// "all off" state, which shows the empty-state message).
|
||||
var selectedLexemeLevels: Set<LexemeLevel> {
|
||||
get {
|
||||
if selectedLexemeLevelsBlob.isEmpty {
|
||||
return [.a1, .a2]
|
||||
}
|
||||
let raw = decodeStringArray(from: selectedLexemeLevelsBlob, fallback: [])
|
||||
return Set(raw.compactMap(LexemeLevel.init(rawValue:)))
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
selectedLexemeLevelsBlob = Self.encodeStringArray(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
func setLexemeLevelEnabled(_ level: LexemeLevel, enabled: Bool) {
|
||||
var values = selectedLexemeLevels
|
||||
if enabled {
|
||||
values.insert(level)
|
||||
} else {
|
||||
values.remove(level)
|
||||
}
|
||||
selectedLexemeLevels = values
|
||||
}
|
||||
|
||||
func setIrregularCategoryEnabled(_ category: IrregularSpan.SpanCategory, enabled: Bool) {
|
||||
var values = enabledIrregularCategories
|
||||
if enabled {
|
||||
|
||||
@@ -43,6 +43,12 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
let paragraphIndex: Int
|
||||
let text: String
|
||||
let wordRanges: [Range<String.Index>]
|
||||
/// Words skipped at the front of this paragraph when the caller asked to
|
||||
/// start mid-paragraph. The utterance is built from a substring, so the
|
||||
/// synth's word indices are local to that substring; add this offset to
|
||||
/// report a word index in the full paragraph's coordinate space (which
|
||||
/// is what the view highlights against). 0 for whole-paragraph entries.
|
||||
let wordIndexOffset: Int
|
||||
}
|
||||
|
||||
override init() {
|
||||
@@ -55,17 +61,40 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
/// `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) {
|
||||
func start(paragraphs: [String], fromParagraph startIndex: Int = 0, word startWordIndex: Int? = nil) {
|
||||
stop()
|
||||
configureAudioSession()
|
||||
|
||||
var entries: [QueueEntry] = []
|
||||
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
|
||||
if Self.isVocabLine(p) { continue }
|
||||
|
||||
// Apply the word offset only to the first paragraph actually read,
|
||||
// and only when it's the paragraph the caller pointed at. A skipped
|
||||
// vocab line at startIndex, word 0, or an out-of-range index all
|
||||
// fall through to reading the whole paragraph.
|
||||
if entries.isEmpty,
|
||||
idx == startIndex,
|
||||
let startWord = startWordIndex,
|
||||
startWord > 0 {
|
||||
let fullRanges = Self.wordRanges(in: p)
|
||||
if startWord < fullRanges.count {
|
||||
let substring = String(p[fullRanges[startWord].lowerBound...])
|
||||
entries.append(QueueEntry(
|
||||
paragraphIndex: idx,
|
||||
text: substring,
|
||||
wordRanges: Self.wordRanges(in: substring),
|
||||
wordIndexOffset: startWord
|
||||
))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
entries.append(QueueEntry(
|
||||
paragraphIndex: idx,
|
||||
text: p,
|
||||
wordRanges: Self.wordRanges(in: p)
|
||||
wordRanges: Self.wordRanges(in: p),
|
||||
wordIndexOffset: 0
|
||||
))
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
@@ -189,8 +218,11 @@ final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
let idx = entry.wordRanges.firstIndex {
|
||||
$0.lowerBound <= lower && lower < $0.upperBound
|
||||
}
|
||||
if let idx, idx != currentWordIndex {
|
||||
currentWordIndex = idx
|
||||
if let idx {
|
||||
let reported = entry.wordIndexOffset + idx
|
||||
if reported != currentWordIndex {
|
||||
currentWordIndex = reported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ actor DataLoader {
|
||||
static let textbookDataVersion = 14
|
||||
static let textbookDataKey = "textbookDataVersion"
|
||||
|
||||
static let bookDataVersion = 6 // bump: BookChapter.paragraphCount added
|
||||
static let bookDataVersion = 7 // Lexeme table + WordGloss.gender added
|
||||
static let bookDataKey = "bookDataVersion"
|
||||
|
||||
static let lexemeDataVersion = 1 // initial — seeded from vocab_lexemes.json
|
||||
static let lexemeDataKey = "lexemeDataVersion"
|
||||
|
||||
/// Quick check: does the DB need seeding or course data refresh?
|
||||
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||
let context = ModelContext(container)
|
||||
@@ -602,7 +605,8 @@ actor DataLoader {
|
||||
glossary[word] = WordGloss(
|
||||
baseForm: fields["baseForm"] ?? word,
|
||||
english: fields["english"] ?? "",
|
||||
partOfSpeech: fields["partOfSpeech"] ?? ""
|
||||
partOfSpeech: fields["partOfSpeech"] ?? "",
|
||||
gender: fields["gender"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -657,6 +661,100 @@ actor DataLoader {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Lexeme catalog (Phase 3 of vocab study)
|
||||
|
||||
/// Re-seed the `Lexeme` catalog if the version has changed or the rows
|
||||
/// are missing. The catalog is sourced from the bundled
|
||||
/// `vocab_lexemes.json` (built by `Scripts/vocab/build_lexemes.py` from
|
||||
/// doozan/spanish_data) — independent from book seeding so a catalog
|
||||
/// refresh doesn't require touching books.
|
||||
static func refreshLexemesIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
|
||||
let storedVersion = shared.integer(forKey: lexemeDataKey)
|
||||
let versionCurrent = storedVersion >= lexemeDataVersion
|
||||
|
||||
print("[DataLoader] refreshLexemesIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(lexemeDataVersion) versionCurrent=\(versionCurrent)")
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if let existing = try? context.fetch(FetchDescriptor<Lexeme>()) {
|
||||
for lexeme in existing { context.delete(lexeme) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: lexeme wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedLexemesFromCatalog(context: context) {
|
||||
shared.set(lexemeDataVersion, forKey: lexemeDataKey)
|
||||
print("[DataLoader] Lexeme data re-seeded to version \(lexemeDataVersion)")
|
||||
} else {
|
||||
print("[DataLoader] Lexeme reseed produced no rows — leaving version key untouched")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `vocab_lexemes.json` from the app bundle and insert one `Lexeme`
|
||||
/// per entry. Returns true when at least one row persisted.
|
||||
private static func seedLexemesFromCatalog(context: ModelContext) -> Bool {
|
||||
guard let url = Bundle.main.url(forResource: "vocab_lexemes", withExtension: "json") else {
|
||||
print("[DataLoader] no vocab_lexemes.json bundled — skipping lexeme seed")
|
||||
return false
|
||||
}
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||
print("[DataLoader] ERROR: vocab_lexemes.json malformed")
|
||||
return false
|
||||
}
|
||||
|
||||
var inserted = 0
|
||||
// Defensive: the build script already dedupes, but skip any stray
|
||||
// dupes so we never throw on the unique-constraint save.
|
||||
var seen: Set<String> = []
|
||||
for entry in array {
|
||||
guard let baseForm = entry["baseForm"] as? String, !baseForm.isEmpty,
|
||||
let english = entry["english"] as? String, !english.isEmpty,
|
||||
let pos = entry["partOfSpeech"] as? String, !pos.isEmpty else {
|
||||
continue
|
||||
}
|
||||
let dedupKey = "\(pos):\(baseForm)"
|
||||
if seen.contains(dedupKey) { continue }
|
||||
seen.insert(dedupKey)
|
||||
|
||||
let lexeme = Lexeme(
|
||||
id: Lexeme.makeID(sourceBookSlug: "catalog", partOfSpeech: pos, baseForm: baseForm),
|
||||
partOfSpeech: pos,
|
||||
baseForm: baseForm,
|
||||
english: english,
|
||||
gender: entry["gender"] as? String,
|
||||
sourceBookSlug: "catalog",
|
||||
frequencyRank: (entry["frequencyRank"] as? Int) ?? 0,
|
||||
exampleES: entry["exampleES"] as? String,
|
||||
exampleEN: entry["exampleEN"] as? String
|
||||
)
|
||||
context.insert(lexeme)
|
||||
inserted += 1
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: lexeme save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let persisted = (try? context.fetchCount(FetchDescriptor<Lexeme>())) ?? 0
|
||||
guard persisted > 0 else {
|
||||
print("[DataLoader] ERROR: seeded \(inserted) lexemes but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
print("Lexeme seeding complete: \(persisted) lexemes from catalog")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Slugs of books bundled with the app. Kept explicit so device installs
|
||||
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
|
||||
/// successfully enumerating the bundle — that API has been observed to
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// SRS rating for non-verb vocab cards. Mirrors `VerbReviewStore` but keyed
|
||||
/// by `(partOfSpeech, lexemeId, drillMode)` so independent drills against the
|
||||
/// same lexeme don't fight over one schedule.
|
||||
struct LexemeReviewStore {
|
||||
let context: ModelContext
|
||||
|
||||
@discardableResult
|
||||
func fetchOrCreateReviewCard(
|
||||
lexemeId: String,
|
||||
partOfSpeech: String,
|
||||
drillMode: String
|
||||
) -> LexemeReviewCard {
|
||||
let id = LexemeReviewCard.makeId(
|
||||
lexemeId: lexemeId,
|
||||
partOfSpeech: partOfSpeech,
|
||||
drillMode: drillMode
|
||||
)
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> { $0.id == id }
|
||||
)
|
||||
if let existing = (try? context.fetch(descriptor))?.first {
|
||||
return existing
|
||||
}
|
||||
let card = LexemeReviewCard(
|
||||
lexemeId: lexemeId,
|
||||
partOfSpeech: partOfSpeech,
|
||||
drillMode: drillMode
|
||||
)
|
||||
context.insert(card)
|
||||
return card
|
||||
}
|
||||
|
||||
func rate(
|
||||
lexemeId: String,
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
quality: ReviewQuality
|
||||
) {
|
||||
let card = fetchOrCreateReviewCard(
|
||||
lexemeId: lexemeId,
|
||||
partOfSpeech: partOfSpeech,
|
||||
drillMode: drillMode
|
||||
)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Which pool a `LexemeSessionQueue` draws from. Mirrors `VocabSessionKind`.
|
||||
enum LexemeSessionKind {
|
||||
/// Due-first + new lexemes from enabled CEFR levels, capped — the
|
||||
/// standard SRS session. Ratings update the long-term schedule.
|
||||
case standard
|
||||
/// Lexemes already studied at least once, most-recent first, uncapped
|
||||
/// and unfiltered — a consolidation cram. Ratings drive the in-session
|
||||
/// queue only and do NOT reschedule (long-term SM-2 due dates left
|
||||
/// untouched, parallel to `VocabSessionKind.reviewLearned`).
|
||||
case reviewLearned
|
||||
}
|
||||
|
||||
/// In-session learning-step queue for `Lexeme`-based vocab practice — the
|
||||
/// non-verb analog of `VocabSessionQueue`. Same Anki-style position-based
|
||||
/// requeue: Again/Hard requeue close, Good advances state then graduates on
|
||||
/// the second pass, Easy graduates immediately. `answer` returns a
|
||||
/// `ReviewQuality` only when the card graduates — that's the rating fed to
|
||||
/// the cross-session `LexemeReviewStore`.
|
||||
struct LexemeSessionQueue {
|
||||
|
||||
enum CardState: String {
|
||||
case new
|
||||
case learning
|
||||
case review
|
||||
}
|
||||
|
||||
enum Rating {
|
||||
case again, hard, good, easy
|
||||
}
|
||||
|
||||
struct Entry: Identifiable {
|
||||
let id = UUID()
|
||||
let lexeme: Lexeme
|
||||
var state: CardState
|
||||
}
|
||||
|
||||
let drillMode: String
|
||||
private(set) var queue: [Entry]
|
||||
private(set) var learnedCount: Int = 0
|
||||
private let originalLexemes: [Lexeme]
|
||||
|
||||
init(lexemes: [Lexeme], drillMode: String) {
|
||||
self.drillMode = drillMode
|
||||
self.originalLexemes = lexemes
|
||||
self.queue = lexemes.map { Entry(lexeme: $0, state: .new) }
|
||||
}
|
||||
|
||||
init(entries: [(lexeme: Lexeme, state: CardState)], drillMode: String, learnedCount: Int) {
|
||||
self.drillMode = drillMode
|
||||
self.originalLexemes = entries.map(\.lexeme)
|
||||
self.queue = entries.map { Entry(lexeme: $0.lexeme, state: $0.state) }
|
||||
self.learnedCount = learnedCount
|
||||
}
|
||||
|
||||
func snapshot() -> [(lexemeId: String, state: CardState)] {
|
||||
queue.map { ($0.lexeme.id, $0.state) }
|
||||
}
|
||||
|
||||
var current: Entry? { queue.first }
|
||||
var isComplete: Bool { queue.isEmpty }
|
||||
var remainingCount: Int { queue.count }
|
||||
|
||||
var progress: Double {
|
||||
let total = learnedCount + queue.count
|
||||
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||||
guard !queue.isEmpty else { return nil }
|
||||
var entry = queue.removeFirst()
|
||||
|
||||
switch rating {
|
||||
case .again:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 5...8))
|
||||
return nil
|
||||
case .hard:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 7...10))
|
||||
return nil
|
||||
case .good:
|
||||
if entry.state == .review {
|
||||
learnedCount += 1
|
||||
return .good
|
||||
}
|
||||
entry.state = .review
|
||||
insert(entry, offset: Int.random(in: 16...24))
|
||||
return nil
|
||||
case .easy:
|
||||
learnedCount += 1
|
||||
return .easy
|
||||
}
|
||||
}
|
||||
|
||||
mutating func restart() {
|
||||
queue = originalLexemes.shuffled().map { Entry(lexeme: $0, state: .new) }
|
||||
learnedCount = 0
|
||||
}
|
||||
|
||||
private mutating func insert(_ entry: Entry, offset: Int) {
|
||||
let idx = min(queue.count, offset)
|
||||
queue.insert(entry, at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session lexeme pool
|
||||
|
||||
/// Builds a session for a given POS + drill mode: due-first per
|
||||
/// `LexemeReviewCard`, then fresh (never-studied) lexemes, capped.
|
||||
enum LexemePool {
|
||||
|
||||
/// Per-session cap for a part of speech, from its "Cards per session"
|
||||
/// setting. Nouns read `nounSessionCardLimit`, adjectives
|
||||
/// `adjectiveSessionCardLimit`; anything else falls back to the legacy
|
||||
/// shared `lexemeSessionCardLimit`. 0/unset → 20. Mirrors
|
||||
/// `VocabVerbPool.sessionCardLimit`.
|
||||
static func sessionCardLimit(for partOfSpeech: String) -> Int {
|
||||
let key: String
|
||||
switch partOfSpeech {
|
||||
case "noun": key = "nounSessionCardLimit"
|
||||
case "adjective": key = "adjectiveSessionCardLimit"
|
||||
default: key = "lexemeSessionCardLimit"
|
||||
}
|
||||
let stored = UserDefaults.standard.integer(forKey: key)
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
static func sessionLexemes(
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
enabledLevels: Set<LexemeLevel>,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Lexeme] {
|
||||
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
|
||||
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||
}
|
||||
)
|
||||
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
let cardById = Dictionary(
|
||||
reviewCards.map { ($0.lexemeId, $0) },
|
||||
uniquingKeysWith: { existing, _ in existing }
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
var due: [(lexeme: Lexeme, dueDate: Date)] = []
|
||||
var fresh: [Lexeme] = []
|
||||
for lexeme in pool {
|
||||
if let card = cardById[lexeme.id] {
|
||||
if card.dueDate <= now {
|
||||
// Due cards surface regardless of current level toggles —
|
||||
// SRS isn't level-gated. Already-studied cards keep
|
||||
// coming back on their schedule.
|
||||
due.append((lexeme, card.dueDate))
|
||||
}
|
||||
} else if enabledLevels.contains(LexemeLevel.level(forRank: lexeme.frequencyRank)) {
|
||||
// Fresh (never-studied) cards only enter the pool from
|
||||
// levels the user has on. Disabling a level is the lever
|
||||
// for "don't introduce me to harder/easier words yet."
|
||||
fresh.append(lexeme)
|
||||
}
|
||||
}
|
||||
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
// Fresh cards surface in frequency order — most-useful words first.
|
||||
// Lexemes without a rank (frequencyRank == 0) sort last.
|
||||
fresh.sort { lhs, rhs in
|
||||
let l = lhs.frequencyRank == 0 ? Int.max : lhs.frequencyRank
|
||||
let r = rhs.frequencyRank == 0 ? Int.max : rhs.frequencyRank
|
||||
if l != r { return l < r }
|
||||
return lhs.baseForm < rhs.baseForm
|
||||
}
|
||||
|
||||
let ordered = due.map(\.lexeme) + fresh
|
||||
return Array(ordered.prefix(sessionCardLimit(for: partOfSpeech)))
|
||||
}
|
||||
|
||||
/// Lexemes the user has already studied at least once for `(POS, drill)`,
|
||||
/// most-recently-studied first. Mirrors `VocabVerbPool.reviewLearnedVerbs`.
|
||||
static func reviewLearnedLexemes(
|
||||
partOfSpeech: String,
|
||||
drillMode: String,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Lexeme] {
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||
}
|
||||
)
|
||||
let reviewCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
let sorted = reviewCards.sorted {
|
||||
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
|
||||
}
|
||||
|
||||
let pool = fetchStudyable(partOfSpeech: partOfSpeech, context: localContext)
|
||||
let byId = Dictionary(pool.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
return sorted.compactMap { byId[$0.lexemeId] }
|
||||
}
|
||||
|
||||
/// Lexemes for a POS. The catalog (`vocab_lexemes.json`) only emits
|
||||
/// nouns that have a known gender, so no extra filter is needed here.
|
||||
private static func fetchStudyable(partOfSpeech: String, context: ModelContext) -> [Lexeme] {
|
||||
let descriptor = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
|
||||
)
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ enum StartupCoordinator {
|
||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshTextbookDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshBooksDataIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshLexemesIfNeeded(container: localContainer)
|
||||
}
|
||||
|
||||
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import PDFKit
|
||||
|
||||
struct CourseMaterialView: View {
|
||||
let weekNumber: Int
|
||||
let courseName: String
|
||||
|
||||
private var resourceName: String? {
|
||||
// Only Beginner I has bundled PDFs (Beginner_I_W1.pdf … Beginner_I_W8.pdf).
|
||||
guard courseName.contains("Beginner I") else { return nil }
|
||||
return "Beginner_I_W\(weekNumber)"
|
||||
}
|
||||
|
||||
private var pdfURL: URL? {
|
||||
guard let name = resourceName else { return nil }
|
||||
return Bundle.main.url(forResource: name, withExtension: "pdf")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url = pdfURL {
|
||||
PDFKitView(url: url)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Material unavailable",
|
||||
systemImage: "doc.questionmark",
|
||||
description: Text("No course material is bundled for week \(weekNumber).")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Week \(weekNumber) Material")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let url = pdfURL {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ShareLink(item: url) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PDFKitView: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> PDFView {
|
||||
let view = PDFView()
|
||||
view.document = PDFDocument(url: url)
|
||||
view.autoScales = true
|
||||
view.displayMode = .singlePageContinuous
|
||||
view.displayDirection = .vertical
|
||||
view.usePageViewController(false)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PDFView, context: Context) {
|
||||
if uiView.document?.documentURL != url {
|
||||
uiView.document = PDFDocument(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CourseMaterialDestination: Hashable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
@@ -57,6 +57,11 @@ struct CourseView: View {
|
||||
return results.map(\.scorePercent).max()
|
||||
}
|
||||
|
||||
private func hasCourseMaterial(for week: Int) -> Bool {
|
||||
guard activeCourse.contains("Beginner I") else { return false }
|
||||
return Bundle.main.url(forResource: "Beginner_I_W\(week)", withExtension: "pdf") != nil
|
||||
}
|
||||
|
||||
private func shortName(_ full: String) -> String {
|
||||
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
||||
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
||||
@@ -116,6 +121,28 @@ struct CourseView: View {
|
||||
// Week sections
|
||||
ForEach(weekGroups, id: \.week) { week, weekDecks in
|
||||
Section {
|
||||
// Course material (PDF)
|
||||
if hasCourseMaterial(for: week) {
|
||||
NavigationLink(value: CourseMaterialDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc.richtext")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.purple)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Review Course Material")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Week \(week) PDF")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test button
|
||||
NavigationLink(value: WeekTestDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
@@ -231,6 +258,9 @@ struct CourseView: View {
|
||||
.navigationDestination(for: TextbookExerciseDestination.self) { dest in
|
||||
textbookExerciseView(for: dest)
|
||||
}
|
||||
.navigationDestination(for: CourseMaterialDestination.self) { dest in
|
||||
CourseMaterialView(weekNumber: dest.weekNumber, courseName: dest.courseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Due-card review for the adjective flashcard SRS — non-verb analog of
|
||||
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
|
||||
/// `partOfSpeech == "adjective"` whose `dueDate` is in the past, shows the
|
||||
/// Spanish base form on the front, reveals the English, then rates via the
|
||||
/// SRS so the schedule moves forward.
|
||||
struct AdjectiveReviewView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var dueCards: [LexemeReviewCard] = []
|
||||
@State private var lexemesByID: [String: Lexeme] = [:]
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var sessionTotal = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isFinished || dueCards.isEmpty {
|
||||
finishedView
|
||||
} else if let card = dueCards[safe: currentIndex] {
|
||||
cardView(card)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Adjective Review")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadDueCards)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cardView(_ card: LexemeReviewCard) -> some View {
|
||||
let lexeme = lexemesByID[card.lexemeId]
|
||||
VStack(spacing: 24) {
|
||||
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||
.tint(.pink)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(lexeme?.baseForm ?? "")
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isRevealed {
|
||||
Text(lexeme?.english ?? "")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ratingButton("Again", color: .red, quality: .again)
|
||||
ratingButton("Hard", color: .orange, quality: .hard)
|
||||
ratingButton("Good", color: .green, quality: .good)
|
||||
ratingButton("Easy", color: .blue, quality: .easy)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Show Answer")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||
|
||||
if dueCards.isEmpty {
|
||||
Text("All caught up!").font(.title2.bold())
|
||||
Text("No adjective cards are due for review.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
Button {
|
||||
rate(quality: quality)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(color)
|
||||
}
|
||||
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? cloudContext.save()
|
||||
|
||||
sessionTotal += 1
|
||||
if quality != .again { sessionCorrect += 1 }
|
||||
|
||||
isRevealed = false
|
||||
if currentIndex + 1 < dueCards.count {
|
||||
currentIndex += 1
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDueCards() {
|
||||
let now = Date()
|
||||
let pos = "adjective"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
},
|
||||
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
|
||||
)
|
||||
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
|
||||
let ids = Set(dueCards.map(\.lexemeId))
|
||||
let lexDesc = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||
)
|
||||
let all = (try? localContext.fetch(lexDesc)) ?? []
|
||||
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
}
|
||||
|
||||
static func dueCount(context: ModelContext) -> Int {
|
||||
let now = Date()
|
||||
let pos = "adjective"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,18 @@ struct BookReaderView: View {
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
/// The book's pre-computed glossary, decoded once on appear.
|
||||
@State private var glossary: [String: WordGloss] = [:]
|
||||
/// The word long-pressed as the read-aloud start point. Session-only —
|
||||
/// consulted when starting fresh; cleared by long-pressing it again.
|
||||
@State private var startAnchor: ReadingStart?
|
||||
|
||||
/// A chosen read-aloud start location: a word within a paragraph.
|
||||
private struct ReadingStart: Equatable {
|
||||
let paragraphIndex: Int
|
||||
let wordIndex: Int
|
||||
}
|
||||
|
||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.50
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
@@ -70,14 +79,24 @@ struct BookReaderView: View {
|
||||
}
|
||||
.accessibilityLabel("Voice & speed")
|
||||
|
||||
if speech.isReading {
|
||||
Button {
|
||||
speech.stop()
|
||||
} label: {
|
||||
Image(systemName: "stop.circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel("Stop reading")
|
||||
}
|
||||
|
||||
Button {
|
||||
toggleReadAloud()
|
||||
} label: {
|
||||
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
||||
Image(systemName: playButtonIcon)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
||||
.accessibilityLabel(playButtonLabel)
|
||||
|
||||
Button {
|
||||
withAnimation { showEnglish.toggle() }
|
||||
@@ -105,6 +124,7 @@ struct BookReaderView: View {
|
||||
.onDisappear {
|
||||
speech.stop()
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: startAnchor)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -116,10 +136,11 @@ struct BookReaderView: View {
|
||||
} else {
|
||||
TappableParagraph(
|
||||
text: paragraph,
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
||||
) { word in
|
||||
handleTap(word: word, paragraph: paragraph)
|
||||
}
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil,
|
||||
startWordIndex: startAnchor?.paragraphIndex == index ? startAnchor?.wordIndex : nil,
|
||||
onTap: { word in handleTap(word: word, paragraph: paragraph) },
|
||||
onLongPress: { wordIndex in setStartAnchor(paragraphIndex: index, wordIndex: wordIndex) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,18 +152,45 @@ struct BookReaderView: View {
|
||||
|
||||
// MARK: - Read-along controls
|
||||
|
||||
/// Main read-aloud button: starts, pauses, or resumes — it never stops, so
|
||||
/// the reading position survives pausing (and flipping to the English
|
||||
/// translation and back). Stopping is a separate button. A fresh start
|
||||
/// honors the long-pressed `startAnchor` when one is set.
|
||||
private func toggleReadAloud() {
|
||||
if speech.isReading {
|
||||
speech.stop()
|
||||
if speech.isPaused {
|
||||
speech.resume()
|
||||
} else {
|
||||
speech.pause()
|
||||
}
|
||||
} else if let anchor = startAnchor {
|
||||
speech.start(
|
||||
paragraphs: paragraphsES,
|
||||
fromParagraph: anchor.paragraphIndex,
|
||||
word: anchor.wordIndex
|
||||
)
|
||||
} 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 playButtonIcon: String {
|
||||
(speech.isReading && !speech.isPaused) ? "pause.circle.fill" : "play.circle.fill"
|
||||
}
|
||||
|
||||
private var playButtonLabel: String {
|
||||
guard speech.isReading else { return "Read aloud" }
|
||||
return speech.isPaused ? "Resume" : "Pause"
|
||||
}
|
||||
|
||||
/// Long-press handler: mark this word as the start point, or clear it when
|
||||
/// the already-marked word is long-pressed again. Doesn't interrupt an
|
||||
/// active read-aloud — the anchor is used on the next fresh start.
|
||||
private func setStartAnchor(paragraphIndex: Int, wordIndex: Int) {
|
||||
let candidate = ReadingStart(paragraphIndex: paragraphIndex, wordIndex: wordIndex)
|
||||
startAnchor = (startAnchor == candidate) ? nil : candidate
|
||||
}
|
||||
|
||||
private var voiceBinding: Binding<String?> {
|
||||
Binding(
|
||||
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
||||
@@ -238,13 +286,21 @@ struct BookReaderView: View {
|
||||
private struct TappableParagraph: View {
|
||||
let text: String
|
||||
let highlightedWordIndex: Int?
|
||||
let startWordIndex: Int?
|
||||
let onTap: (String) -> Void
|
||||
let onLongPress: (Int) -> 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)
|
||||
WordButton(
|
||||
word: word,
|
||||
isHighlighted: idx == highlightedWordIndex,
|
||||
isStartAnchor: idx == startWordIndex,
|
||||
onTap: onTap,
|
||||
onLongPress: { onLongPress(idx) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
@@ -254,7 +310,9 @@ private struct TappableParagraph: View {
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let isHighlighted: Bool
|
||||
let isStartAnchor: Bool
|
||||
let onTap: (String) -> Void
|
||||
let onLongPress: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
@@ -263,17 +321,26 @@ private struct WordButton: View {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, isHighlighted ? 2 : 0)
|
||||
.padding(.horizontal, (isHighlighted || isStartAnchor) ? 2 : 0)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
isHighlighted
|
||||
? Color.yellow.opacity(0.35)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 4)
|
||||
)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 4))
|
||||
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
||||
.animation(.easeInOut(duration: 0.15), value: isStartAnchor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Long-press marks the read-aloud start; the Button's tap still defines
|
||||
// the word. simultaneousGesture lets both live on the same view.
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: 0.4).onEnded { _ in onLongPress() }
|
||||
)
|
||||
}
|
||||
|
||||
/// The active spoken word (yellow) takes precedence over the start marker
|
||||
/// (indigo) so a word that's both reads as "now speaking."
|
||||
private var backgroundColor: Color {
|
||||
if isHighlighted { return Color.yellow.opacity(0.35) }
|
||||
if isStartAnchor { return Color.indigo.opacity(0.20) }
|
||||
return Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,11 +42,13 @@ struct BookVoicePickerSheet: View {
|
||||
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))
|
||||
Text("0.5×").tag(Float(0.30))
|
||||
Text("0.75×").tag(Float(0.40))
|
||||
Text("1×").tag(Float(0.50))
|
||||
Text("1.25×").tag(Float(0.575))
|
||||
Text("1.5×").tag(Float(0.65))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
if groups.isEmpty {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Due-card review for the noun flashcard SRS — the non-verb analog of
|
||||
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
|
||||
/// `partOfSpeech == "noun"` whose `dueDate` is in the past, shows the
|
||||
/// Spanish word with its article on the front, reveals the English, then
|
||||
/// rates via the SRS so the schedule moves forward.
|
||||
struct NounReviewView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var dueCards: [LexemeReviewCard] = []
|
||||
@State private var lexemesByID: [String: Lexeme] = [:]
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var sessionTotal = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isFinished || dueCards.isEmpty {
|
||||
finishedView
|
||||
} else if let card = dueCards[safe: currentIndex] {
|
||||
cardView(card)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Noun Review")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadDueCards)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cardView(_ card: LexemeReviewCard) -> some View {
|
||||
let lexeme = lexemesByID[card.lexemeId]
|
||||
VStack(spacing: 24) {
|
||||
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||
.tint(.teal)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(spanishFront(lexeme))
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isRevealed {
|
||||
Text(lexeme?.english ?? "")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ratingButton("Again", color: .red, quality: .again)
|
||||
ratingButton("Hard", color: .orange, quality: .hard)
|
||||
ratingButton("Good", color: .green, quality: .good)
|
||||
ratingButton("Easy", color: .blue, quality: .easy)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Show Answer")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||
|
||||
if dueCards.isEmpty {
|
||||
Text("All caught up!").font(.title2.bold())
|
||||
Text("No noun cards are due for review.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
Button {
|
||||
rate(quality: quality)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(color)
|
||||
}
|
||||
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? cloudContext.save()
|
||||
|
||||
sessionTotal += 1
|
||||
if quality != .again { sessionCorrect += 1 }
|
||||
|
||||
isRevealed = false
|
||||
if currentIndex + 1 < dueCards.count {
|
||||
currentIndex += 1
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDueCards() {
|
||||
let now = Date()
|
||||
let pos = "noun"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
},
|
||||
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
|
||||
)
|
||||
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
|
||||
let ids = Set(dueCards.map(\.lexemeId))
|
||||
let lexDesc = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||
)
|
||||
let all = (try? localContext.fetch(lexDesc)) ?? []
|
||||
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
}
|
||||
|
||||
static func dueCount(context: ModelContext) -> Int {
|
||||
let now = Date()
|
||||
let pos = "noun"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
|
||||
private func spanishFront(_ lexeme: Lexeme?) -> String {
|
||||
guard let lexeme else { return "" }
|
||||
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
|
||||
let article: String
|
||||
switch g {
|
||||
case "f": article = "la"
|
||||
case "m/f": article = "el/la"
|
||||
default: article = "el"
|
||||
}
|
||||
return "\(article) \(lexeme.baseForm)"
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ struct PracticeView: View {
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var isPracticing = false
|
||||
@State private var userProgress: UserProgress?
|
||||
/// Cached due counts for the noun + adjective Review rows. Refreshed on
|
||||
/// appear, on session end (`isPracticing` change), and after the user
|
||||
/// returns from a Review screen. Avoids running `fetchCount` against the
|
||||
/// cloud context on every `body` re-evaluation.
|
||||
@State private var nounDueCount: Int = 0
|
||||
@State private var adjectiveDueCount: Int = 0
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -36,10 +42,14 @@ struct PracticeView: View {
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
.onAppear {
|
||||
loadProgress()
|
||||
refreshLexemeDueCounts()
|
||||
}
|
||||
.onChange(of: isPracticing) { _, practicing in
|
||||
if !practicing {
|
||||
loadProgress()
|
||||
refreshLexemeDueCounts()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
@@ -115,6 +125,14 @@ struct PracticeView: View {
|
||||
sectionHeader("Vocabulary")
|
||||
vocabSection
|
||||
|
||||
// === Section: Nouns ===
|
||||
sectionHeader("Nouns")
|
||||
nounsSection
|
||||
|
||||
// === Section: Adjectives ===
|
||||
sectionHeader("Adjectives")
|
||||
adjectivesSection
|
||||
|
||||
// === Section: Reading ===
|
||||
sectionHeader("Reading")
|
||||
|
||||
@@ -421,12 +439,22 @@ struct PracticeView: View {
|
||||
VocabFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
|
||||
title: "Review Learned",
|
||||
title: "Review Learned — Flashcards",
|
||||
subtitle: "Re-review verbs you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
VocabMultipleChoicePracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
|
||||
title: "Review Learned — Multiple Choice",
|
||||
subtitle: "Multiple choice over verbs you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Existing: Vocab Review (due cards)
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
@@ -470,6 +498,176 @@ struct PracticeView: View {
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Nouns section
|
||||
|
||||
private var nounsSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
NounFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .teal,
|
||||
title: "Noun Flashcards",
|
||||
subtitle: "English → Spanish noun (with article)")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
NounMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .teal,
|
||||
title: "Noun Multiple Choice",
|
||||
subtitle: "Pick the Spanish noun from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
NounFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
|
||||
title: "Review Learned — Flashcards",
|
||||
subtitle: "Re-review nouns you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
NounMultipleChoicePracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .teal,
|
||||
title: "Review Learned — Multiple Choice",
|
||||
subtitle: "Multiple choice over nouns you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
NounReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Noun Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due noun cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if nounDueCount > 0 {
|
||||
Text("\(nounDueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Adjectives section
|
||||
|
||||
private var adjectivesSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
AdjectiveFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .pink,
|
||||
title: "Adjective Flashcards",
|
||||
subtitle: "English → Spanish adjective base form")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
AdjectiveMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .pink,
|
||||
title: "Adjective Multiple Choice",
|
||||
subtitle: "Pick the Spanish adjective from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
AdjectiveFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
|
||||
title: "Review Learned — Flashcards",
|
||||
subtitle: "Re-review adjectives you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
AdjectiveMultipleChoicePracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .pink,
|
||||
title: "Review Learned — Multiple Choice",
|
||||
subtitle: "Multiple choice over adjectives you've studied — schedule unchanged")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
NavigationLink {
|
||||
AdjectiveReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.pink)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Adjective Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due adjective cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if adjectiveDueCount > 0 {
|
||||
Text("\(adjectiveDueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.pink, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private func practiceRowLabel(icon: String, color: Color, title: String, subtitle: String) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
@@ -613,6 +811,11 @@ extension PracticeView {
|
||||
withAnimation { isPracticing = true }
|
||||
}
|
||||
|
||||
private func refreshLexemeDueCounts() {
|
||||
nounDueCount = NounReviewView.dueCount(context: cloudModelContext)
|
||||
adjectiveDueCount = AdjectiveReviewView.dueCount(context: cloudModelContext)
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
userProgress = progress
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English ↔ Spanish adjective flashcards. Same flow as the noun view and
|
||||
/// the verb flashcards: show the English meaning, tap to reveal the Spanish
|
||||
/// base form, rate Again/Hard/Good/Easy. Agreement (gender + number) is
|
||||
/// taught organically through reading and verb-flashcard examples, not as a
|
||||
/// separate quiz here.
|
||||
///
|
||||
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader`.
|
||||
struct AdjectiveFlashcardPracticeView: View {
|
||||
|
||||
var kind: LexemeSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: LexemeSessionQueue?
|
||||
@State private var revealed: Bool = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||
|
||||
private static let drillMode = "recall"
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerBar
|
||||
if let lexeme = currentLexeme {
|
||||
cardContent(for: lexeme)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjectives")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: revealed)
|
||||
.animation(.smooth, value: currentLexeme?.id)
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
@ViewBuilder
|
||||
private func cardContent(for lexeme: Lexeme) -> some View {
|
||||
Text(lexeme.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
VStack(spacing: 14) {
|
||||
Text(lexeme.baseForm)
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
exampleBlock(for: lexeme)
|
||||
ratingButtons(for: lexeme)
|
||||
}
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
}
|
||||
|
||||
private var tapToReveal: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hand.tap").font(.title).foregroundStyle(.secondary)
|
||||
Text("Tap to reveal").font(.headline).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 200)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.smooth) { revealed = true }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||
if let es = lexeme.exampleES, !es.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(es).font(.subheadline).italic()
|
||||
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0).tint(.pink)
|
||||
Text(progressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Rating
|
||||
|
||||
private func ratingButtons(for lexeme: Lexeme) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again, lexeme: lexeme)
|
||||
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
|
||||
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
|
||||
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
|
||||
Button {
|
||||
answer(rating, for: lexeme)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||
let graduation = session?.answer(rating)
|
||||
if let graduation, kind == .standard {
|
||||
LexemeReviewStore(context: cloudContext).rate(
|
||||
lexemeId: lexeme.id,
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
quality: graduation
|
||||
)
|
||||
}
|
||||
persistGroup()
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56)).foregroundStyle(.green)
|
||||
Text(completionTitle).font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button { studyAgain() } label: {
|
||||
Label("Next Set", systemImage: "arrow.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.pink)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionTitle: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
return learned > 0 ? "Session Complete" : "Nothing Available"
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
return "\(learned) adjective\(learned == 1 ? "" : "s") learned"
|
||||
}
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
if progress.selectedLexemeLevels.isEmpty {
|
||||
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
|
||||
}
|
||||
return "No adjectives available at the enabled levels right now."
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
let lexemes = LexemePool.reviewLearnedLexemes(
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
return
|
||||
|
||||
case .standard:
|
||||
let store = LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode
|
||||
)
|
||||
if let group = store.activeGroup() {
|
||||
let stored = group.entries
|
||||
if !stored.isEmpty {
|
||||
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
||||
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
||||
guard let lex = byId[e.lexemeId] else { return nil }
|
||||
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||
}
|
||||
if entries.count == stored.count {
|
||||
session = LexemeSessionQueue(
|
||||
entries: entries,
|
||||
drillMode: Self.drillMode,
|
||||
learnedCount: group.learnedCount
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||
let descriptor = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||
)
|
||||
let all = (try? localContext.fetch(descriptor)) ?? []
|
||||
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
}
|
||||
|
||||
private func persistGroup() {
|
||||
guard kind == .standard, let session else { return }
|
||||
let store = LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode
|
||||
)
|
||||
if session.isComplete {
|
||||
store.clear()
|
||||
} else {
|
||||
let entries = session.snapshot().map {
|
||||
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
|
||||
}
|
||||
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
case .standard:
|
||||
LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode
|
||||
).clear()
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first adjective multiple choice — non-verb analog of
|
||||
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
|
||||
/// adjective pool; 4 options (1 correct + 3 random distractors from the
|
||||
/// session). Options are bare base forms — agreement isn't drilled here.
|
||||
struct AdjectiveMultipleChoicePracticeView: View {
|
||||
var kind: LexemeSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: LexemeSessionQueue?
|
||||
@State private var distractorPool: [Lexeme] = []
|
||||
@State private var options: [Lexeme] = []
|
||||
@State private var selectedOption: Lexeme? = nil
|
||||
|
||||
private static let drillMode = "recall"
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let lexeme = currentLexeme {
|
||||
questionBody(lexeme)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Adjective Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
.animation(.smooth, value: currentLexeme?.id)
|
||||
}
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0).tint(.pink)
|
||||
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ lexeme: Lexeme) -> some View {
|
||||
Text(lexeme.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(lexeme)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionGrid: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(option.baseForm)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ lexeme: Lexeme) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(lexeme)
|
||||
exampleBlock(for: lexeme)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ lexeme: Lexeme) -> some View {
|
||||
let correct = (selectedOption?.id == lexeme.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(lexeme.baseForm)
|
||||
.font(.title2.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||
if let es = lexeme.exampleES, !es.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(es).font(.subheadline).italic()
|
||||
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button { studyAgain() } label: {
|
||||
Label("Study Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.pink)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
let verb = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) adjective\(learned == 1 ? "" : "s") \(verb)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No adjectives are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish an adjective session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
let lexemes: [Lexeme]
|
||||
switch kind {
|
||||
case .standard:
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
case .reviewLearned:
|
||||
lexemes = LexemePool.reviewLearnedLexemes(
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
}
|
||||
distractorPool = lexemes
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
session?.restart()
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let lexeme = currentLexeme else { options = []; return }
|
||||
let candidates = distractorPool.filter { $0.id != lexeme.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([lexeme] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func answer(_ rating: LexemeSessionQueue.Rating) {
|
||||
guard let lexeme = currentLexeme else { return }
|
||||
let graduation = session?.answer(rating)
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
LexemeReviewStore(context: cloudContext).rate(
|
||||
lexemeId: lexeme.id,
|
||||
partOfSpeech: "adjective",
|
||||
drillMode: Self.drillMode,
|
||||
quality: graduation
|
||||
)
|
||||
}
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English ↔ Spanish noun flashcards. Same flow as `VocabFlashcardPracticeView`
|
||||
/// for verbs: show the English meaning, tap to reveal the Spanish word, rate
|
||||
/// Again/Hard/Good/Easy. The Spanish reveal shows the word with its article
|
||||
/// (`la taza`, `el problema`) so gender is taught alongside meaning instead
|
||||
/// of being a separate "el or la?" quiz.
|
||||
///
|
||||
/// Plain `ScrollView { VStack }` — no `LazyVStack`/`ScrollViewReader` (keeps
|
||||
/// it out of the books-reader layout-loop class of bug).
|
||||
struct NounFlashcardPracticeView: View {
|
||||
|
||||
var kind: LexemeSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: LexemeSessionQueue?
|
||||
@State private var revealed: Bool = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||
|
||||
/// Single drill mode for now — meaning recall. The `LexemeReviewCard` /
|
||||
/// `LexemeStudyGroup` IDs are keyed by drillMode so other modes can be
|
||||
/// added later without colliding with this one.
|
||||
private static let drillMode = "recall"
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerBar
|
||||
if let lexeme = currentLexeme {
|
||||
cardContent(for: lexeme)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Nouns")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: revealed)
|
||||
.animation(.smooth, value: currentLexeme?.id)
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
@ViewBuilder
|
||||
private func cardContent(for lexeme: Lexeme) -> some View {
|
||||
Text(lexeme.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
VStack(spacing: 14) {
|
||||
Text(formattedSpanish(lexeme))
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
exampleBlock(for: lexeme)
|
||||
ratingButtons(for: lexeme)
|
||||
}
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the noun with its article so gender comes along free.
|
||||
private func formattedSpanish(_ lexeme: Lexeme) -> String {
|
||||
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
|
||||
let article: String
|
||||
switch g {
|
||||
case "f": article = "la"
|
||||
case "m/f": article = "el/la"
|
||||
default: article = "el"
|
||||
}
|
||||
return "\(article) \(lexeme.baseForm)"
|
||||
}
|
||||
|
||||
private var tapToReveal: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hand.tap").font(.title).foregroundStyle(.secondary)
|
||||
Text("Tap to reveal").font(.headline).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 200)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.smooth) { revealed = true }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||
if let es = lexeme.exampleES, !es.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(es).font(.subheadline).italic()
|
||||
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0).tint(.teal)
|
||||
Text(progressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Rating
|
||||
|
||||
private func ratingButtons(for lexeme: Lexeme) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again, lexeme: lexeme)
|
||||
ratingButton("Hard", color: .orange, rating: .hard, lexeme: lexeme)
|
||||
ratingButton("Good", color: .green, rating: .good, lexeme: lexeme)
|
||||
ratingButton("Easy", color: .blue, rating: .easy, lexeme: lexeme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating, lexeme: Lexeme) -> some View {
|
||||
Button {
|
||||
answer(rating, for: lexeme)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func answer(_ rating: LexemeSessionQueue.Rating, for lexeme: Lexeme) {
|
||||
let graduation = session?.answer(rating)
|
||||
// Review Learned is a cram — graduation drives the in-session queue
|
||||
// only; the cross-session SM-2 schedule is left alone (mirrors the
|
||||
// verb VocabFlashcardPracticeView reviewLearned behavior).
|
||||
if let graduation, kind == .standard {
|
||||
LexemeReviewStore(context: cloudContext).rate(
|
||||
lexemeId: lexeme.id,
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
quality: graduation
|
||||
)
|
||||
}
|
||||
persistGroup()
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56)).foregroundStyle(.green)
|
||||
Text(completionTitle).font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button { studyAgain() } label: {
|
||||
Label("Next Set", systemImage: "arrow.right")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionTitle: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
return learned > 0 ? "Session Complete" : "Nothing Available"
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
return "\(learned) noun\(learned == 1 ? "" : "s") learned"
|
||||
}
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
if progress.selectedLexemeLevels.isEmpty {
|
||||
return "No vocabulary levels enabled. Open Settings → Vocabulary Levels to turn some on."
|
||||
}
|
||||
return "No nouns available at the enabled levels right now."
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
// Cram pass over previously-studied lexemes. No study-group
|
||||
// persistence — restart-fresh each time it opens.
|
||||
let lexemes = LexemePool.reviewLearnedLexemes(
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
return
|
||||
|
||||
case .standard:
|
||||
let store = LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode
|
||||
)
|
||||
if let group = store.activeGroup() {
|
||||
let stored = group.entries
|
||||
if !stored.isEmpty {
|
||||
let byId = lexemesByID(Set(stored.map(\.lexemeId)))
|
||||
let entries: [(lexeme: Lexeme, state: LexemeSessionQueue.CardState)] = stored.compactMap { e in
|
||||
guard let lex = byId[e.lexemeId] else { return nil }
|
||||
return (lex, LexemeSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||
}
|
||||
if entries.count == stored.count {
|
||||
session = LexemeSessionQueue(
|
||||
entries: entries,
|
||||
drillMode: Self.drillMode,
|
||||
learnedCount: group.learnedCount
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func lexemesByID(_ ids: Set<String>) -> [String: Lexeme] {
|
||||
let descriptor = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||
)
|
||||
let all = (try? localContext.fetch(descriptor)) ?? []
|
||||
return Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
}
|
||||
|
||||
private func persistGroup() {
|
||||
// Review Learned is a transient cram; don't write a study group.
|
||||
guard kind == .standard, let session else { return }
|
||||
let store = LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode
|
||||
)
|
||||
if session.isComplete {
|
||||
store.clear()
|
||||
} else {
|
||||
let entries = session.snapshot().map {
|
||||
StoredLexemeEntry(lexemeId: $0.lexemeId, state: $0.state.rawValue)
|
||||
}
|
||||
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
case .standard:
|
||||
LexemeStudyGroupStore(
|
||||
context: cloudContext,
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode
|
||||
).clear()
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
persistGroup()
|
||||
}
|
||||
revealed = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first noun multiple choice — non-verb analog of
|
||||
/// `VocabMultipleChoicePracticeView`. Driven by `LexemeSessionQueue` over the
|
||||
/// noun pool; 4 options (1 correct + 3 random distractors from the session).
|
||||
/// After answering: reveal feedback, the answer with its article (la taza /
|
||||
/// el problema), example sentence when present, and Again/Hard/Good/Easy
|
||||
/// rating which drives the `LexemeReviewStore` schedule.
|
||||
struct NounMultipleChoicePracticeView: View {
|
||||
var kind: LexemeSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: LexemeSessionQueue?
|
||||
@State private var distractorPool: [Lexeme] = []
|
||||
@State private var options: [Lexeme] = []
|
||||
@State private var selectedOption: Lexeme? = nil
|
||||
|
||||
private static let drillMode = "recall"
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
private var currentLexeme: Lexeme? { session?.current?.lexeme }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let lexeme = currentLexeme {
|
||||
questionBody(lexeme)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Noun Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
.animation(.smooth, value: currentLexeme?.id)
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0).tint(.teal)
|
||||
Text(progressLabel).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Question
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ lexeme: Lexeme) -> some View {
|
||||
Text(lexeme.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(lexeme)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionGrid: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(formattedSpanish(option))
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ lexeme: Lexeme) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(lexeme)
|
||||
exampleBlock(for: lexeme)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ lexeme: Lexeme) -> some View {
|
||||
let correct = (selectedOption?.id == lexeme.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(formattedSpanish(lexeme))
|
||||
.font(.title2.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for lexeme: Lexeme) -> some View {
|
||||
if let es = lexeme.exampleES, !es.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(es).font(.subheadline).italic()
|
||||
if let en = lexeme.exampleEN, !en.isEmpty {
|
||||
Text(en).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: LexemeSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button { studyAgain() } label: {
|
||||
Label("Study Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
let verb = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) noun\(learned == 1 ? "" : "s") \(verb)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No nouns are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish a noun session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
let lexemes: [Lexeme]
|
||||
switch kind {
|
||||
case .standard:
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
lexemes = LexemePool.sessionLexemes(
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
enabledLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
case .reviewLearned:
|
||||
lexemes = LexemePool.reviewLearnedLexemes(
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext
|
||||
)
|
||||
}
|
||||
distractorPool = lexemes
|
||||
session = LexemeSessionQueue(lexemes: lexemes, drillMode: Self.drillMode)
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
session?.restart()
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let lexeme = currentLexeme else { options = []; return }
|
||||
let candidates = distractorPool.filter { $0.id != lexeme.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([lexeme] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func answer(_ rating: LexemeSessionQueue.Rating) {
|
||||
guard let lexeme = currentLexeme else { return }
|
||||
let graduation = session?.answer(rating)
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
LexemeReviewStore(context: cloudContext).rate(
|
||||
lexemeId: lexeme.id,
|
||||
partOfSpeech: "noun",
|
||||
drillMode: Self.drillMode,
|
||||
quality: graduation
|
||||
)
|
||||
}
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
}
|
||||
|
||||
private func formattedSpanish(_ lexeme: Lexeme) -> String {
|
||||
guard let g = lexeme.gender, !g.isEmpty else { return lexeme.baseForm }
|
||||
let article: String
|
||||
switch g {
|
||||
case "f": article = "la"
|
||||
case "m/f": article = "el/la"
|
||||
default: article = "el"
|
||||
}
|
||||
return "\(article) \(lexeme.baseForm)"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import SwiftData
|
||||
/// 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 {
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@@ -37,7 +39,7 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle("Vocab Multiple Choice")
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
@@ -221,16 +223,28 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") learned"
|
||||
let verb = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") \(verb)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish a Vocab session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
let verbs: [Verb]
|
||||
switch kind {
|
||||
case .standard:
|
||||
verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
case .reviewLearned:
|
||||
verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
}
|
||||
distractorPool = verbs
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
prepareOptions()
|
||||
@@ -254,7 +268,9 @@ struct VocabMultipleChoicePracticeView: View {
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
if let graduation {
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
selectedOption = nil
|
||||
|
||||
@@ -10,8 +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).
|
||||
/// Cards per study session, per word type. 999 = "All" (no cap).
|
||||
@AppStorage("vocabSessionCardLimit") private var vocabSessionCardLimit: Int = 20
|
||||
@AppStorage("nounSessionCardLimit") private var nounSessionCardLimit: Int = 20
|
||||
@AppStorage("adjectiveSessionCardLimit") private var adjectiveSessionCardLimit: Int = 20
|
||||
private let vocabSessionSizes: [Int] = [10, 15, 20, 25, 30, 50, 999]
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
@@ -47,15 +49,13 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Cards per session", selection: $vocabSessionCardLimit) {
|
||||
ForEach(vocabSessionSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit)
|
||||
sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit)
|
||||
sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit)
|
||||
} header: {
|
||||
Text("Vocab Flashcards")
|
||||
Text("Cards Per Session")
|
||||
} footer: {
|
||||
Text("How many verbs a Vocab Flashcards session draws. Overdue verbs are pulled first, then new ones.")
|
||||
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -72,11 +72,30 @@ struct SettingsView: View {
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Levels")
|
||||
Text("Verb Levels")
|
||||
} footer: {
|
||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(LexemeLevel.allCases, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
get: {
|
||||
progress?.selectedLexemeLevels.contains(level) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setLexemeLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Vocabulary Levels")
|
||||
} footer: {
|
||||
Text("Noun and adjective flashcards pull only from the enabled CEFR levels. New first-time installs default to A1 + A2.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
Toggle(tense.english, isOn: Binding(
|
||||
@@ -153,6 +172,14 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionSizePicker(_ title: String, selection: Binding<Int>) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(vocabSessionSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress = resolved
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -60,10 +60,15 @@ For EACH word, produce one entry:
|
||||
dictionary sense.
|
||||
- partOfSpeech: one of verb, noun, adjective, adverb, pronoun, preposition,
|
||||
conjunction, article, interjection, numeral, proper noun, other.
|
||||
- gender: ONLY for `partOfSpeech == "noun"`. "m" for masculine, "f" for
|
||||
feminine, "m/f" for nouns that take either article (estudiante, artista).
|
||||
OMIT the field entirely (or use null) for non-nouns and for cases where the
|
||||
gender is genuinely unknowable from context. Don't guess for non-nouns.
|
||||
|
||||
Write the output file as JSON with this exact shape:
|
||||
{{"jobId": "<the jobId from the input>", "entries": [
|
||||
{{"word": "...", "baseForm": "...", "english": "...", "partOfSpeech": "..."}}
|
||||
{{"word": "...", "baseForm": "...", "english": "...",
|
||||
"partOfSpeech": "...", "gender": "m"}}
|
||||
]}}
|
||||
|
||||
`entries` MUST contain exactly one object per input word, cover every word, and
|
||||
|
||||
@@ -109,11 +109,15 @@ def main() -> None:
|
||||
word = (entry.get("word") or "").strip()
|
||||
if not word:
|
||||
continue
|
||||
glossary[word] = {
|
||||
gloss_entry: dict = {
|
||||
"baseForm": entry.get("baseForm") or word,
|
||||
"english": entry.get("english") or "",
|
||||
"partOfSpeech": entry.get("partOfSpeech") or "",
|
||||
}
|
||||
gender = entry.get("gender")
|
||||
if isinstance(gender, str) and gender.strip():
|
||||
gloss_entry["gender"] = gender.strip()
|
||||
glossary[word] = gloss_entry
|
||||
if glossary_missing:
|
||||
msg = f"{len(glossary_missing)} glossary job(s) missing output: {glossary_missing[:5]}{'...' if len(glossary_missing) > 5 else ''}"
|
||||
if args.require_all:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.cache/
|
||||
@@ -0,0 +1,62 @@
|
||||
# Vocab catalog build
|
||||
|
||||
`build_lexemes.py` produces `Conjuga/vocab_lexemes.json`, the bundled catalog
|
||||
of frequency-ranked Spanish nouns and adjectives that powers the Noun /
|
||||
Adjective flashcard study modes.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
python3 build_lexemes.py
|
||||
```
|
||||
|
||||
Downloads `frequency.csv` + `es-en.data` from a pinned commit of
|
||||
[`doozan/spanish_data`](https://github.com/doozan/spanish_data), caches them
|
||||
under `.cache/<commit>/`, joins them, and writes the JSON. Re-running is
|
||||
fast — only the join step happens after the first download.
|
||||
|
||||
Override defaults:
|
||||
|
||||
```sh
|
||||
python3 build_lexemes.py --max-nouns 3000 --max-adjectives 1000
|
||||
python3 build_lexemes.py --output /tmp/vocab.json
|
||||
```
|
||||
|
||||
## Data sources & attribution
|
||||
|
||||
All datasets are CC-licensed; the bundled catalog inherits CC-BY-SA. Credit
|
||||
in the app's About screen must read:
|
||||
|
||||
> Vocabulary data: Wiktionary (CC-BY-SA), OpenSubtitles via FrequencyWords
|
||||
> (CC-BY-SA 3.0).
|
||||
|
||||
- **`frequency.csv`** — derived from
|
||||
[hermitdave/FrequencyWords](https://github.com/hermitdave/FrequencyWords)
|
||||
(OpenSubtitles corpus), packaged by doozan. License: CC-BY-SA 3.0.
|
||||
- **`es-en.data`** — Spanish→English Wiktionary export in the
|
||||
[`enwiktionary_wordlist`](https://github.com/doozan/enwiktionary_wordlist)
|
||||
format. License: CC-BY-SA.
|
||||
|
||||
The pinned doozan commit is at the top of `build_lexemes.py`
|
||||
(`DOOZAN_COMMIT`). Bump it to refresh; the cache key includes the commit so
|
||||
old data is auto-replaced.
|
||||
|
||||
## Output shape
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"baseForm": "casa",
|
||||
"english": "house",
|
||||
"partOfSpeech": "noun",
|
||||
"gender": "f",
|
||||
"frequencyRank": 142,
|
||||
"exampleES": "La casa es grande",
|
||||
"exampleEN": "The house is big"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Sorted by `frequencyRank` ascending so the fresh-card path in `LexemePool`
|
||||
surfaces the most useful words first.
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build Conjuga/vocab_lexemes.json from doozan/spanish_data.
|
||||
|
||||
Joins doozan's frequency.csv (CC-BY-SA 3.0, OpenSubtitles via FrequencyWords)
|
||||
with es-en.data (CC-BY-SA, Wiktionary) into a single bundled JSON catalog of
|
||||
the highest-frequency Spanish nouns and adjectives — each row carries the
|
||||
lemma, English gloss, gender (for nouns), frequency rank, and an example
|
||||
sentence with translation when Wiktionary has one.
|
||||
|
||||
The app's DataLoader.seedLexemesFromCatalog reads this file at startup to
|
||||
populate the Lexeme table that powers Noun / Adjective flashcard study.
|
||||
|
||||
Usage:
|
||||
python3 build_lexemes.py [--max-nouns N] [--max-adjectives N]
|
||||
[--output PATH] [--cache-dir PATH]
|
||||
|
||||
Pinned doozan commit: aeac698949e7b27112056ee8d72f70f853cd1ef9 (2026-05-01)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DOOZAN_COMMIT = "aeac698949e7b27112056ee8d72f70f853cd1ef9"
|
||||
BASE_URL = f"https://raw.githubusercontent.com/doozan/spanish_data/{DOOZAN_COMMIT}"
|
||||
|
||||
FILES = {
|
||||
"frequency.csv": f"{BASE_URL}/frequency.csv",
|
||||
"es-en.data": f"{BASE_URL}/es-en.data",
|
||||
}
|
||||
|
||||
# Both frequency.csv and es-en.data use short POS codes (`n`, `adj`); we keep
|
||||
# the same codes for the join. The output JSON uses the longer names the
|
||||
# app's Lexeme model expects.
|
||||
JOIN_POS = {"n", "adj"}
|
||||
OUTPUT_POS = {"n": "noun", "adj": "adjective"}
|
||||
|
||||
|
||||
def fetch(name: str, url: str, cache_dir: Path) -> Path:
|
||||
"""Download once; reuse local cache on subsequent runs."""
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = cache_dir / name
|
||||
if out.exists() and out.stat().st_size > 0:
|
||||
return out
|
||||
print(f" downloading {name} ({url}) ...", file=sys.stderr)
|
||||
with urllib.request.urlopen(url) as resp, open(out, "wb") as fh:
|
||||
fh.write(resp.read())
|
||||
return out
|
||||
|
||||
|
||||
def load_frequency(path: Path, *, keep_pos: set[str]) -> list[dict]:
|
||||
"""Read frequency.csv → list of {lemma, pos, rank} for the POSes we care
|
||||
about. Rank is the row index (1-based), which matches frequency-descending
|
||||
order in the source file."""
|
||||
rows: list[dict] = []
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
for i, row in enumerate(reader):
|
||||
pos = (row.get("pos") or "").strip()
|
||||
if pos not in keep_pos:
|
||||
continue
|
||||
flags = (row.get("flags") or "").strip()
|
||||
if "DUPLICATE" in flags or "NOUSAGE" in flags:
|
||||
continue
|
||||
lemma = (row.get("spanish") or "").strip()
|
||||
if not lemma:
|
||||
continue
|
||||
rows.append({"lemma": lemma, "pos": pos, "rank": i + 1})
|
||||
return rows
|
||||
|
||||
|
||||
def load_es_en(path: Path) -> dict[tuple[str, str], dict]:
|
||||
"""Parse es-en.data → {(lemma, pos): {gender, english, exampleES, exampleEN}}.
|
||||
|
||||
A single `_____`-delimited block can hold multiple `pos:` sub-entries
|
||||
for the same lemma (e.g. `rojo` is both an adjective ("red") and a
|
||||
masculine noun ("a red one"); `mano` has two noun senses with different
|
||||
genders). We commit each sub-entry when we see the next `pos:` line, so
|
||||
`(lemma, pos)` pairs don't get clobbered by later same-block sub-entries.
|
||||
First-sense-wins on duplicate keys, which aligns with Wiktionary listing
|
||||
the most-common meaning first.
|
||||
"""
|
||||
entries: dict[tuple[str, str], dict] = {}
|
||||
lemma = pos = gender = english = ex_es = ex_en = None
|
||||
next_is_lemma = False
|
||||
|
||||
def commit_subentry() -> None:
|
||||
nonlocal pos, gender, english, ex_es, ex_en
|
||||
if lemma and pos and english:
|
||||
key = (lemma, pos)
|
||||
if key not in entries:
|
||||
entries[key] = {
|
||||
"gender": gender,
|
||||
"english": english,
|
||||
"exampleES": ex_es,
|
||||
"exampleEN": ex_en,
|
||||
}
|
||||
pos = gender = english = ex_es = ex_en = None
|
||||
|
||||
def reset_entry() -> None:
|
||||
nonlocal lemma
|
||||
commit_subentry()
|
||||
lemma = None
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for raw in fh:
|
||||
line = raw.rstrip("\n")
|
||||
stripped = line.lstrip()
|
||||
if stripped == "_____":
|
||||
reset_entry()
|
||||
next_is_lemma = True
|
||||
continue
|
||||
if next_is_lemma:
|
||||
lemma = stripped
|
||||
next_is_lemma = False
|
||||
continue
|
||||
if stripped.startswith("pos: "):
|
||||
# Starting a new sub-entry for the current lemma; commit the
|
||||
# previous sub-entry's state before resetting.
|
||||
commit_subentry()
|
||||
pos = stripped[5:].strip()
|
||||
elif stripped.startswith("g: "):
|
||||
gender = stripped[3:].strip()
|
||||
elif stripped.startswith("gloss: "):
|
||||
if english is None:
|
||||
english = stripped[7:].strip()
|
||||
elif stripped.startswith("ex: "):
|
||||
if ex_es is None:
|
||||
ex_es = stripped[4:].strip()
|
||||
elif stripped.startswith("eng: "):
|
||||
if ex_en is None:
|
||||
ex_en = stripped[5:].strip()
|
||||
reset_entry()
|
||||
return entries
|
||||
|
||||
|
||||
def normalize_gender(g: str | None) -> str | None:
|
||||
"""Reduce Wiktionary gender codes to {m, f, m/f, None}.
|
||||
|
||||
`mp` (masculine plural) / `fp` (feminine plural) are inherently-plural
|
||||
nouns (gafas, pantalones); they don't fit the singular el/la drill cleanly
|
||||
in v1, so we drop them here and the entry is filtered out upstream.
|
||||
"""
|
||||
if not g:
|
||||
return None
|
||||
g = g.strip()
|
||||
if g in ("m", "f"):
|
||||
return g
|
||||
if g in ("mf", "m/f", "m, f", "f, m"):
|
||||
return "m/f"
|
||||
return None
|
||||
|
||||
|
||||
def build(args) -> None:
|
||||
cache = Path(args.cache_dir).expanduser()
|
||||
paths = {name: fetch(name, url, cache) for name, url in FILES.items()}
|
||||
|
||||
print(
|
||||
f"Reading frequency.csv (top {args.max_nouns} nouns, "
|
||||
f"top {args.max_adjectives} adjectives) ...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
rows = load_frequency(paths["frequency.csv"], keep_pos=JOIN_POS)
|
||||
nouns = [r for r in rows if r["pos"] == "n"][: args.max_nouns]
|
||||
adjs = [r for r in rows if r["pos"] == "adj"][: args.max_adjectives]
|
||||
print(f" candidates: {len(nouns)} nouns, {len(adjs)} adjectives", file=sys.stderr)
|
||||
|
||||
print("Parsing es-en.data ...", file=sys.stderr)
|
||||
es_en = load_es_en(paths["es-en.data"])
|
||||
print(f" {len(es_en)} (lemma, pos) entries", file=sys.stderr)
|
||||
|
||||
out: list[dict] = []
|
||||
skipped_no_entry = 0
|
||||
skipped_no_english = 0
|
||||
skipped_no_gender = 0
|
||||
for source_rows in (nouns, adjs):
|
||||
for r in source_rows:
|
||||
short_pos = r["pos"]
|
||||
output_pos = OUTPUT_POS[short_pos]
|
||||
entry = es_en.get((r["lemma"], short_pos))
|
||||
if not entry:
|
||||
skipped_no_entry += 1
|
||||
continue
|
||||
english = entry.get("english")
|
||||
if not english:
|
||||
skipped_no_english += 1
|
||||
continue
|
||||
gender = normalize_gender(entry.get("gender")) if short_pos == "n" else None
|
||||
if short_pos == "n" and gender is None:
|
||||
# Drill needs gender; if Wiktionary doesn't have it, skip.
|
||||
skipped_no_gender += 1
|
||||
continue
|
||||
out.append({
|
||||
"baseForm": r["lemma"],
|
||||
"english": english,
|
||||
"partOfSpeech": output_pos,
|
||||
"gender": gender,
|
||||
"frequencyRank": r["rank"],
|
||||
"exampleES": entry.get("exampleES"),
|
||||
"exampleEN": entry.get("exampleEN"),
|
||||
})
|
||||
|
||||
out.sort(key=lambda e: e["frequencyRank"])
|
||||
|
||||
out_path = Path(args.output).expanduser()
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, ensure_ascii=False, separators=(",", ":"))
|
||||
fh.write("\n")
|
||||
|
||||
noun_count = sum(1 for e in out if e["partOfSpeech"] == "noun")
|
||||
adj_count = sum(1 for e in out if e["partOfSpeech"] == "adjective")
|
||||
print(
|
||||
f"Wrote {out_path} — {noun_count} nouns, {adj_count} adjectives "
|
||||
f"({len(out)} total, {out_path.stat().st_size:,} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f" skipped: no es-en entry={skipped_no_entry}, "
|
||||
f"no english={skipped_no_english}, "
|
||||
f"no gender={skipped_no_gender}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
here = Path(__file__).resolve().parent
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--max-nouns", type=int, default=1500)
|
||||
parser.add_argument("--max-adjectives", type=int, default=600)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default=str(here / ".." / ".." / "Conjuga" / "vocab_lexemes.json"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache-dir",
|
||||
default=str(here / ".cache" / DOOZAN_COMMIT[:8]),
|
||||
)
|
||||
build(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -44,15 +44,24 @@ public final class Book {
|
||||
}
|
||||
|
||||
/// One glossary entry: a word's dictionary base form, English meaning, and
|
||||
/// part of speech, translated in the book's context at import time.
|
||||
/// part of speech, translated in the book's context at import time. `gender`
|
||||
/// is populated by the glossary pipeline for nouns ("m"/"f"/"m/f"); nil for
|
||||
/// non-nouns or when the pipeline hasn't been re-run yet.
|
||||
public struct WordGloss: Codable, Hashable, Sendable {
|
||||
public let baseForm: String
|
||||
public let english: String
|
||||
public let partOfSpeech: String
|
||||
public let gender: String?
|
||||
|
||||
public init(baseForm: String, english: String, partOfSpeech: String) {
|
||||
public init(
|
||||
baseForm: String,
|
||||
english: String,
|
||||
partOfSpeech: String,
|
||||
gender: String? = nil
|
||||
) {
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.partOfSpeech = partOfSpeech
|
||||
self.gender = gender
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// A non-verb vocabulary item harvested from the books pipeline's per-book
|
||||
/// glossary. Verbs keep their own richer `Verb` model — `Lexeme` covers
|
||||
/// nouns, adjectives, etc. so the flashcard study modes can drill the grammar
|
||||
/// that's specific to each part of speech.
|
||||
///
|
||||
/// Identity is `"<sourceBookSlug>:<partOfSpeech>:<baseForm>"`; the seeder
|
||||
/// dedupes on `(partOfSpeech, baseForm)` across books and keeps the first-
|
||||
/// seen source. Lives in the LOCAL reference-data store (same place as
|
||||
/// `Book`/`BookChapter`), not the cloud container.
|
||||
@Model
|
||||
public final class Lexeme {
|
||||
@Attribute(.unique) public var id: String = ""
|
||||
public var partOfSpeech: String = ""
|
||||
public var baseForm: String = ""
|
||||
public var english: String = ""
|
||||
/// For nouns: "m", "f", or "m/f". Nil for non-nouns or when unknown.
|
||||
/// The curated catalog (`vocab_lexemes.json` from doozan/spanish_data)
|
||||
/// emits Wiktionary-sourced gender; `Lexeme.inferGender` provides a
|
||||
/// morphology fallback if a different seeder ever lands a noun without
|
||||
/// one.
|
||||
public var gender: String? = nil
|
||||
/// Source tag — `"catalog"` for entries from `vocab_lexemes.json`, or a
|
||||
/// book slug for legacy book-glossary-derived entries. Used to keep
|
||||
/// catalog refreshes from wiping book-personal additions later.
|
||||
public var sourceBookSlug: String = ""
|
||||
/// 1-based rank in the source frequency list (lower = more common).
|
||||
/// 0 means unknown/unranked. `LexemePool` sorts fresh cards by this so
|
||||
/// the most-useful words surface first.
|
||||
public var frequencyRank: Int = 0
|
||||
/// Optional example sentence pair, shown below the answer in Recall
|
||||
/// mode. Sourced from Wiktionary's `ex:`/`eng:` lines when available.
|
||||
public var exampleES: String? = nil
|
||||
public var exampleEN: String? = nil
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
partOfSpeech: String,
|
||||
baseForm: String,
|
||||
english: String,
|
||||
gender: String? = nil,
|
||||
sourceBookSlug: String = "",
|
||||
frequencyRank: Int = 0,
|
||||
exampleES: String? = nil,
|
||||
exampleEN: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.partOfSpeech = partOfSpeech
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.gender = gender
|
||||
self.sourceBookSlug = sourceBookSlug
|
||||
self.frequencyRank = frequencyRank
|
||||
self.exampleES = exampleES
|
||||
self.exampleEN = exampleEN
|
||||
}
|
||||
|
||||
public static func makeID(sourceBookSlug: String, partOfSpeech: String, baseForm: String) -> String {
|
||||
"\(sourceBookSlug):\(partOfSpeech):\(baseForm)"
|
||||
}
|
||||
|
||||
/// Best-effort gender from Spanish morphology. Used as a fallback when
|
||||
/// the glossary pipeline hasn't emitted a `gender` field yet. Conservative:
|
||||
/// returns nil for ambiguous endings rather than guessing wrong.
|
||||
///
|
||||
/// - `-ción/-sión/-dad/-tad/-tud/-umbre/-ez/-anza` → feminine
|
||||
/// - `-aje/-or` → masculine
|
||||
/// - `-ma/-pa/-ta` → nil (Greek-origin masculines mix with regular -a feminines)
|
||||
/// - `-a` (other) → feminine
|
||||
/// - `-o` → masculine
|
||||
/// - everything else → nil
|
||||
public static func inferGender(forBaseForm baseForm: String) -> String? {
|
||||
let s = baseForm.lowercased()
|
||||
if s.hasSuffix("ción") || s.hasSuffix("sión") || s.hasSuffix("dad") ||
|
||||
s.hasSuffix("tad") || s.hasSuffix("tud") || s.hasSuffix("umbre") ||
|
||||
s.hasSuffix("ez") || s.hasSuffix("anza") {
|
||||
return "f"
|
||||
}
|
||||
if s.hasSuffix("aje") || s.hasSuffix("or") {
|
||||
return "m"
|
||||
}
|
||||
if s.hasSuffix("ma") || s.hasSuffix("pa") || s.hasSuffix("ta") {
|
||||
return nil
|
||||
}
|
||||
if s.hasSuffix("a") { return "f" }
|
||||
if s.hasSuffix("o") { return "m" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// CEFR-style level for a `Lexeme`, derived from its `frequencyRank`. Lets
|
||||
/// users gate noun/adjective flashcard sessions by level via a Settings
|
||||
/// toggle. Cutoffs follow the standard Spanish-frequency-dictionary
|
||||
/// convention (Davies; RAE CEFR-aligned lists).
|
||||
///
|
||||
/// Note: SRS is *not* level-gated. Disabling a level only stops *new*
|
||||
/// cards from that band entering the session pool — already-studied cards
|
||||
/// keep coming back on their SM-2 schedule regardless. See
|
||||
/// `LexemePool.sessionLexemes` for where the filter is applied.
|
||||
public enum LexemeLevel: String, Codable, Hashable, CaseIterable, Sendable {
|
||||
case a1, a2, b1, b2, c1
|
||||
|
||||
/// 1-based frequency rank range. `c1` is open-ended on the high end so
|
||||
/// any far-tail entry has a level even if the catalog later expands.
|
||||
public var rankRange: ClosedRange<Int> {
|
||||
switch self {
|
||||
case .a1: return 1...250
|
||||
case .a2: return 251...500
|
||||
case .b1: return 501...1000
|
||||
case .b2: return 1001...2000
|
||||
case .c1: return 2001...Int.max
|
||||
}
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .a1: return "A1 — Beginner"
|
||||
case .a2: return "A2 — Elementary"
|
||||
case .b1: return "B1 — Intermediate"
|
||||
case .b2: return "B2 — Upper-intermediate"
|
||||
case .c1: return "C1+ — Advanced"
|
||||
}
|
||||
}
|
||||
|
||||
/// The level containing this frequency rank. Rank 0 (unranked) falls
|
||||
/// into `c1` — better to include unknown-rank lexemes when only the
|
||||
/// top end is on than silently drop them.
|
||||
public static func level(forRank rank: Int) -> LexemeLevel {
|
||||
guard rank > 0 else { return .c1 }
|
||||
for level in LexemeLevel.allCases where level.rankRange.contains(rank) {
|
||||
return level
|
||||
}
|
||||
return .c1
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ public enum SharedStore {
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
TextbookChapter.self, DownloadedVideo.self,
|
||||
Book.self, BookChapter.self,
|
||||
Lexeme.self,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,12 @@ targets:
|
||||
buildPhase: resources
|
||||
- path: Conjuga/textbook_vocab.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/book_olly-vol2.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/vocab_lexemes.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/CourseMaterials
|
||||
buildPhase: resources
|
||||
info:
|
||||
path: Conjuga/Info.plist
|
||||
properties:
|
||||
|
||||
Reference in New Issue
Block a user