Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ad448a600 | |||
| aab64116b3 | |||
| 179400b90d | |||
| 696eafa64f | |||
| 7da98d786c | |||
| ac84b22977 | |||
| 3ee1563cb0 | |||
| d0582c4ce7 | |||
| 26ce662c60 | |||
| dd08a09860 | |||
| c794c013f0 | |||
| eec0fb56d5 | |||
| 209602eaad | |||
| cee962c0e0 | |||
| f14008f96f | |||
| 5c0fc8ee2d | |||
| d61f9e50b1 | |||
| 900a927f95 | |||
| f0eb75a28a | |||
| f4c139aed0 | |||
| 0af8e648fe | |||
| c890095610 | |||
| 164a0a1bb7 | |||
| d49eb38a6d | |||
| 0b7d4a73ad | |||
| 9aa4d0836d | |||
| 5db4b014a9 | |||
| de446b2301 | |||
| a416233a2d | |||
| 70d8299df8 | |||
| 51067e23fd | |||
| 05a367fdbe | |||
| 09e49bda2c | |||
| ade091f108 | |||
| 05a0cc0d17 | |||
| f368c24ad6 | |||
| 90aea92fba | |||
| dce2cc1f51 | |||
| 06b47d37cf | |||
| f993bfbb96 | |||
| fcb907718a | |||
| 9c7033d1b4 | |||
| 0a099c3fc9 | |||
| 57f945a4d3 | |||
| 5777a210cd | |||
| 98badc98ad | |||
| 4093b5a7f3 | |||
| 5d3accb2c0 | |||
| 3d8cbccc4e | |||
| cc6ec70ed9 | |||
| d99d88e73c | |||
| 8e1c9b6bf1 | |||
| d9ddaa4902 | |||
| cdf1e05c4c | |||
| 455df18dad | |||
| 3c5600f562 | |||
| 5f90a01314 | |||
| cd491bd695 | |||
| df96a9e540 | |||
| c73762ab9f | |||
| f809bc2a1d | |||
| 63dfc5e41a | |||
| 5ba76a947b | |||
| bb596b19bd | |||
| 47a7871c38 | |||
| b17fb49d49 |
+23
@@ -34,3 +34,26 @@ Pods/
|
||||
screens/
|
||||
conjugato/
|
||||
conjuu-es/
|
||||
|
||||
# Video scraping pipeline (kept locally for reruns, not committed)
|
||||
scrape/
|
||||
*.webm
|
||||
*.mp4
|
||||
*.mkv
|
||||
|
||||
# Third-party textbook sources (not redistributable)
|
||||
*.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.
|
||||
Conjuga/Scripts/textbook/*.json
|
||||
Conjuga/Scripts/textbook/review.html
|
||||
Conjuga/Scripts/textbook/paired_vocab_llm/
|
||||
# Note: the app-bundle copies (Conjuga/Conjuga/textbook_{data,vocab}.json)
|
||||
# ARE committed so `xcodebuild` works on a fresh clone without first running
|
||||
# the pipeline. They're regenerated from the scripts when content changes.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Project rules
|
||||
|
||||
## Git
|
||||
|
||||
- **Never run `git commit` or `git push` without an explicit request from the user in the current turn.** File edits are fine; committing and pushing are not. Wait to be told.
|
||||
@@ -8,92 +8,145 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
00BEC0BDBB49198022D9852E /* WordOfDayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */; };
|
||||
04C74D9E0ED84BF785A8331C /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D232CDA43CC9218D748BA121 /* ClozeView.swift */; };
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
6262897A38854385C64EE03B /* NounFlashcardPracticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E0786770462A55664C838 /* NounFlashcardPracticeView.swift */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.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 */; };
|
||||
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 */; };
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
9F0ACDC1F4ACB1E0D331283D /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */; };
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */; };
|
||||
A651D3E1584A34472BCE53B5 /* textbook_vocab.json in Resources */ = {isa = PBXBuildFile; fileRef = 3540936F058728CFD87B1A1E /* textbook_vocab.json */; };
|
||||
A7DF435F99E66E067F2B33E1 /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D1904DF07E0A6816134CF3 /* ListeningView.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
ABBE5080E254D1D3E3465E40 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96C065B8787DEC6818E497 /* ConversationService.swift */; };
|
||||
AC0422000A8042251357FA83 /* VideoActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */; };
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F23A9C2935408B136C7C7 /* StoryGenerator.swift */; };
|
||||
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 */; };
|
||||
B48C0015BE53279B0631C2D7 /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648436F8326CF95777E2FA58 /* ChatLibraryView.swift */; };
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */; };
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */; };
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */; };
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327659ABFD524514B6D2D505 /* StoryGenerator.swift */; };
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */; };
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8B6081226847E0A0A174BC /* StoryReaderView.swift */; };
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */; };
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */; };
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3698CE7ACF148318615293E /* VocabReviewView.swift */; };
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A649B04B8B3C49419AD9219C /* ClozeView.swift */; };
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */; };
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */; };
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D535EF6988A24B47B70209A2 /* PronunciationService.swift */; };
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2179562E54E148C98219D /* ListeningView.swift */; };
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10603F454E54341AA4B9931 /* ConversationService.swift */; };
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5667AA04211A449A9150BD28 /* ChatLibraryView.swift */; };
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */; };
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */; };
|
||||
FE2F845FFC094479E790E8B1 /* VideoDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -121,26 +174,40 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* 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>"; };
|
||||
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>"; };
|
||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
||||
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
34C67DD1A1CB9B8B5A2BDCED /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||
3540936F058728CFD87B1A1E /* textbook_vocab.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = textbook_vocab.json; sourceTree = "<group>"; };
|
||||
3644B5ED77F29A65877D926A /* reflexive_verbs.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reflexive_verbs.json; sourceTree = "<group>"; };
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingRecognizer.swift; sourceTree = "<group>"; };
|
||||
39908548430FDF01D76201FB /* TextbookChapterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterView.swift; sourceTree = "<group>"; };
|
||||
3A96C065B8787DEC6818E497 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
|
||||
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
|
||||
@@ -149,42 +216,82 @@
|
||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
|
||||
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
|
||||
496D851D2D95BEA283C9FD45 /* TextbookChapterListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextbookChapterListView.swift; sourceTree = "<group>"; };
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekTestView.swift; sourceTree = "<group>"; };
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckpointExamView.swift; sourceTree = "<group>"; };
|
||||
626873572466403C0288090D /* QuizType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizType.swift; sourceTree = "<group>"; };
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
|
||||
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||
76BE2A08EC694FF784ED5575 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordStatusMetrics.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>"; };
|
||||
@@ -192,26 +299,24 @@
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudyTimerService.swift; sourceTree = "<group>"; };
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGenerator.swift; sourceTree = "<group>"; };
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryLibraryView.swift; sourceTree = "<group>"; };
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReaderView.swift; sourceTree = "<group>"; };
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryQuizView.swift; sourceTree = "<group>"; };
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryService.swift; sourceTree = "<group>"; };
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabReviewView.swift; sourceTree = "<group>"; };
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClozeView.swift; sourceTree = "<group>"; };
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExercise.swift; sourceTree = "<group>"; };
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarExerciseView.swift; sourceTree = "<group>"; };
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PronunciationService.swift; sourceTree = "<group>"; };
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListeningView.swift; sourceTree = "<group>"; };
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationService.swift; sourceTree = "<group>"; };
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLibraryView.swift; sourceTree = "<group>"; };
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureReferenceView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -228,6 +333,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */,
|
||||
362F2159FC4C6E2A3DCBF07F /* YouTubeKit in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -238,14 +344,22 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */,
|
||||
03E33DDF3CB9AA0770DDDD8D /* book_olly-vol2.json */,
|
||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */,
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */,
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */,
|
||||
BC273716CD14A99EFF8206CA /* course_data.json */,
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||
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 */,
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||
A81CA75762B08D35D5B7A44D /* Views */,
|
||||
);
|
||||
@@ -255,8 +369,9 @@
|
||||
0931AEB5B728C3A03F06A1CA /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FB5F16AFB9FAF6617FDFA35D /* DownloadedVideosView.swift */,
|
||||
1C3E36BDC2540AF2A67AEEB1 /* FeatureReferenceView.swift */,
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -274,28 +389,64 @@
|
||||
isa = PBXGroup;
|
||||
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 */,
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||
4AE3D1D8723D5C41D3501774 /* PronunciationService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
940826D9ED5C18D2C4E7B2C7 /* ReflexiveVerbStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||
713F23A9C2935408B136C7C7 /* StoryGenerator.swift */,
|
||||
4EC8C4E931AD7A1D87C490BB /* StudyTimerService.swift */,
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||
EBEEC9CC9A8C502AF5F42914 /* VerbExampleCache.swift */,
|
||||
02EB3F9305349775E0EB28B9 /* VerbExampleGenerator.swift */,
|
||||
CCC782D61A0763072E4964B6 /* VerbReviewStore.swift */,
|
||||
51FA0182FB27A06FFC703138 /* VideoDownloadService.swift */,
|
||||
841A3015E99E1E2FF19FF8EA /* VocabSessionQueue.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
CE46A302E9DE8DDEC3186862 /* WordStatusMetrics.swift */,
|
||||
AFF65B05E7CEC386F121973E /* YouTubeVideoStore.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23B49FBE9B44D8734D96625F /* Scripts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6D8FBC65B3D300DB2966E989 /* guide-enrichment */,
|
||||
);
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -308,15 +459,19 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
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 */,
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
);
|
||||
F474200CFCCCBB318720EC64 /* VocabStudyGroup.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -341,6 +496,16 @@
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
43E4D263B0AF47E401A51601 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
15AC27B1E3D332709657F20B /* StoryLibraryView.swift */,
|
||||
A7CDC5F2660A3009A3ADF048 /* StoryQuizView.swift */,
|
||||
6A48474D969CEF5F573DF09B /* StoryReaderView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4B183AB0C56BC2EC302531E7 /* ConjugaWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -360,53 +525,68 @@
|
||||
5A23E5D4EFE8E46030CA9D77 /* Practice */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
09E5143541E221C7E28B939E /* AdjectiveReviewView.swift */,
|
||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */,
|
||||
D232CDA43CC9218D748BA121 /* ClozeView.swift */,
|
||||
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */,
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */,
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */,
|
||||
20D1904DF07E0A6816134CF3 /* ListeningView.swift */,
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
E4365AC54DB1DA5D7017CB42 /* NounReviewView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
E8D95887B18216FCA71643D6 /* VocabReviewView.swift */,
|
||||
9CD612E55440D22B877EA8FE /* Books */,
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */,
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||
DFD75E32A53845A693D98F48 /* Chat */,
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||
);
|
||||
43E4D263B0AF47E401A51601 /* Stories */,
|
||||
730BD7F59F4C97D87EF98FB1 /* Vocab */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6D8FBC65B3D300DB2966E989 /* guide-enrichment */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7DE0F6354CF73BDA0CE728BA /* in */,
|
||||
C36A0F3B1A4B759412ADB4E5 /* out */,
|
||||
);
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
07F24ED76059609D6857EC97 /* GrammarExerciseView.swift */,
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.swift */,
|
||||
);
|
||||
86A57770AAE3FAC5DCA329F9 /* VideoActionsView.swift */,
|
||||
);
|
||||
path = Guide;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DFD75E32A53845A693D98F48 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5667AA04211A449A9150BD28 /* ChatLibraryView.swift */,
|
||||
FA5FE6E149F54A6BA7D01D99 /* ChatView.swift */,
|
||||
);
|
||||
path = Chat;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
950347251CC94D4A9DFF7CBC /* StoryLibraryView.swift */,
|
||||
2A8B6081226847E0A0A174BC /* StoryReaderView.swift */,
|
||||
E292A183ABB24FFE9CB719C8 /* StoryQuizView.swift */,
|
||||
);
|
||||
path = Stories;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -419,6 +599,26 @@
|
||||
path = Lyrics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8FB89F19B33894DDF27C8EC2 /* Chat */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
648436F8326CF95777E2FA58 /* ChatLibraryView.swift */,
|
||||
79576893566932D2BE207528 /* ChatView.swift */,
|
||||
);
|
||||
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 = (
|
||||
@@ -457,21 +657,27 @@
|
||||
BE5A40BAC9DD6884C58A2096 /* Course */ = {
|
||||
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 */,
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||
C36A0F3B1A4B759412ADB4E5 /* out */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Utilities;
|
||||
path = out;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||
@@ -511,6 +717,7 @@
|
||||
name = Conjuga;
|
||||
packageProductDependencies = (
|
||||
BCCBABD74CADDB118179D8E9 /* SharedModels */,
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */,
|
||||
);
|
||||
productName = Conjuga;
|
||||
productReference = 16C1F74196C3C5628953BE3F /* Conjuga.app */;
|
||||
@@ -555,7 +762,6 @@
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F011A5DA3101F6F7CA7D2D95 /* Build configuration list for PBXProject "Conjuga" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
@@ -565,9 +771,11 @@
|
||||
mainGroup = A591A3B6F1F13D23D68D7A9D;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */,
|
||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = F605D24E5EA11065FD18AF7E /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
@@ -583,8 +791,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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -597,8 +820,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 */,
|
||||
@@ -607,15 +845,28 @@
|
||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */,
|
||||
C8C3880535008764B7117049 /* DataLoader.swift in Sources */,
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */,
|
||||
90B76E34F195223580F7CCCF /* DictionaryService.swift in Sources */,
|
||||
B09F872A18F4105ABAF6CDF3 /* DownloadedVideosView.swift in Sources */,
|
||||
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 */,
|
||||
8B516215E0842189DEA0DBB1 /* GrammarExercise.swift in Sources */,
|
||||
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 */,
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||
@@ -623,13 +874,18 @@
|
||||
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 */,
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||
0D0D3B5CC128D1A1D1252282 /* PronunciationService.swift in Sources */,
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||
A4DA8CF1957A4FE161830AB2 /* ReflexiveVerbStore.swift in Sources */,
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||
@@ -637,39 +893,43 @@
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
||||
20B71403A8D305C29C73ADA2 /* StemChangeConjugationView.swift in Sources */,
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
||||
ACE9D8B3116757B5D6F0F766 /* StoryGenerator.swift in Sources */,
|
||||
61328552866DE185B15011A9 /* StoryLibraryView.swift in Sources */,
|
||||
0AD63CAED7C568590A16E879 /* StoryQuizView.swift in Sources */,
|
||||
CC886125F8ECE72D1AAD4861 /* StoryReaderView.swift in Sources */,
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
||||
5EE41911F3D17224CAB359ED /* StudyTimerService.swift in Sources */,
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||
81E4DB9F64F3FF3AB8BCB03A /* TextbookChapterListView.swift in Sources */,
|
||||
B10A324C06F0957DDE2233F8 /* TextbookChapterView.swift in Sources */,
|
||||
354631F309E625046A3A436B /* TextbookExerciseView.swift in Sources */,
|
||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||
BA3DE2DA319AA3B572C19E11 /* VerbExampleCache.swift in Sources */,
|
||||
4C577CF6B137D0A32759A169 /* VerbExampleGenerator.swift in Sources */,
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
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 */,
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */,
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||
DDF58F3899FC4B92BF6587D2 /* StudyTimerService.swift in Sources */,
|
||||
8C1E4E7F36D64EFF8D092AC8 /* StoryGenerator.swift in Sources */,
|
||||
4C2649215B81470195F38ED0 /* StoryLibraryView.swift in Sources */,
|
||||
8E3D8E8254CF4213B9D9FAD3 /* StoryReaderView.swift in Sources */,
|
||||
12D2C9311D5C4764B48B1754 /* StoryQuizView.swift in Sources */,
|
||||
8D7CA0F4496B44C28CD5EBD5 /* DictionaryService.swift in Sources */,
|
||||
3EC2A2F4B9C24B029DA49C40 /* VocabReviewView.swift in Sources */,
|
||||
53908E41767B438C8BD229CD /* ClozeView.swift in Sources */,
|
||||
65ABC39F35804C619DAB3200 /* GrammarExercise.swift in Sources */,
|
||||
B73F6EED00304B718C6FEFFA /* GrammarExerciseView.swift in Sources */,
|
||||
EA07DB964C8940F69C14DE2C /* PronunciationService.swift in Sources */,
|
||||
4DCC5CC233DE4701A12FD7EB /* ListeningView.swift in Sources */,
|
||||
35D6404C60C249D5995AD895 /* ConversationService.swift in Sources */,
|
||||
C8AF0931F7FD458C80B6EC0D /* ChatLibraryView.swift in Sources */,
|
||||
6CCC8D51F5524688A4BC5AF8 /* ChatView.swift in Sources */,
|
||||
8510085D78E248D885181E80 /* FeatureReferenceView.swift in Sources */,
|
||||
);
|
||||
9C4AFADEC6D1F21A5BBDD39B /* WordStatusMetrics.swift in Sources */,
|
||||
05D825674F6508D6D12D2156 /* YouTubeVideoStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
|
||||
@@ -941,7 +1201,23 @@
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/alexeichhorn/YouTubeKit.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.3.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
08D6313690BEE4E2F18EADC3 /* YouTubeKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = E93A551A7819E7C28E7EACDF /* XCRemoteSwiftPackageReference "YouTubeKit" */;
|
||||
productName = YouTubeKit;
|
||||
};
|
||||
4A4D7B02884EBA9ACD93F0FD /* SharedModels */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SharedModels;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "1b6ada17bf1104878f9520a6f7cb3cd84338c0da74dc3761cef075709d7df45d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "youtubekit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/alexeichhorn/YouTubeKit.git",
|
||||
"state" : {
|
||||
"revision" : "65be95dbb1dbd749499e0638871568c823822276",
|
||||
"version" : "0.4.8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -40,6 +40,9 @@ struct ConjugaApp: App {
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
@State private var studyTimer = StudyTimerService()
|
||||
@State private var dictionary = DictionaryService()
|
||||
@State private var verbExampleCache = VerbExampleCache()
|
||||
@State private var reflexiveStore = ReflexiveVerbStore()
|
||||
@State private var youtubeVideoStore = YouTubeVideoStore()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -67,14 +70,18 @@ struct ConjugaApp: App {
|
||||
let cloudConfig = ModelConfiguration(
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
for: ReviewCard.self, CourseReviewCard.self, VerbReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
TextbookExerciseAttempt.self, ExtraStudyMark.self, VocabStudyGroup.self,
|
||||
LexemeReviewCard.self, LexemeStudyGroup.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
@@ -111,6 +118,9 @@ struct ConjugaApp: App {
|
||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||
.environment(studyTimer)
|
||||
.environment(dictionary)
|
||||
.environment(verbExampleCache)
|
||||
.environment(reflexiveStore)
|
||||
.environment(youtubeVideoStore)
|
||||
.task {
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
if needsSeed {
|
||||
@@ -133,6 +143,11 @@ struct ConjugaApp: App {
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
)
|
||||
// Reset a broken streak immediately on launch so the
|
||||
// dashboard never shows a stale number even if the user
|
||||
// hasn't navigated to it yet.
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContainer.mainContext)
|
||||
progress.validateStreakIfStale(context: cloudContainer.mainContext)
|
||||
WidgetDataService.update(
|
||||
localContainer: localContainer,
|
||||
cloudContainer: cloudContainer
|
||||
@@ -204,20 +219,16 @@ struct ConjugaApp: App {
|
||||
}
|
||||
|
||||
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
||||
// Built from the single shared model list so the app and the widget
|
||||
// extension always open the store with an identical schema.
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let localConfig = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: url,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
return try ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
configurations: localConfig
|
||||
)
|
||||
return try ModelContainer(for: schema, configurations: localConfig)
|
||||
}
|
||||
|
||||
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
||||
@@ -244,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 = 3 // bump: SavedSong moved to cloud 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.
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -22,21 +26,13 @@
|
||||
<string>1</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.education</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Conjuga uses speech recognition to check your Spanish pronunciation and transcribe what you say during listening exercises.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Conjuga needs microphone access to record your voice for pronunciation practice.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -32,10 +32,21 @@ final class DailyLog {
|
||||
}
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
/// Defensive formatter: explicit POSIX locale + current timezone so date
|
||||
/// strings can never drift due to locale formatting (e.g. Arabic numerals)
|
||||
/// or implicit-zone shifts. The string format is timezone-naive
|
||||
/// `yyyy-MM-dd`, which works because we only ever compare to other
|
||||
/// strings produced by this same formatter.
|
||||
private static func makeFormatter() -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
return formatter
|
||||
}
|
||||
|
||||
static func dateString(from date: Date) -> String {
|
||||
makeFormatter().string(from: date)
|
||||
}
|
||||
|
||||
static func todayString() -> String {
|
||||
@@ -43,8 +54,6 @@ final class DailyLog {
|
||||
}
|
||||
|
||||
static func date(from string: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: string)
|
||||
makeFormatter().date(from: string)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps tense guides ↔ grammar notes for the Guide tab's cross-link chips.
|
||||
/// The forward map is the curated source; the reverse map is derived once.
|
||||
///
|
||||
/// A tense ID appears here only if at least one grammar note in
|
||||
/// `GrammarNote.allNotesIncludingGenerated` covers a concept directly tied
|
||||
/// to that tense (forms, contrast, triggers, choice). Two tenses currently
|
||||
/// have no aligned notes and don't appear: `ind_pluscuamperfecto` and
|
||||
/// `ind_preterito_anterior`.
|
||||
enum GuideCrossLinks {
|
||||
/// Tense ID → ordered grammar note IDs that go deeper on this tense.
|
||||
/// Order matters — the first chip is the most-relevant note for the
|
||||
/// tense's primary teaching point.
|
||||
static let relatedNotes: [String: [String]] = [
|
||||
"ind_presente": [
|
||||
"present-indicative-conjugation",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
"estar-gerund-progressive",
|
||||
],
|
||||
"ind_preterito": [
|
||||
"preterite-vs-imperfect",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"ind_imperfecto": [
|
||||
"preterite-vs-imperfect",
|
||||
],
|
||||
"ind_futuro": [
|
||||
"future-vs-ir-a",
|
||||
],
|
||||
"ind_perfecto": [
|
||||
"present-perfect-tense",
|
||||
],
|
||||
"ind_futuro_perfecto": [
|
||||
"future-perfect-tense",
|
||||
],
|
||||
"cond_presente": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"cond_perfecto": [
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_presente": [
|
||||
"subjunctive-triggers",
|
||||
"irregular-yo-verbs",
|
||||
"stem-changing-verbs",
|
||||
],
|
||||
"subj_imperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_imperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_pluscuamperfecto_1": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_pluscuamperfecto_2": [
|
||||
"subjunctive-triggers",
|
||||
"conditional-if-clauses",
|
||||
],
|
||||
"subj_futuro": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"subj_futuro_perfecto": [
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
"imp_afirmativo": [
|
||||
"commands-imperative",
|
||||
],
|
||||
"imp_negativo": [
|
||||
"commands-imperative",
|
||||
"subjunctive-triggers",
|
||||
],
|
||||
]
|
||||
|
||||
/// Grammar note ID → tense IDs that point at this note, ordered by the
|
||||
/// shared `TenseInfo.order` so chips appear in canonical conjugation
|
||||
/// order.
|
||||
static let relatedTenses: [String: [String]] = {
|
||||
var inverse: [String: [String]] = [:]
|
||||
for (tenseId, noteIds) in relatedNotes {
|
||||
for noteId in noteIds {
|
||||
inverse[noteId, default: []].append(tenseId)
|
||||
}
|
||||
}
|
||||
for key in inverse.keys {
|
||||
inverse[key]?.sort { lhs, rhs in
|
||||
(TenseInfo.find(lhs)?.order ?? 999) < (TenseInfo.find(rhs)?.order ?? 999)
|
||||
}
|
||||
}
|
||||
return inverse
|
||||
}()
|
||||
|
||||
static func noteIds(forTense tenseId: String) -> [String] {
|
||||
relatedNotes[tenseId] ?? []
|
||||
}
|
||||
|
||||
static func tenseIds(forNote noteId: String) -> [String] {
|
||||
relatedTenses[noteId] ?? []
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,30 @@ final class CourseReviewCard {
|
||||
self.back = back
|
||||
}
|
||||
}
|
||||
|
||||
/// SRS record for verb-level vocab practice (EN ↔ ES infinitive recall),
|
||||
/// separate from per-form `ReviewCard` so a user's vocab progress doesn't
|
||||
/// collide with conjugation form mastery.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced via fetch-or-create in
|
||||
/// `VerbReviewStore` since CloudKit forbids `@Attribute(.unique)`.
|
||||
@Model
|
||||
final class VerbReviewCard {
|
||||
var id: String = ""
|
||||
var verbId: Int = 0
|
||||
|
||||
var easeFactor: Double = 2.5
|
||||
var interval: Int = 0
|
||||
var repetitions: Int = 0
|
||||
var dueDate: Date = Date()
|
||||
var lastReviewDate: Date?
|
||||
|
||||
init(verbId: Int) {
|
||||
self.id = Self.makeId(verbId: verbId)
|
||||
self.verbId = verbId
|
||||
}
|
||||
|
||||
static func makeId(verbId: Int) -> String {
|
||||
"verb-\(verbId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ final class UserProgress {
|
||||
var selectedLevel: String = "basic"
|
||||
var showVosotros: Bool = true
|
||||
var autoFillStem: Bool = false
|
||||
var showReflexiveVerbsOnly: Bool = false
|
||||
|
||||
// Legacy CloudKit array-backed fields retained for migration compatibility.
|
||||
var enabledTenses: [String] = []
|
||||
@@ -21,6 +22,14 @@ final class UserProgress {
|
||||
var enabledTensesBlob: String = ""
|
||||
var unlockedBadgesBlob: String = ""
|
||||
|
||||
// Multi-select level + irregularity filters (Issue #26).
|
||||
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 {
|
||||
@@ -44,6 +53,44 @@ final class UserProgress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels currently enabled for practice. Multi-select per Issue #26.
|
||||
/// Setting this also syncs `selectedLevel` to the highest-ranked selection so
|
||||
/// legacy single-level consumers (widget, AI scenarios, word-of-day) stay consistent.
|
||||
var selectedVerbLevels: Set<VerbLevel> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: selectedLevelsBlob, fallback: [])
|
||||
let decoded = Set(raw.compactMap(VerbLevel.init(rawValue:)))
|
||||
if !decoded.isEmpty { return decoded }
|
||||
// Pre-migration users: treat the single selectedLevel as the set.
|
||||
if let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
return [legacy]
|
||||
}
|
||||
return []
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
selectedLevelsBlob = Self.encodeStringArray(sorted)
|
||||
selectedLevel = VerbLevel.highest(in: newValue)?.rawValue ?? VerbLevel.basic.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
/// The single representative level for callers that need one value
|
||||
/// (word-of-day widget, AI chat/story scenarios). Highest selected level.
|
||||
var primaryLevel: VerbLevel {
|
||||
VerbLevel.highest(in: selectedVerbLevels) ?? selectedVerbLevel
|
||||
}
|
||||
|
||||
var enabledIrregularCategories: Set<IrregularSpan.SpanCategory> {
|
||||
get {
|
||||
let raw = decodeStringArray(from: enabledIrregularCategoriesBlob, fallback: [])
|
||||
return Set(raw.compactMap(IrregularSpan.SpanCategory.init(rawValue:)))
|
||||
}
|
||||
set {
|
||||
let sorted = newValue.map(\.rawValue)
|
||||
enabledIrregularCategoriesBlob = Self.encodeStringArray(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
func setTenseEnabled(_ tenseId: String, enabled: Bool) {
|
||||
var values = Set(enabledTenseIDs)
|
||||
if enabled {
|
||||
@@ -54,12 +101,79 @@ final class UserProgress {
|
||||
enabledTenseIDs = values.sorted()
|
||||
}
|
||||
|
||||
func setLevelEnabled(_ level: VerbLevel, enabled: Bool) {
|
||||
var values = selectedVerbLevels
|
||||
if enabled {
|
||||
values.insert(level)
|
||||
} else {
|
||||
values.remove(level)
|
||||
}
|
||||
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 {
|
||||
values.insert(category)
|
||||
} else {
|
||||
values.remove(category)
|
||||
}
|
||||
enabledIrregularCategories = values
|
||||
}
|
||||
|
||||
func unlockBadge(_ badgeId: String) {
|
||||
var values = Set(unlockedBadgeIDs)
|
||||
values.insert(badgeId)
|
||||
unlockedBadgeIDs = values.sorted()
|
||||
}
|
||||
|
||||
/// Resets `currentStreak` to zero if more than one day has passed since
|
||||
/// the last recorded activity. Without this check the dashboard keeps
|
||||
/// displaying a stale streak number for days after the user actually
|
||||
/// stops practicing — the underlying counter only updates on the *next*
|
||||
/// practice action. Call from app launch and the dashboard's `.task`.
|
||||
@MainActor
|
||||
func validateStreakIfStale(today: Date = Date(), context: ModelContext) {
|
||||
guard !todayDate.isEmpty else { return }
|
||||
let todayString = DailyLog.dateString(from: today)
|
||||
if todayDate == todayString { return }
|
||||
guard let prevDate = DailyLog.date(from: todayDate) else { return }
|
||||
let diff = Calendar.current.dateComponents([.day], from: prevDate, to: today)
|
||||
if (diff.day ?? Int.max) > 1 && currentStreak != 0 {
|
||||
currentStreak = 0
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
func migrateLegacyStorageIfNeeded() {
|
||||
if enabledTensesBlob.isEmpty && !enabledTenses.isEmpty {
|
||||
enabledTenseIDs = enabledTenses
|
||||
@@ -67,6 +181,9 @@ final class UserProgress {
|
||||
if unlockedBadgesBlob.isEmpty && !unlockedBadges.isEmpty {
|
||||
unlockedBadgeIDs = unlockedBadges
|
||||
}
|
||||
if selectedLevelsBlob.isEmpty, let legacy = VerbLevel(rawValue: selectedLevel) {
|
||||
selectedVerbLevels = [legacy]
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeStringArray(from blob: String, fallback: [String]) -> [String] {
|
||||
@@ -86,4 +203,5 @@ final class UserProgress {
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// The user's active vocab-flashcard study set, persisted and CloudKit-synced
|
||||
/// so the same group of verbs follows them across launches and across devices.
|
||||
/// A new group is created when the previous one is fully learned.
|
||||
///
|
||||
/// CloudKit-synced; uniqueness on `id` is enforced in code (CloudKit forbids
|
||||
/// `@Attribute(.unique)`). There is one active standard group at a time.
|
||||
@Model
|
||||
final class VocabStudyGroup {
|
||||
var id: String = ""
|
||||
/// JSON-encoded `[StoredVocabEntry]` — the in-session queue (un-graduated
|
||||
/// verbs only) in order, each with its learning-step state.
|
||||
var entriesJSON: Data = Data()
|
||||
var learnedCount: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(entriesJSON: Data, learnedCount: Int) {
|
||||
self.id = Self.activeID
|
||||
self.entriesJSON = entriesJSON
|
||||
self.learnedCount = learnedCount
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
/// Single active standard-session group.
|
||||
static let activeID = "active-standard"
|
||||
|
||||
var entries: [StoredVocabEntry] {
|
||||
(try? JSONDecoder().decode([StoredVocabEntry].self, from: entriesJSON)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// One verb's persisted spot in the study group.
|
||||
struct StoredVocabEntry: Codable {
|
||||
var verbId: Int
|
||||
/// Raw value of `VocabSessionQueue.CardState`.
|
||||
var state: String
|
||||
}
|
||||
|
||||
/// Fetch / persist / clear the active study group. Operates on the cloud
|
||||
/// context so the group syncs across devices.
|
||||
struct VocabStudyGroupStore {
|
||||
let context: ModelContext
|
||||
|
||||
/// The current active group, or nil. If duplicate records exist (two
|
||||
/// devices both created one before sync settled), the newest wins.
|
||||
func activeGroup() -> VocabStudyGroup? {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
/// Write the in-progress group, creating it if needed. If duplicate records
|
||||
/// exist (two devices created a group before sync settled), the newest is
|
||||
/// updated and the rest deleted so a single record survives.
|
||||
func persist(entries: [StoredVocabEntry], learnedCount: Int) {
|
||||
let data = (try? JSONEncoder().encode(entries)) ?? Data()
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id },
|
||||
sortBy: [SortDescriptor(\VocabStudyGroup.createdAt, order: .reverse)]
|
||||
)
|
||||
let existing = (try? context.fetch(descriptor)) ?? []
|
||||
if let newest = existing.first {
|
||||
newest.entriesJSON = data
|
||||
newest.learnedCount = learnedCount
|
||||
for duplicate in existing.dropFirst() { context.delete(duplicate) }
|
||||
} else {
|
||||
context.insert(VocabStudyGroup(entriesJSON: data, learnedCount: learnedCount))
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// Remove the active group (and any duplicates) — the set is finished.
|
||||
func clear() {
|
||||
let id = VocabStudyGroup.activeID
|
||||
let descriptor = FetchDescriptor<VocabStudyGroup>(
|
||||
predicate: #Predicate<VocabStudyGroup> { $0.id == id }
|
||||
)
|
||||
for group in (try? context.fetch(descriptor)) ?? [] {
|
||||
context.delete(group)
|
||||
}
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Thin app-side wrapper around the SharedModels `AnswerGrader`. All logic
|
||||
/// lives in SharedModels so it can be unit tested.
|
||||
enum AnswerChecker {
|
||||
static func grade(userText: String, canonical: String, alternates: [String] = []) -> TextbookGrade {
|
||||
AnswerGrader.grade(userText: userText, canonical: canonical, alternates: alternates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Drives "read aloud" mode for `BookReaderView`. Wraps an
|
||||
/// `AVSpeechSynthesizer` with a queue of paragraph utterances and exposes the
|
||||
/// current paragraph/word index so the view can highlight the active word.
|
||||
///
|
||||
/// Skips vocabulary lines (`palabra = meaning`) since the synth pronounces the
|
||||
/// `=` awkwardly and the bilingual gloss is reference material, not prose.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookSpeechController: NSObject, AVSpeechSynthesizerDelegate {
|
||||
// MARK: - Observable state
|
||||
|
||||
private(set) var isReading: Bool = false
|
||||
private(set) var isPaused: Bool = false
|
||||
private(set) var currentParagraphIndex: Int? = nil
|
||||
private(set) var currentWordIndex: Int? = nil
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
var rate: Float = 0.45
|
||||
var voiceIdentifier: String? = nil
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// Built on first use, not in `init`. `AVSpeechSynthesizer()` connects to
|
||||
/// the system speech daemon, so allocating one per `BookReaderView` struct
|
||||
/// construction (SwiftUI rebuilds the struct on every parent render) is a
|
||||
/// real cost — deferring it keeps controller construction cheap.
|
||||
@ObservationIgnored
|
||||
private lazy var synthesizer: AVSpeechSynthesizer = {
|
||||
let synth = AVSpeechSynthesizer()
|
||||
synth.delegate = self
|
||||
return synth
|
||||
}()
|
||||
private var queue: [QueueEntry] = []
|
||||
private var queueCursor: Int = 0
|
||||
private var audioSessionConfigured = false
|
||||
|
||||
private struct QueueEntry {
|
||||
let paragraphIndex: Int
|
||||
let text: String
|
||||
let wordRanges: [Range<String.Index>]
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public control
|
||||
|
||||
/// Start (or restart) reading the given paragraphs. Indexes in
|
||||
/// `currentParagraphIndex` are positions in the original `paragraphs`
|
||||
/// array — vocab lines are skipped internally but the visible index space
|
||||
/// matches what the caller passed.
|
||||
func start(paragraphs: [String], from startIndex: Int = 0) {
|
||||
stop()
|
||||
configureAudioSession()
|
||||
|
||||
var entries: [QueueEntry] = []
|
||||
for (idx, p) in paragraphs.enumerated() where idx >= startIndex {
|
||||
if Self.isVocabLine(p) { continue }
|
||||
entries.append(QueueEntry(
|
||||
paragraphIndex: idx,
|
||||
text: p,
|
||||
wordRanges: Self.wordRanges(in: p)
|
||||
))
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
queue = entries
|
||||
queueCursor = 0
|
||||
isReading = true
|
||||
isPaused = false
|
||||
speakCurrent()
|
||||
}
|
||||
|
||||
/// Pause immediately (no word boundary). Use this for tap-to-define so the
|
||||
/// audio stops the moment the user taps.
|
||||
func pause() {
|
||||
guard isReading, !isPaused else { return }
|
||||
synthesizer.pauseSpeaking(at: .immediate)
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard isReading, isPaused else { return }
|
||||
synthesizer.continueSpeaking()
|
||||
isPaused = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
queue.removeAll()
|
||||
queueCursor = 0
|
||||
isReading = false
|
||||
isPaused = false
|
||||
currentParagraphIndex = nil
|
||||
currentWordIndex = nil
|
||||
deactivateAudioSession()
|
||||
}
|
||||
|
||||
// MARK: - Vocab detection + word ranges
|
||||
|
||||
/// Vocabulary entries in the book are formatted `palabra = meaning`.
|
||||
/// Reading them aloud says "palabra equals meaning" which is awkward, and
|
||||
/// they're reference material, so the read-along skips them.
|
||||
static func isVocabLine(_ paragraph: String) -> Bool {
|
||||
paragraph.contains(" = ")
|
||||
}
|
||||
|
||||
/// Word ranges that match the BookReaderView's space-split rendering —
|
||||
/// the visible word index N in a paragraph corresponds to wordRanges[N].
|
||||
static func wordRanges(in text: String) -> [Range<String.Index>] {
|
||||
var ranges: [Range<String.Index>] = []
|
||||
var i = text.startIndex
|
||||
while i < text.endIndex {
|
||||
while i < text.endIndex && text[i] == " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
guard i < text.endIndex else { break }
|
||||
let start = i
|
||||
while i < text.endIndex && text[i] != " " {
|
||||
i = text.index(after: i)
|
||||
}
|
||||
ranges.append(start..<i)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func speakCurrent() {
|
||||
guard queueCursor < queue.count else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
let entry = queue[queueCursor]
|
||||
currentParagraphIndex = entry.paragraphIndex
|
||||
currentWordIndex = nil
|
||||
|
||||
let utterance = AVSpeechUtterance(string: entry.text)
|
||||
utterance.voice = resolveVoice()
|
||||
utterance.rate = rate
|
||||
utterance.pitchMultiplier = 1.0
|
||||
utterance.postUtteranceDelay = 0.20
|
||||
synthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
private func resolveVoice() -> AVSpeechSynthesisVoice? {
|
||||
if let id = voiceIdentifier, let v = AVSpeechSynthesisVoice(identifier: id) {
|
||||
return v
|
||||
}
|
||||
return AVSpeechSynthesisVoice(language: "es-ES")
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
guard !audioSessionConfigured else { return }
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [])
|
||||
try session.setActive(true)
|
||||
audioSessionConfigured = true
|
||||
} catch {
|
||||
print("[BookSpeech] audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Release audio focus on stop so the OS hands control back to whatever
|
||||
/// app was playing before (music, podcast, etc.). Without this the
|
||||
/// session stays "active" until the app is killed.
|
||||
private func deactivateAudioSession() {
|
||||
guard audioSessionConfigured else { return }
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
print("[BookSpeech] audio session deactivation failed: \(error)")
|
||||
}
|
||||
audioSessionConfigured = false
|
||||
}
|
||||
|
||||
private func handleWillSpeakRange(_ range: NSRange) {
|
||||
guard queueCursor < queue.count else { return }
|
||||
let entry = queue[queueCursor]
|
||||
guard let stringRange = Range(range, in: entry.text) else { return }
|
||||
let lower = stringRange.lowerBound
|
||||
let idx = entry.wordRanges.firstIndex {
|
||||
$0.lowerBound <= lower && lower < $0.upperBound
|
||||
}
|
||||
if let idx, idx != currentWordIndex {
|
||||
currentWordIndex = idx
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDidFinish() {
|
||||
queueCursor += 1
|
||||
if queueCursor < queue.count {
|
||||
speakCurrent()
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVSpeechSynthesizerDelegate
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
willSpeakRangeOfSpeechString characterRange: NSRange,
|
||||
utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleWillSpeakRange(characterRange) }
|
||||
}
|
||||
|
||||
nonisolated func speechSynthesizer(
|
||||
_ synthesizer: AVSpeechSynthesizer,
|
||||
didFinish utterance: AVSpeechUtterance
|
||||
) {
|
||||
Task { @MainActor in self.handleDidFinish() }
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,18 @@ import SharedModels
|
||||
import Foundation
|
||||
|
||||
actor DataLoader {
|
||||
static let courseDataVersion = 6
|
||||
static let courseDataVersion = 9 // bump: all 19 tense guides + 36 grammar notes enriched to teacher-handout depth
|
||||
static let courseDataKey = "courseDataVersion"
|
||||
|
||||
static let textbookDataVersion = 14
|
||||
static let textbookDataKey = "textbookDataVersion"
|
||||
|
||||
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)
|
||||
@@ -15,6 +24,12 @@ actor DataLoader {
|
||||
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
|
||||
if storedVersion < courseDataVersion { return true }
|
||||
|
||||
let textbookVersion = UserDefaults.standard.integer(forKey: textbookDataKey)
|
||||
if textbookVersion < textbookDataVersion { return true }
|
||||
|
||||
let bookVersion = UserDefaults.standard.integer(forKey: bookDataKey)
|
||||
if bookVersion < bookDataVersion { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -133,6 +148,98 @@ actor DataLoader {
|
||||
|
||||
// Seed course data (uses the same mainContext so @Query sees it)
|
||||
seedCourseData(context: context)
|
||||
|
||||
// Seed textbook data — only bump the version key if the seed
|
||||
// actually inserted rows, so a missing/unparseable bundle doesn't
|
||||
// permanently lock us out of future re-seeds.
|
||||
if seedTextbookData(context: context) {
|
||||
UserDefaults.standard.set(textbookDataVersion, forKey: textbookDataKey)
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
UserDefaults.standard.set(bookDataVersion, forKey: bookDataKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed books if the version has changed or the rows are missing.
|
||||
static func refreshBooksDataIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let storedVersion = shared.integer(forKey: bookDataKey)
|
||||
let versionCurrent = storedVersion >= bookDataVersion
|
||||
|
||||
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if let existing = try? context.fetch(FetchDescriptor<Book>()) {
|
||||
for book in existing { context.delete(book) }
|
||||
}
|
||||
if let existing = try? context.fetch(FetchDescriptor<BookChapter>()) {
|
||||
for chapter in existing { context.delete(chapter) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedBooks(context: context) {
|
||||
shared.set(bookDataVersion, forKey: bookDataKey)
|
||||
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
|
||||
} else {
|
||||
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed textbook data if the version has changed OR if the rows are
|
||||
/// missing on disk. The row-count check exists because anything opening
|
||||
/// this store with a subset schema (e.g. an out-of-date widget extension)
|
||||
/// can destructively drop the rows without touching UserDefaults — so a
|
||||
/// pure version-flag trigger would leave us permanently empty.
|
||||
static func refreshTextbookDataIfNeeded(container: ModelContainer) async {
|
||||
let shared = UserDefaults.standard
|
||||
let context = ModelContext(container)
|
||||
let existingCount = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
|
||||
let versionCurrent = shared.integer(forKey: textbookDataKey) >= textbookDataVersion
|
||||
|
||||
if versionCurrent && existingCount > 0 { return }
|
||||
|
||||
if versionCurrent {
|
||||
print("Textbook data version current but store has \(existingCount) chapters — re-seeding...")
|
||||
} else {
|
||||
print("Textbook data version outdated — re-seeding...")
|
||||
}
|
||||
|
||||
// Fetch + delete individually instead of batch delete. SwiftData's
|
||||
// context.delete(model:) hits the store directly and doesn't always
|
||||
// clear the unique-constraint index before the reseed's save runs,
|
||||
// so re-inserting rows with the same .unique id can throw.
|
||||
let textbookCourseName = "Complete Spanish Step-by-Step"
|
||||
if let existing = try? context.fetch(FetchDescriptor<TextbookChapter>()) {
|
||||
for chapter in existing { context.delete(chapter) }
|
||||
}
|
||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||
predicate: #Predicate<CourseDeck> { $0.courseName == textbookCourseName }
|
||||
)
|
||||
if let decks = try? context.fetch(deckDescriptor) {
|
||||
for deck in decks { context.delete(deck) }
|
||||
}
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: textbook wipe save failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
if seedTextbookData(context: context) {
|
||||
shared.set(textbookDataVersion, forKey: textbookDataKey)
|
||||
print("Textbook data re-seeded to version \(textbookDataVersion)")
|
||||
} else {
|
||||
print("Textbook re-seed failed — leaving version key untouched so next launch retries")
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||
@@ -145,14 +252,35 @@ actor DataLoader {
|
||||
print("Course data version outdated — re-seeding...")
|
||||
let context = ModelContext(container)
|
||||
|
||||
// Delete existing course data
|
||||
// Delete existing course data + tense guides so they can be re-seeded
|
||||
// with updated bodies from the bundled conjuga_data.json.
|
||||
try? context.delete(model: VocabCard.self)
|
||||
try? context.delete(model: CourseDeck.self)
|
||||
try? context.delete(model: TenseGuide.self)
|
||||
try? context.save()
|
||||
|
||||
// Re-seed
|
||||
// Re-seed tense guides from the bundled JSON
|
||||
if let url = Bundle.main.url(forResource: "conjuga_data", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let guides = json["tenseGuides"] as? [[String: Any]] {
|
||||
for g in guides {
|
||||
guard let tenseId = g["tenseId"] as? String,
|
||||
let title = g["title"] as? String,
|
||||
let body = g["body"] as? String else { continue }
|
||||
context.insert(TenseGuide(tenseId: tenseId, title: title, body: body))
|
||||
}
|
||||
try? context.save()
|
||||
print("Re-seeded \(guides.count) tense guides")
|
||||
}
|
||||
|
||||
// Re-seed course data
|
||||
seedCourseData(context: context)
|
||||
|
||||
// Textbook's vocab decks/cards share the same CourseDeck/VocabCard
|
||||
// entities, so they were just wiped above. Reseed them.
|
||||
seedTextbookVocabDecks(context: context, courseName: "Complete Spanish Step-by-Step")
|
||||
|
||||
shared.set(courseDataVersion, forKey: courseDataKey)
|
||||
print("Course data re-seeded to version \(courseDataVersion)")
|
||||
}
|
||||
@@ -319,4 +447,408 @@ actor DataLoader {
|
||||
context.insert(reviewCard)
|
||||
return reviewCard
|
||||
}
|
||||
|
||||
// MARK: - Textbook seeding
|
||||
|
||||
@discardableResult
|
||||
private static func seedTextbookData(context: ModelContext) -> Bool {
|
||||
let url = Bundle.main.url(forResource: "textbook_data", withExtension: "json")
|
||||
?? Bundle.main.bundleURL.appendingPathComponent("textbook_data.json")
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
print("[DataLoader] textbook_data.json not bundled — skipping textbook seed")
|
||||
return false
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[DataLoader] ERROR: Could not parse textbook_data.json")
|
||||
return false
|
||||
}
|
||||
let courseName = (json["courseName"] as? String) ?? "Textbook"
|
||||
guard let chapters = json["chapters"] as? [[String: Any]] else {
|
||||
print("[DataLoader] ERROR: textbook_data.json missing chapters")
|
||||
return false
|
||||
}
|
||||
|
||||
var inserted = 0
|
||||
for ch in chapters {
|
||||
guard let id = ch["id"] as? String,
|
||||
let number = ch["number"] as? Int,
|
||||
let title = ch["title"] as? String,
|
||||
let blocksRaw = ch["blocks"] as? [[String: Any]] else { continue }
|
||||
|
||||
let part = (ch["part"] as? Int) ?? 0
|
||||
|
||||
// Normalize each block to canonical keys expected by TextbookBlock decoder.
|
||||
var normalized: [[String: Any]] = []
|
||||
var exerciseCount = 0
|
||||
var vocabTableCount = 0
|
||||
for (i, b) in blocksRaw.enumerated() {
|
||||
var out: [String: Any] = [:]
|
||||
out["index"] = i
|
||||
let kind = (b["kind"] as? String) ?? ""
|
||||
out["kind"] = kind
|
||||
switch kind {
|
||||
case "heading":
|
||||
if let level = b["level"] { out["level"] = level }
|
||||
if let text = b["text"] { out["text"] = text }
|
||||
case "paragraph":
|
||||
if let text = b["text"] { out["text"] = text }
|
||||
case "key_vocab_header":
|
||||
break
|
||||
case "vocab_table":
|
||||
vocabTableCount += 1
|
||||
if let src = b["sourceImage"] { out["sourceImage"] = src }
|
||||
if let lines = b["ocrLines"] { out["ocrLines"] = lines }
|
||||
if let conf = b["ocrConfidence"] { out["ocrConfidence"] = conf }
|
||||
// Paired Spanish→English cards from the bounding-box extractor.
|
||||
if let cards = b["cards"] as? [[String: Any]], !cards.isEmpty {
|
||||
let normalized: [[String: Any]] = cards.compactMap { c in
|
||||
guard let front = c["front"] as? String,
|
||||
let back = c["back"] as? String else { return nil }
|
||||
return ["front": front, "back": back]
|
||||
}
|
||||
if !normalized.isEmpty {
|
||||
out["cards"] = normalized
|
||||
}
|
||||
}
|
||||
case "exercise":
|
||||
exerciseCount += 1
|
||||
if let exId = b["id"] { out["exerciseId"] = exId }
|
||||
if let inst = b["instruction"] { out["instruction"] = inst }
|
||||
if let extra = b["extra"] { out["extra"] = extra }
|
||||
if let prompts = b["prompts"] { out["prompts"] = prompts }
|
||||
if let items = b["answerItems"] { out["answerItems"] = items }
|
||||
if let freeform = b["freeform"] { out["freeform"] = freeform }
|
||||
default:
|
||||
break
|
||||
}
|
||||
normalized.append(out)
|
||||
}
|
||||
|
||||
let bodyJSON: Data
|
||||
do {
|
||||
bodyJSON = try JSONSerialization.data(withJSONObject: normalized, options: [])
|
||||
} catch {
|
||||
print("[DataLoader] failed to encode chapter \(number) blocks: \(error)")
|
||||
continue
|
||||
}
|
||||
|
||||
let chapter = TextbookChapter(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
part: part,
|
||||
courseName: courseName,
|
||||
bodyJSON: bodyJSON,
|
||||
exerciseCount: exerciseCount,
|
||||
vocabTableCount: vocabTableCount
|
||||
)
|
||||
context.insert(chapter)
|
||||
inserted += 1
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: textbook chapter save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify rows actually hit the store — guards against the case where
|
||||
// save returned cleanly but no rows were persisted.
|
||||
let persisted = (try? context.fetchCount(FetchDescriptor<TextbookChapter>())) ?? 0
|
||||
guard persisted > 0 else {
|
||||
print("[DataLoader] ERROR: textbook seeded \(inserted) chapters but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
|
||||
// Seed textbook-derived vocabulary flashcards as CourseDecks so the
|
||||
// existing Course UI can surface them alongside LanGo decks.
|
||||
seedTextbookVocabDecks(context: context, courseName: courseName)
|
||||
|
||||
print("Textbook seeding complete: \(inserted) chapters inserted, \(persisted) persisted")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Books seeding
|
||||
|
||||
/// Walk the bundle for any `book_*.json` resources and seed `Book` +
|
||||
/// `BookChapter` rows from each one. Returns true when at least one row
|
||||
/// was inserted (mirrors `seedTextbookData`'s contract).
|
||||
@discardableResult
|
||||
private static func seedBooks(context: ModelContext) -> Bool {
|
||||
let bookURLs = bundledBookJSONURLs()
|
||||
guard !bookURLs.isEmpty else {
|
||||
print("[DataLoader] no book_*.json bundled — skipping book seed")
|
||||
return false
|
||||
}
|
||||
|
||||
var insertedBooks = 0
|
||||
for url in bookURLs {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
print("[DataLoader] WARN: could not read \(url.lastPathComponent)")
|
||||
continue
|
||||
}
|
||||
guard let slug = json["slug"] as? String,
|
||||
let title = json["title"] as? String,
|
||||
let chaptersRaw = json["chapters"] as? [[String: Any]] else {
|
||||
print("[DataLoader] WARN: \(url.lastPathComponent) missing required fields")
|
||||
continue
|
||||
}
|
||||
let author = (json["author"] as? String) ?? ""
|
||||
let language = (json["language"] as? String) ?? "es"
|
||||
|
||||
// Pre-computed per-book glossary, keyed by cleaned word.
|
||||
var glossary: [String: WordGloss] = [:]
|
||||
if let glossaryRaw = json["glossary"] as? [String: [String: String]] {
|
||||
for (word, fields) in glossaryRaw {
|
||||
glossary[word] = WordGloss(
|
||||
baseForm: fields["baseForm"] ?? word,
|
||||
english: fields["english"] ?? "",
|
||||
partOfSpeech: fields["partOfSpeech"] ?? "",
|
||||
gender: fields["gender"]
|
||||
)
|
||||
}
|
||||
}
|
||||
let glossaryData = (try? JSONEncoder().encode(glossary)) ?? Data()
|
||||
|
||||
let book = Book(
|
||||
slug: slug,
|
||||
title: title,
|
||||
author: author,
|
||||
language: language,
|
||||
chapterCount: chaptersRaw.count,
|
||||
accentColorHex: accentHex(forSlug: slug),
|
||||
glossaryJSON: glossaryData
|
||||
)
|
||||
context.insert(book)
|
||||
insertedBooks += 1
|
||||
|
||||
for ch in chaptersRaw {
|
||||
guard let number = ch["number"] as? Int,
|
||||
let chTitle = ch["title"] as? String else { continue }
|
||||
let paragraphsES = (ch["paragraphsES"] as? [String]) ?? []
|
||||
let paragraphsEN = (ch["paragraphsEN"] as? [String]) ?? []
|
||||
let esData = (try? JSONEncoder().encode(paragraphsES)) ?? Data()
|
||||
let enData = (try? JSONEncoder().encode(paragraphsEN)) ?? Data()
|
||||
let chapter = BookChapter(
|
||||
id: "\(slug)-ch\(number)",
|
||||
bookSlug: slug,
|
||||
number: number,
|
||||
title: chTitle,
|
||||
paragraphCount: paragraphsES.count,
|
||||
paragraphsESJSON: esData,
|
||||
paragraphsENJSON: enData
|
||||
)
|
||||
context.insert(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("[DataLoader] ERROR: book save failed: \(error)")
|
||||
return false
|
||||
}
|
||||
|
||||
let persistedBooks = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
|
||||
let persistedChapters = (try? context.fetchCount(FetchDescriptor<BookChapter>())) ?? 0
|
||||
guard persistedBooks > 0 else {
|
||||
print("[DataLoader] ERROR: seeded \(insertedBooks) books but persisted count is 0")
|
||||
return false
|
||||
}
|
||||
print("Book seeding complete: \(persistedBooks) books, \(persistedChapters) chapters")
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
/// return empty for some iOS configurations even when the resource is
|
||||
/// present, matching the same `bundleURL.appendingPathComponent` fallback
|
||||
/// used by the textbook seed.
|
||||
private static let bundledBookSlugs: [String] = [
|
||||
"olly-vol2",
|
||||
]
|
||||
|
||||
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
|
||||
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
|
||||
/// directory enumeration so newly-bundled books are picked up without a
|
||||
/// code change.
|
||||
private static func bundledBookJSONURLs() -> [URL] {
|
||||
var seen = Set<String>()
|
||||
var out: [URL] = []
|
||||
let bundle = Bundle.main
|
||||
|
||||
for slug in bundledBookSlugs {
|
||||
let filename = "book_\(slug).json"
|
||||
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
|
||||
?? bundle.bundleURL.appendingPathComponent(filename)
|
||||
if FileManager.default.fileExists(atPath: url.path),
|
||||
seen.insert(filename).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
|
||||
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
|
||||
for url in urls where url.lastPathComponent.hasPrefix("book_") {
|
||||
if seen.insert(url.lastPathComponent).inserted {
|
||||
out.append(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let names = out.map(\.lastPathComponent).joined(separator: ", ")
|
||||
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
|
||||
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
/// Deterministic accent colour for a book, derived from its slug so the
|
||||
/// cover tile has a stable colour across launches.
|
||||
private static func accentHex(forSlug slug: String) -> String {
|
||||
let palette = [
|
||||
"#7B6CF6", "#E07A5F", "#3D5A80", "#81B29A",
|
||||
"#F2CC8F", "#D4A5A5", "#5B8A72", "#A06CD5",
|
||||
]
|
||||
let hash = slug.unicodeScalars.reduce(0) { ($0 &* 31) &+ Int($1.value) }
|
||||
return palette[abs(hash) % palette.count]
|
||||
}
|
||||
|
||||
private static func seedTextbookVocabDecks(context: ModelContext, courseName: String) {
|
||||
let url = Bundle.main.url(forResource: "textbook_vocab", withExtension: "json")
|
||||
?? Bundle.main.bundleURL.appendingPathComponent("textbook_vocab.json")
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let chaptersArr = json["chapters"] as? [[String: Any]]
|
||||
else { return }
|
||||
|
||||
let courseSlug = courseName.lowercased()
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
|
||||
var deckCount = 0
|
||||
var cardCount = 0
|
||||
for chData in chaptersArr {
|
||||
guard let chNum = chData["chapter"] as? Int,
|
||||
let cards = chData["cards"] as? [[String: Any]],
|
||||
!cards.isEmpty else { continue }
|
||||
|
||||
let deckId = "textbook_\(courseSlug)_ch\(chNum)"
|
||||
let title = "Chapter \(chNum) vocabulary"
|
||||
let deck = CourseDeck(
|
||||
id: deckId,
|
||||
weekNumber: chNum,
|
||||
title: title,
|
||||
cardCount: cards.count,
|
||||
courseName: courseName,
|
||||
isReversed: false
|
||||
)
|
||||
context.insert(deck)
|
||||
deckCount += 1
|
||||
|
||||
for c in cards {
|
||||
guard let front = c["front"] as? String,
|
||||
let back = c["back"] as? String else { continue }
|
||||
let card = VocabCard(front: front, back: back, deckId: deckId)
|
||||
card.deck = deck
|
||||
context.insert(card)
|
||||
cardCount += 1
|
||||
}
|
||||
}
|
||||
try? context.save()
|
||||
print("Textbook vocab seeding complete: \(deckCount) decks, \(cardCount) cards")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Cloud-context CRUD for `ExtraStudyMark`. Uniqueness is enforced in code via
|
||||
/// fetch-or-create on `id` (CloudKit forbids `@Attribute(.unique)`).
|
||||
struct ExtraStudyStore {
|
||||
let context: ModelContext
|
||||
|
||||
private func fetchMark(id: String) -> ExtraStudyMark? {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.id == id }
|
||||
)
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
}
|
||||
|
||||
func contains(card: VocabCard) -> Bool {
|
||||
fetchMark(id: CourseCardStore.reviewKey(for: card)) != nil
|
||||
}
|
||||
|
||||
/// Toggle a mark for the given card. Returns the new "is marked" state.
|
||||
@discardableResult
|
||||
func toggle(
|
||||
card: VocabCard,
|
||||
courseName: String,
|
||||
weekNumber: Int
|
||||
) -> Bool {
|
||||
let id = CourseCardStore.reviewKey(for: card)
|
||||
if let existing = fetchMark(id: id) {
|
||||
context.delete(existing)
|
||||
try? context.save()
|
||||
return false
|
||||
}
|
||||
let mark = ExtraStudyMark(
|
||||
id: id,
|
||||
deckId: card.deckId,
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber,
|
||||
front: card.front,
|
||||
back: card.back
|
||||
)
|
||||
context.insert(mark)
|
||||
try? context.save()
|
||||
return true
|
||||
}
|
||||
|
||||
func count(courseName: String, weekNumber: Int) -> Int {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
|
||||
func countsByWeek(courseName: String) -> [Int: Int] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> { $0.courseName == courseName }
|
||||
)
|
||||
let marks = (try? context.fetch(descriptor)) ?? []
|
||||
var counts: [Int: Int] = [:]
|
||||
for mark in marks {
|
||||
counts[mark.weekNumber, default: 0] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func fetch(courseName: String, weekNumber: Int) -> [ExtraStudyMark] {
|
||||
let descriptor = FetchDescriptor<ExtraStudyMark>(
|
||||
predicate: #Predicate<ExtraStudyMark> {
|
||||
$0.courseName == courseName && $0.weekNumber == weekNumber
|
||||
},
|
||||
sortBy: [SortDescriptor(\.markedAt)]
|
||||
)
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func fetchIds(courseName: String, weekNumber: Int) -> Set<String> {
|
||||
Set(fetch(courseName: courseName, weekNumber: weekNumber).map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight context passed to `VocabFlashcardView` so the in-session star
|
||||
/// button knows which week/course to attribute a mark to.
|
||||
struct ExtraStudyMarkContext: Equatable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
@@ -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,239 @@
|
||||
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
|
||||
}
|
||||
|
||||
/// Max brand-new words to introduce per session for a POS, from its
|
||||
/// "New words per session" setting. 0 is a valid value (review-only), so
|
||||
/// "unset" is distinguished from 0 and defaults to 10. 999 means "no
|
||||
/// throttle" (fill whatever room reviews leave).
|
||||
static func newWordsPerSession(for partOfSpeech: String) -> Int {
|
||||
let key: String
|
||||
switch partOfSpeech {
|
||||
case "noun": key = "nounNewWordsPerSession"
|
||||
case "adjective": key = "adjectiveNewWordsPerSession"
|
||||
default: key = "lexemeNewWordsPerSession"
|
||||
}
|
||||
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
|
||||
return UserDefaults.standard.integer(forKey: key)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Reviews take priority: due cards fill the session first, then up to
|
||||
// `newMax` fresh words take whatever room is left (Anki-style new-card
|
||||
// throttle). 999 = no throttle (old behavior: fill the cap with fresh).
|
||||
let cap = sessionCardLimit(for: partOfSpeech)
|
||||
let newMax = newWordsPerSession(for: partOfSpeech)
|
||||
let dueTaken = Array(due.map(\.lexeme).prefix(cap))
|
||||
let remaining = cap - dueTaken.count
|
||||
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
|
||||
return dueTaken + newTaken
|
||||
}
|
||||
|
||||
/// 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)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,23 @@ import SwiftData
|
||||
|
||||
struct PracticeSettings: Sendable {
|
||||
let selectedLevel: String
|
||||
let selectedLevels: Set<String>
|
||||
let enabledTenses: Set<String>
|
||||
let enabledIrregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
let showVosotros: Bool
|
||||
let showReflexiveVerbsOnly: Bool
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
|
||||
init(progress: UserProgress?) {
|
||||
let resolved = progress?.enabledTenseIDs ?? []
|
||||
init(progress: UserProgress?, reflexiveBaseInfinitives: Set<String> = []) {
|
||||
let resolvedTenses = progress?.enabledTenseIDs ?? []
|
||||
let resolvedLevels = progress?.selectedVerbLevels ?? []
|
||||
self.selectedLevel = progress?.selectedLevel ?? VerbLevel.basic.rawValue
|
||||
self.enabledTenses = Set(resolved)
|
||||
self.selectedLevels = Set(resolvedLevels.map(\.rawValue))
|
||||
self.enabledTenses = Set(resolvedTenses)
|
||||
self.enabledIrregularCategories = progress?.enabledIrregularCategories ?? []
|
||||
self.showVosotros = progress?.showVosotros ?? true
|
||||
self.showReflexiveVerbsOnly = progress?.showReflexiveVerbsOnly ?? false
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
}
|
||||
|
||||
var selectionTenseIDs: [String] {
|
||||
@@ -36,16 +45,25 @@ struct FullTablePrompt {
|
||||
struct PracticeSessionService {
|
||||
let localContext: ModelContext
|
||||
let cloudContext: ModelContext
|
||||
let reflexiveBaseInfinitives: Set<String>
|
||||
private let referenceStore: ReferenceStore
|
||||
|
||||
init(localContext: ModelContext, cloudContext: ModelContext) {
|
||||
init(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext,
|
||||
reflexiveBaseInfinitives: Set<String> = []
|
||||
) {
|
||||
self.localContext = localContext
|
||||
self.cloudContext = cloudContext
|
||||
self.reflexiveBaseInfinitives = reflexiveBaseInfinitives
|
||||
self.referenceStore = ReferenceStore(context: localContext)
|
||||
}
|
||||
|
||||
func settings() -> PracticeSettings {
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||
PracticeSettings(
|
||||
progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext),
|
||||
reflexiveBaseInfinitives: reflexiveBaseInfinitives
|
||||
)
|
||||
}
|
||||
|
||||
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
||||
@@ -77,24 +95,114 @@ struct PracticeSessionService {
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomFullTablePrompt() -> FullTablePrompt? {
|
||||
/// Builds a Full Table prompt. `previousTenseId` / `previousEnding` describe
|
||||
/// the prompt just shown; when possible the next prompt avoids repeating the
|
||||
/// same tense back-to-back and switches the verb's ending family
|
||||
/// (-ar ⇄ -er/-ir) so consecutive rounds feel varied. Both are best-effort:
|
||||
/// if honouring them would leave no eligible combo, the constraint is
|
||||
/// dropped rather than dead-ending.
|
||||
func randomFullTablePrompt(
|
||||
previousTenseId: String? = nil,
|
||||
previousEnding: String? = nil
|
||||
) -> FullTablePrompt? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
// Full Table is testing the user's grasp of regular conjugation patterns,
|
||||
// not vocabulary recognition. Level filter is intentionally bypassed so
|
||||
// we draw from the entire verb pool — being able to conjugate `hablar`
|
||||
// regularly transfers to any other regular verb regardless of "level".
|
||||
// Irregular-category and tense filters still apply via downstream checks.
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(),
|
||||
settings: settings
|
||||
)
|
||||
guard !verbs.isEmpty else { return nil }
|
||||
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = settings.selectionTenseIDs.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
let candidateTenseIds = settings.selectionTenseIDs
|
||||
guard !candidateTenseIds.isEmpty else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
if forms.isEmpty { continue }
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: forms)
|
||||
let tenseChoices = tenseChoicesAvoidingRepeat(candidateTenseIds, previous: previousTenseId)
|
||||
let verbChoices = verbChoicesSwitchingFamily(verbs, previousEnding: previousEnding)
|
||||
let isConstrained = tenseChoices.count != candidateTenseIds.count
|
||||
|| verbChoices.count != verbs.count
|
||||
|
||||
// Best-effort: random-sample the constrained pool first so consecutive
|
||||
// prompts vary the tense and the -ar/-er-ir family.
|
||||
if isConstrained,
|
||||
let prompt = sampleFullTablePrompt(verbs: verbChoices, tenseIds: tenseChoices) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// No constraint, or honouring it found nothing quickly — sample the
|
||||
// full pool.
|
||||
if let prompt = sampleFullTablePrompt(verbs: verbs, tenseIds: candidateTenseIds) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Guarantee: if any eligible (verb, tense) combo exists in the data we
|
||||
// return one. Only return nil when the user's settings genuinely produce
|
||||
// an empty pool (so the UI can show an error state instead of a blank).
|
||||
for verb in verbs.shuffled() {
|
||||
for tenseId in candidateTenseIds.shuffled() {
|
||||
guard let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Random-sample up to 40 (verb, tense) pairs for a fully-regular combo.
|
||||
/// With ~1750 verbs and several hundred eligible combos this almost always
|
||||
/// succeeds within a handful of attempts.
|
||||
private func sampleFullTablePrompt(verbs: [Verb], tenseIds: [String]) -> FullTablePrompt? {
|
||||
guard !verbs.isEmpty, !tenseIds.isEmpty else { return nil }
|
||||
for _ in 0..<40 {
|
||||
guard let verb = verbs.randomElement(),
|
||||
let tenseId = tenseIds.randomElement(),
|
||||
let tenseInfo = TenseInfo.find(tenseId) else { continue }
|
||||
if let prompt = makePromptIfFullyRegular(verb: verb, tenseId: tenseId, tenseInfo: tenseInfo) {
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Tense ids with the previous one removed — only when more than one tense
|
||||
/// is selected and removing it still leaves a choice.
|
||||
private func tenseChoicesAvoidingRepeat(_ ids: [String], previous: String?) -> [String] {
|
||||
guard ids.count > 1, let previous else { return ids }
|
||||
let filtered = ids.filter { $0 != previous }
|
||||
return filtered.isEmpty ? ids : filtered
|
||||
}
|
||||
|
||||
/// Verbs whose ending family (-ar vs -er/-ir) differs from the previous
|
||||
/// verb's — only when both families are actually present in the pool.
|
||||
private func verbChoicesSwitchingFamily(_ verbs: [Verb], previousEnding: String?) -> [Verb] {
|
||||
guard let previousEnding else { return verbs }
|
||||
let previousIsAr = (previousEnding == "ar")
|
||||
let switched = verbs.filter { ($0.ending == "ar") != previousIsAr }
|
||||
return switched.isEmpty ? verbs : switched
|
||||
}
|
||||
|
||||
/// Returns a `FullTablePrompt` if this verb's forms in the given tense
|
||||
/// follow the regular pattern (per `FullTableEligibility`). Nil otherwise.
|
||||
private func makePromptIfFullyRegular(
|
||||
verb: Verb,
|
||||
tenseId: String,
|
||||
tenseInfo: TenseInfo
|
||||
) -> FullTablePrompt? {
|
||||
let forms = referenceStore.fetchForms(verbId: verb.id, tenseId: tenseId)
|
||||
guard !forms.isEmpty else { return nil }
|
||||
// Forms must arrive in personIndex order so the regularity array lines
|
||||
// up. `fetchForms` already sorts them, but assert for safety.
|
||||
let sorted = forms.sorted { $0.personIndex < $1.personIndex }
|
||||
let regularities = sorted.map { $0.regularity }
|
||||
guard FullTableEligibility.isFullyRegular(regularities: regularities) else { return nil }
|
||||
return FullTablePrompt(verb: verb, tenseInfo: tenseInfo, forms: sorted)
|
||||
}
|
||||
|
||||
func rate(verbId: Int, tenseId: String, personIndex: Int, quality: ReviewQuality) -> [Badge] {
|
||||
ReviewStore.recordReview(
|
||||
verbId: verbId,
|
||||
@@ -131,6 +239,27 @@ struct PracticeSessionService {
|
||||
return buildCardLoad(verb: verb, form: form)
|
||||
}
|
||||
|
||||
/// When the user has "Reflexive verbs only" enabled, restrict the allowed
|
||||
/// verb-id set to IDs whose infinitive is in the curated list.
|
||||
/// No-op otherwise.
|
||||
private func applyReflexiveFilter(to ids: Set<Int>, settings: PracticeSettings) -> Set<Int> {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return ids
|
||||
}
|
||||
let matching = ids.filter { id in
|
||||
guard let verb = referenceStore.fetchVerb(id: id) else { return false }
|
||||
return settings.reflexiveBaseInfinitives.contains(verb.infinitive.lowercased())
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
private func applyReflexiveFilter(to verbs: [Verb], settings: PracticeSettings) -> [Verb] {
|
||||
guard settings.showReflexiveVerbsOnly, !settings.reflexiveBaseInfinitives.isEmpty else {
|
||||
return verbs
|
||||
}
|
||||
return verbs.filter { settings.reflexiveBaseInfinitives.contains($0.infinitive.lowercased()) }
|
||||
}
|
||||
|
||||
private func buildCardLoad(verb: Verb, form: VerbForm) -> PracticeCardLoad {
|
||||
let spans = referenceStore.fetchSpans(
|
||||
verbId: form.verbId,
|
||||
@@ -152,7 +281,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let now = Date()
|
||||
var descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now },
|
||||
@@ -179,7 +314,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickWeakForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||
@@ -201,7 +342,15 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickIrregularForm(filter: IrregularityFilter) -> VerbForm? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
// Focus mode explicitly selects one irregular category, so the user's
|
||||
// settings-level irregular filter is deliberately skipped here.
|
||||
let allowedVerbIds = applyReflexiveFilter(
|
||||
to: referenceStore.allowedVerbIDs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: []
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
let typeRange: ClosedRange<Int>
|
||||
|
||||
switch filter {
|
||||
@@ -238,7 +387,13 @@ struct PracticeSessionService {
|
||||
private func pickCommonTenseForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let coreTenseIDs = TenseID.coreTenseIDs
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
@@ -251,7 +406,13 @@ struct PracticeSessionService {
|
||||
|
||||
private func pickRandomForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
let verbs = applyReflexiveFilter(
|
||||
to: referenceStore.fetchVerbs(
|
||||
selectedLevels: settings.selectedLevels,
|
||||
irregularCategories: settings.enabledIrregularCategories
|
||||
),
|
||||
settings: settings
|
||||
)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
|
||||
@@ -65,28 +65,26 @@ final class PronunciationService {
|
||||
|
||||
do {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
|
||||
try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers])
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
audioEngine = AVAudioEngine()
|
||||
// Use SFSpeechAudioBufferRecognitionRequest with the recognizer
|
||||
// directly — avoid AVAudioEngine entirely since it produces
|
||||
// zero-length buffers on some devices causing assertion crashes.
|
||||
request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
guard let audioEngine, let request else { return }
|
||||
guard let request else { return }
|
||||
request.shouldReportPartialResults = true
|
||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||
|
||||
// Use AVAudioEngine with the native input format
|
||||
audioEngine = AVAudioEngine()
|
||||
guard let audioEngine else { return }
|
||||
|
||||
let inputNode = audioEngine.inputNode
|
||||
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
||||
|
||||
// Validate format — 0 channels crashes installTap
|
||||
guard recordingFormat.channelCount > 0 else {
|
||||
print("[PronunciationService] invalid recording format (0 channels)")
|
||||
self.audioEngine = nil
|
||||
self.request = nil
|
||||
return
|
||||
}
|
||||
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in
|
||||
guard buffer.frameLength > 0 else { return }
|
||||
// Use nil format — lets the system pick a compatible format
|
||||
// and avoids the mDataByteSize(0) assertion from format mismatches
|
||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: nil) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,50 @@ struct ReferenceStore {
|
||||
Set(fetchVerbs(selectedLevel: selectedLevel).map(\.id))
|
||||
}
|
||||
|
||||
/// Union of data-levels for all selected user-facing levels.
|
||||
/// Empty input produces an empty result — callers decide how to handle that.
|
||||
func fetchVerbs(selectedLevels: Set<String>) -> [Verb] {
|
||||
guard !selectedLevels.isEmpty else { return [] }
|
||||
let ids = PracticeFilter.verbIDs(
|
||||
matchingLevels: selectedLevels,
|
||||
in: fetchVerbs().map { .init(id: $0.id, level: $0.level) }
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
/// Practice verb pool intersecting selected levels with selected irregular-span categories.
|
||||
/// Delegates to `PracticeFilter` so the intersection logic is unit-tested
|
||||
/// in SharedModels without a ModelContainer (Issue #26).
|
||||
func allowedVerbIDs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> Set<Int> {
|
||||
PracticeFilter.allowedVerbIDs(
|
||||
verbs: fetchVerbs().map { .init(id: $0.id, level: $0.level) },
|
||||
spans: allIrregularSlots(),
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience: full Verb objects passing both filters.
|
||||
func fetchVerbs(
|
||||
selectedLevels: Set<String>,
|
||||
irregularCategories: Set<IrregularSpan.SpanCategory>
|
||||
) -> [Verb] {
|
||||
let ids = allowedVerbIDs(
|
||||
selectedLevels: selectedLevels,
|
||||
irregularCategories: irregularCategories
|
||||
)
|
||||
return fetchVerbs().filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
private func allIrregularSlots() -> [PracticeFilter.IrregularSlot] {
|
||||
let descriptor = FetchDescriptor<IrregularSpan>()
|
||||
let spans = (try? context.fetch(descriptor)) ?? []
|
||||
return spans.map { .init(verbId: $0.verbId, category: $0.category) }
|
||||
}
|
||||
|
||||
func fetchVerb(id: Int) -> Verb? {
|
||||
let descriptor = FetchDescriptor<Verb>(predicate: #Predicate<Verb> { $0.id == id })
|
||||
return (try? context.fetch(descriptor))?.first
|
||||
@@ -50,6 +94,19 @@ struct ReferenceStore {
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Map of tenseId → conjugated forms for a verb, used to ground and
|
||||
/// validate LLM-generated example sentences.
|
||||
func conjugatedForms(verbId: Int, tenseIds: [String]) -> [String: [String]] {
|
||||
var map: [String: [String]] = [:]
|
||||
for tenseId in tenseIds {
|
||||
let forms = fetchForms(verbId: verbId, tenseId: tenseId)
|
||||
.map(\.form)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
if !forms.isEmpty { map[tenseId] = forms }
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
func fetchForm(verbId: Int, tenseId: String, personIndex: Int) -> VerbForm? {
|
||||
let descriptor = FetchDescriptor<VerbForm>(
|
||||
predicate: #Predicate<VerbForm> { form in
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Loads and queries the curated reflexive-verb list bundled with the app
|
||||
/// (Gitea issue #28). One JSON load at init; in-memory lookup thereafter.
|
||||
///
|
||||
/// `entries(for:)` returns a list because a single base infinitive may map to
|
||||
/// multiple reflexive entries — e.g., `ponerse` covers both "to put on
|
||||
/// (clothing) / to become" and "to come to an agreement (with)".
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ReflexiveVerbStore {
|
||||
|
||||
/// Process-wide accessor for services that can't use @Environment injection
|
||||
/// (e.g. PracticeSessionService called from ViewModels). Views should still
|
||||
/// prefer @Environment(ReflexiveVerbStore.self) for consistency.
|
||||
static let shared = ReflexiveVerbStore()
|
||||
|
||||
private(set) var entries: [ReflexiveVerb] = []
|
||||
private var indexByBase: [String: [ReflexiveVerb]] = [:]
|
||||
|
||||
/// Set of base infinitives present in the list. Cheap lookup for filters.
|
||||
private(set) var baseInfinitives: Set<String> = []
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// All reflexive entries whose base infinitive matches (case-insensitive).
|
||||
func entries(for baseInfinitive: String) -> [ReflexiveVerb] {
|
||||
indexByBase[baseInfinitive.lowercased()] ?? []
|
||||
}
|
||||
|
||||
/// Convenience — true when the verb's bare infinitive appears in the list.
|
||||
func isReflexive(baseInfinitive: String) -> Bool {
|
||||
baseInfinitives.contains(baseInfinitive.lowercased())
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "reflexive_verbs", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[ReflexiveVerbStore] bundled reflexive_verbs.json not found")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode([ReflexiveVerb].self, from: data)
|
||||
entries = decoded
|
||||
var index: [String: [ReflexiveVerb]] = [:]
|
||||
for entry in decoded {
|
||||
index[entry.baseInfinitive.lowercased(), default: []].append(entry)
|
||||
}
|
||||
indexByBase = index
|
||||
baseInfinitives = Set(index.keys)
|
||||
print("[ReflexiveVerbStore] loaded \(decoded.count) entries (\(baseInfinitives.count) distinct base infinitives)")
|
||||
} catch {
|
||||
print("[ReflexiveVerbStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,13 +72,13 @@ struct ReviewStore {
|
||||
return newCard
|
||||
}
|
||||
|
||||
/// Bumps the streak / "showed up today" bookkeeping without touching
|
||||
/// review-specific counters. Call from any user-initiated learning action
|
||||
/// — sending a chat message, doing an exercise, watching a curated video,
|
||||
/// looking up a word in lyrics, etc. Safe to call multiple times per day;
|
||||
/// only the first call on a fresh date moves the streak.
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
static func recordActivity(context: ModelContext, date: Date = Date()) -> UserProgress {
|
||||
let progress = fetchOrCreateUserProgress(context: context)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
@@ -97,9 +97,25 @@ struct ReviewStore {
|
||||
progress.todayCount = 0
|
||||
}
|
||||
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
try? context.save()
|
||||
return progress
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func updateProgress(
|
||||
reviewIncrement: Int,
|
||||
correctIncrement: Int,
|
||||
context: ModelContext,
|
||||
date: Date = Date()
|
||||
) -> UserProgress {
|
||||
// Bump streak / today-date first so review-specific counters land on
|
||||
// the correct day if this is the user's first action after midnight.
|
||||
let progress = recordActivity(context: context, date: date)
|
||||
let todayString = DailyLog.dateString(from: date)
|
||||
|
||||
progress.todayCount += reviewIncrement
|
||||
progress.totalReviewed += reviewIncrement
|
||||
progress.longestStreak = max(progress.longestStreak, progress.currentStreak)
|
||||
|
||||
let log = fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||
log.reviewCount += reviewIncrement
|
||||
|
||||
@@ -9,6 +9,9 @@ enum StartupCoordinator {
|
||||
static func bootstrap(localContainer: ModelContainer) async {
|
||||
await DataLoader.seedIfNeeded(container: localContainer)
|
||||
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.
|
||||
|
||||
@@ -26,12 +26,14 @@ enum StoreInspector {
|
||||
let hasZVERBFORM = tables.contains("ZVERBFORM")
|
||||
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
|
||||
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
|
||||
let hasZTEXTBOOKCHAPTER = tables.contains("ZTEXTBOOKCHAPTER")
|
||||
|
||||
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
|
||||
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
|
||||
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
|
||||
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
|
||||
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
|
||||
summary += " ZTEXTBOOKCHAPTER=\(hasZTEXTBOOKCHAPTER ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTEXTBOOKCHAPTER") : -1)"
|
||||
print(summary)
|
||||
|
||||
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// Disk-backed cache for verb example sentences (Issue #27). One JSON file
|
||||
/// in the Caches directory keyed by verb id; lazy-loaded on first access and
|
||||
/// write-through on every generation. Matches DictionaryService's disk pattern.
|
||||
///
|
||||
/// Cache eviction by the OS is acceptable because contents are regenerable.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VerbExampleCache {
|
||||
|
||||
/// Bump to invalidate every cached example set. Raised to 2 for Issue #33
|
||||
/// — examples generated before the verb-grounding/validation fix could
|
||||
/// contain sentences built on the wrong verb.
|
||||
private static let cacheVersion = 2
|
||||
|
||||
private struct CacheFile: Codable {
|
||||
var version: Int
|
||||
var entries: [String: [VerbExample]]
|
||||
}
|
||||
|
||||
private var store: [Int: [VerbExample]] = [:]
|
||||
private var isLoaded = false
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Look up cached examples for a verb; returns nil on miss.
|
||||
/// Safe to call before `loadIfNeeded()`; it triggers the disk load itself.
|
||||
func examples(for verbId: Int) -> [VerbExample]? {
|
||||
loadIfNeeded()
|
||||
return store[verbId]
|
||||
}
|
||||
|
||||
/// Store newly generated examples and persist to disk.
|
||||
func setExamples(_ examples: [VerbExample], for verbId: Int) {
|
||||
loadIfNeeded()
|
||||
store[verbId] = examples
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Disk I/O
|
||||
|
||||
private static var cacheURL: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("verb_examples.json")
|
||||
}
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard !isLoaded else { return }
|
||||
defer { isLoaded = true }
|
||||
|
||||
guard let data = try? Data(contentsOf: Self.cacheURL),
|
||||
let decoded = try? JSONDecoder().decode(CacheFile.self, from: data),
|
||||
decoded.version == Self.cacheVersion
|
||||
else {
|
||||
// Missing, unreadable, old flat format, or stale version — start
|
||||
// fresh so pre-fix examples don't linger.
|
||||
return
|
||||
}
|
||||
|
||||
// Persisted with String keys because JSON object keys are strings;
|
||||
// convert back to Int for in-memory lookup.
|
||||
var rebuilt: [Int: [VerbExample]] = [:]
|
||||
for (key, value) in decoded.entries {
|
||||
if let id = Int(key) {
|
||||
rebuilt[id] = value
|
||||
}
|
||||
}
|
||||
store = rebuilt
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let entries = Dictionary(uniqueKeysWithValues: store.map { (String($0.key), $0.value) })
|
||||
let file = CacheFile(version: Self.cacheVersion, entries: entries)
|
||||
guard let data = try? JSONEncoder().encode(file) else { return }
|
||||
try? data.write(to: Self.cacheURL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
|
||||
/// Generates a set of example sentences for a single verb, one per core tense
|
||||
/// (Issue #27). Mirrors the StoryGenerator pattern: @Generable response types,
|
||||
/// a static availability flag, and a single generate(...) entry point.
|
||||
///
|
||||
/// Issue #33: the model used to drift onto other verbs partway through the
|
||||
/// 6-example batch (a "tener" set would contain sentences built on estar / ir
|
||||
/// / deber). Two defenses now apply:
|
||||
/// 1. The prompt embeds the verb's *exact* conjugated forms per tense, so
|
||||
/// the model echoes a real form instead of recalling one.
|
||||
/// 2. Every generated sentence is validated against those forms; failures
|
||||
/// are regenerated once, and anything still wrong is dropped rather than
|
||||
/// shown.
|
||||
@MainActor
|
||||
struct VerbExampleGenerator {
|
||||
|
||||
// MARK: - Generable Types
|
||||
|
||||
@Generable
|
||||
struct GeneratedExampleSet {
|
||||
@Guide(
|
||||
description: "Six example sentences, one per tense in the exact order requested. Each sentence must actually use the target verb conjugated in that tense.",
|
||||
.count(6)
|
||||
)
|
||||
var examples: [GeneratedExample]
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedExample {
|
||||
@Guide(description: "The tense id this sentence demonstrates. Must match one of the ids provided in the prompt exactly (e.g. ind_presente).")
|
||||
var tenseId: String
|
||||
|
||||
@Guide(description: "A natural Spanish sentence, 6-14 words, that uses the target verb in the specified tense. For imperative tenses use tú or nosotros — never yo.")
|
||||
var spanish: String
|
||||
|
||||
@Guide(description: "An accurate, idiomatic English translation of the Spanish sentence.")
|
||||
var english: String
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
/// Generate one validated example per tense in `tenseIds`.
|
||||
///
|
||||
/// - Parameter formsByTense: the verb's conjugated forms keyed by tenseId
|
||||
/// (from `ReferenceStore.conjugatedForms`). Used to ground the prompt
|
||||
/// and validate the output. A tense with no forms here is accepted
|
||||
/// without validation.
|
||||
static func generate(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [VerbExample] {
|
||||
// A thrown error here (model busy, context overflow) shouldn't abort the
|
||||
// whole generation — treat it as a fully-failed batch so the retry below
|
||||
// still runs, same as the retry path's own `try?`.
|
||||
let firstPass = (try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)) ?? [:]
|
||||
|
||||
var valid: [String: VerbExample] = [:]
|
||||
var failedTenses: [String] = []
|
||||
for id in tenseIds {
|
||||
if let ex = firstPass[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
} else {
|
||||
failedTenses.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
// One focused retry — regenerate the whole batch, but only adopt the
|
||||
// results for tenses that failed the first pass.
|
||||
if !failedTenses.isEmpty {
|
||||
let retry = try? await generateBatch(
|
||||
verbInfinitive: verbInfinitive,
|
||||
verbEnglish: verbEnglish,
|
||||
tenseIds: tenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
if let retry {
|
||||
for id in failedTenses {
|
||||
if let ex = retry[id], exampleUsesVerb(ex.spanish, forms: formsByTense[id] ?? []) {
|
||||
valid[id] = ex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Requested order, dropping any tense that never produced a valid
|
||||
// sentence — better to show fewer examples than wrong ones.
|
||||
return tenseIds.compactMap { valid[$0] }
|
||||
}
|
||||
|
||||
// MARK: - Single batch call
|
||||
|
||||
private static func generateBatch(
|
||||
verbInfinitive: String,
|
||||
verbEnglish: String,
|
||||
tenseIds: [String],
|
||||
formsByTense: [String: [String]]
|
||||
) async throws -> [String: VerbExample] {
|
||||
let tenseBlock = tenseIds.compactMap { id -> String? in
|
||||
guard let info = TenseInfo.find(id) else { return nil }
|
||||
let forms = formsByTense[id] ?? []
|
||||
if forms.isEmpty {
|
||||
return "- \(id) (\(info.english))"
|
||||
}
|
||||
return "- \(id) (\(info.english)): use one of these exact conjugated forms — \(forms.joined(separator: ", "))"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish language teacher writing short example sentences for a learner.
|
||||
The learner is studying the verb "\(verbInfinitive)" (to \(verbEnglish)).
|
||||
EVERY sentence must use "\(verbInfinitive)" as its main verb, conjugated in the
|
||||
requested tense — never substitute a different verb (no estar, ir, deber, etc.
|
||||
unless the target verb itself is that verb). Each sentence must:
|
||||
- Contain one of the exact conjugated forms listed for its tense.
|
||||
- Be 6-14 words, natural and everyday.
|
||||
- Use vocabulary appropriate for intermediate learners.
|
||||
- Vary subjects and contexts across the set; do not reuse the same subject twice.
|
||||
For imperative tenses, address "tú" or "nosotros" — never "yo".
|
||||
""")
|
||||
|
||||
let prompt = """
|
||||
Write one example sentence for "\(verbInfinitive)" per tense below, in this order:
|
||||
\(tenseBlock)
|
||||
|
||||
Return one GeneratedExample per tense with the matching tenseId, spanish, and english.
|
||||
The Spanish sentence MUST contain one of the conjugated forms shown for that tense.
|
||||
"""
|
||||
|
||||
let response = try await session.respond(to: prompt, generating: GeneratedExampleSet.self)
|
||||
|
||||
// `uniquingKeysWith` defensively — the schema forces 6 examples even
|
||||
// when fewer tenses are requested, so the model may repeat a tenseId.
|
||||
return Dictionary(
|
||||
response.content.examples.map {
|
||||
($0.tenseId, VerbExample(tenseId: $0.tenseId, spanish: $0.spanish, english: $0.english))
|
||||
},
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// True when `sentence` contains at least one of `forms` as a whole word
|
||||
/// (accent- and case-insensitive). Empty `forms` → accept (can't validate).
|
||||
static func exampleUsesVerb(_ sentence: String, forms: [String]) -> Bool {
|
||||
guard !forms.isEmpty else { return true }
|
||||
let sentenceWords = foldedWords(sentence)
|
||||
let formWords = Set(forms.flatMap { foldedWords($0) })
|
||||
return !sentenceWords.isDisjoint(with: formWords)
|
||||
}
|
||||
|
||||
private static func foldedWords(_ text: String) -> Set<String> {
|
||||
let folded = text.folding(
|
||||
options: [.diacriticInsensitive, .caseInsensitive],
|
||||
locale: nil
|
||||
)
|
||||
return Set(folded.split { !$0.isLetter }.map(String.init))
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// SRS rating for verb-level vocab practice. Mirrors `CourseReviewStore` but
|
||||
/// keyed by `verbId` (the integer primary key on `Verb`).
|
||||
struct VerbReviewStore {
|
||||
let context: ModelContext
|
||||
|
||||
@discardableResult
|
||||
func fetchOrCreateReviewCard(verbId: Int) -> VerbReviewCard {
|
||||
let id = VerbReviewCard.makeId(verbId: verbId)
|
||||
let descriptor = FetchDescriptor<VerbReviewCard>(
|
||||
predicate: #Predicate<VerbReviewCard> { $0.id == id }
|
||||
)
|
||||
if let existing = (try? context.fetch(descriptor))?.first {
|
||||
return existing
|
||||
}
|
||||
let card = VerbReviewCard(verbId: verbId)
|
||||
context.insert(card)
|
||||
return card
|
||||
}
|
||||
|
||||
func rate(verbId: Int, quality: ReviewQuality) {
|
||||
let card = fetchOrCreateReviewCard(verbId: verbId)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
import AVFoundation
|
||||
import YouTubeKit
|
||||
|
||||
/// Downloads YouTube videos for offline viewing (Issue #21, updated for #30).
|
||||
///
|
||||
/// Two-path strategy since YouTube phased out high-quality progressive streams:
|
||||
/// 1. **Progressive** — single MP4 with audio+video combined (rare, ≤360p).
|
||||
/// Download directly to the final path.
|
||||
/// 2. **Adaptive** — DASH: separate video + audio tracks. Download each to
|
||||
/// temp files, then mux with AVAssetExportSession (`.passthrough` preset,
|
||||
/// no re-encoding) into the final MP4.
|
||||
///
|
||||
/// YouTubeKit returns stream URLs only; combining tracks is the app's job.
|
||||
/// See the library's README Example 3 for the progressive-only pattern.
|
||||
///
|
||||
/// **Known fragility**: YouTubeKit scrapes YouTube's private stream API and
|
||||
/// will break when YouTube changes their internal format. Streaming (iframe
|
||||
/// embed elsewhere) keeps working regardless.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class VideoDownloadService {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
/// Per-download state surfaced to the UI. `progress == nil` means show an
|
||||
/// indeterminate spinner (e.g. muxing phase).
|
||||
struct DownloadStatus: Equatable, Sendable {
|
||||
var progress: Double?
|
||||
var label: String
|
||||
}
|
||||
|
||||
enum DownloadError: Error, LocalizedError {
|
||||
case extractionFailed(String)
|
||||
case noSuitableStream
|
||||
case downloadFailed(String)
|
||||
case muxFailed(String)
|
||||
case fileWriteFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .extractionFailed(let why): "Could not extract video: \(why)"
|
||||
case .noSuitableStream: "No downloadable video+audio streams found for this video."
|
||||
case .downloadFailed(let why): "Download failed: \(why)"
|
||||
case .muxFailed(let why): "Could not combine audio and video: \(why)"
|
||||
case .fileWriteFailed(let why): "Could not save video: \(why)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-flight downloads by videoId, with a phase label and progress.
|
||||
var activeDownloads: [String: DownloadStatus] = [:]
|
||||
|
||||
static let shared = VideoDownloadService()
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
private static var videosDirectory: URL {
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return docs.appendingPathComponent("videos", isDirectory: true)
|
||||
}
|
||||
|
||||
private static var tempDirectory: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
}
|
||||
|
||||
private static func ensureDirectory() throws {
|
||||
let url = videosDirectory
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
static func fileURL(for videoId: String) -> URL {
|
||||
videosDirectory.appendingPathComponent("\(videoId).mp4")
|
||||
}
|
||||
|
||||
private static func tempURL(videoId: String, kind: String, ext: String) -> URL {
|
||||
tempDirectory.appendingPathComponent("\(videoId).\(kind).\(ext)")
|
||||
}
|
||||
|
||||
/// True if a downloaded MP4 exists for this videoId.
|
||||
static func isDownloaded(videoId: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: fileURL(for: videoId).path)
|
||||
}
|
||||
|
||||
// MARK: - Public download entry point
|
||||
|
||||
func download(
|
||||
videoId: String,
|
||||
title: String,
|
||||
into modelContext: ModelContext
|
||||
) async throws {
|
||||
guard activeDownloads[videoId] == nil else { return }
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Preparing…")
|
||||
|
||||
defer { activeDownloads.removeValue(forKey: videoId) }
|
||||
|
||||
try Self.ensureDirectory()
|
||||
|
||||
let destURL = Self.fileURL(for: videoId)
|
||||
|
||||
// Resolve streams once; decide progressive vs adaptive.
|
||||
let plan: DownloadPlan
|
||||
do {
|
||||
plan = try await Self.resolvePlan(videoId: videoId)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.extractionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
switch plan {
|
||||
case .progressive(let url):
|
||||
activeDownloads[videoId] = DownloadStatus(progress: 0, label: "Downloading…")
|
||||
try await downloadStream(from: url, to: destURL) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p,
|
||||
label: "Downloading \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case .adaptive(let videoURL, let audioURL):
|
||||
let tempVideo = Self.tempURL(videoId: videoId, kind: "video", ext: "mp4")
|
||||
let tempAudio = Self.tempURL(videoId: videoId, kind: "audio", ext: "m4a")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempVideo)
|
||||
try? FileManager.default.removeItem(at: tempAudio)
|
||||
}
|
||||
|
||||
// Phase 1 of 3: video track (0–55% of overall progress)
|
||||
try await downloadStream(from: videoURL, to: tempVideo) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: p * 0.55,
|
||||
label: "Video \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 of 3: audio track (55–80%)
|
||||
try await downloadStream(from: audioURL, to: tempAudio) { [weak self] p in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeDownloads[videoId] = DownloadStatus(
|
||||
progress: 0.55 + p * 0.25,
|
||||
label: "Audio \(Int(p * 100))%"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3 of 3: mux (indeterminate)
|
||||
activeDownloads[videoId] = DownloadStatus(progress: nil, label: "Finalizing…")
|
||||
do {
|
||||
try await Self.mux(videoURL: tempVideo, audioURL: tempAudio, to: destURL)
|
||||
} catch let e as DownloadError {
|
||||
throw e
|
||||
} catch {
|
||||
throw DownloadError.muxFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Record in SwiftData.
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: destURL.path)
|
||||
let byteCount = (attrs?[.size] as? Int) ?? 0
|
||||
let entry = DownloadedVideo(
|
||||
videoId: videoId,
|
||||
title: title,
|
||||
filename: "\(videoId).mp4",
|
||||
byteCount: byteCount
|
||||
)
|
||||
modelContext.insert(entry)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
/// Deletes the downloaded file and its SwiftData row.
|
||||
func delete(videoId: String, modelContext: ModelContext) {
|
||||
let url = Self.fileURL(for: videoId)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
let descriptor = FetchDescriptor<DownloadedVideo>(
|
||||
predicate: #Predicate<DownloadedVideo> { $0.videoId == videoId }
|
||||
)
|
||||
if let existing = try? modelContext.fetch(descriptor) {
|
||||
for entry in existing {
|
||||
modelContext.delete(entry)
|
||||
}
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total bytes used by all downloads.
|
||||
static func totalBytesUsed() -> Int {
|
||||
let url = videosDirectory
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: url, includingPropertiesForKeys: [.fileSizeKey]
|
||||
) else { return 0 }
|
||||
return contents.reduce(0) { acc, file in
|
||||
let size = (try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
return acc + size
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream resolution
|
||||
|
||||
private enum DownloadPlan {
|
||||
case progressive(URL)
|
||||
case adaptive(video: URL, audio: URL)
|
||||
}
|
||||
|
||||
/// Picks progressive MP4 if available (single download); otherwise the
|
||||
/// best adaptive MP4 video + M4A audio pair for later muxing.
|
||||
nonisolated private static func resolvePlan(videoId: String) async throws -> DownloadPlan {
|
||||
let youtube = YouTube(videoID: videoId)
|
||||
let streams = try await youtube.streams
|
||||
|
||||
// 1. Progressive path — uses the library's own recommended filter chain.
|
||||
if let progressive = streams
|
||||
.filterVideoAndAudio()
|
||||
.filter({ $0.fileExtension == .mp4 && $0.isNativelyPlayable })
|
||||
.highestResolutionStream() {
|
||||
return .progressive(progressive.url)
|
||||
}
|
||||
|
||||
// 2. Adaptive path — highest-resolution MP4 video track + highest-bitrate M4A audio.
|
||||
let videoStream = streams
|
||||
.filterVideoOnly()
|
||||
.filter { $0.fileExtension == .mp4 && $0.isNativelyPlayable }
|
||||
.highestResolutionStream()
|
||||
let audioStream = streams
|
||||
.filterAudioOnly()
|
||||
.filter {
|
||||
($0.fileExtension == .m4a || $0.fileExtension == .mp4) && $0.isNativelyPlayable
|
||||
}
|
||||
.highestAudioBitrateStream()
|
||||
|
||||
if let v = videoStream, let a = audioStream {
|
||||
return .adaptive(video: v.url, audio: a.url)
|
||||
}
|
||||
|
||||
throw DownloadError.noSuitableStream
|
||||
}
|
||||
|
||||
// MARK: - Download helper
|
||||
|
||||
nonisolated private func downloadStream(
|
||||
from url: URL,
|
||||
to dest: URL,
|
||||
onProgress: @escaping @Sendable (Double) -> Void
|
||||
) async throws {
|
||||
let delegate = DownloadProgressDelegate(onProgress: onProgress)
|
||||
do {
|
||||
let (tempURL, _) = try await URLSession.shared.download(
|
||||
for: URLRequest(url: url),
|
||||
delegate: delegate
|
||||
)
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.moveItem(at: tempURL, to: dest)
|
||||
} catch {
|
||||
throw DownloadError.downloadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Muxing
|
||||
|
||||
/// Combines a video-only file and an audio-only file into a single MP4.
|
||||
/// Uses `.passthrough` preset — no re-encoding, just container rewrite.
|
||||
/// Falls back to `.highestQuality` (which re-encodes) if passthrough
|
||||
/// rejects the composition due to codec incompatibility.
|
||||
nonisolated private static func mux(
|
||||
videoURL: URL,
|
||||
audioURL: URL,
|
||||
to outputURL: URL
|
||||
) async throws {
|
||||
let videoAsset = AVURLAsset(url: videoURL)
|
||||
let audioAsset = AVURLAsset(url: audioURL)
|
||||
|
||||
let videoTracks = try await videoAsset.loadTracks(withMediaType: .video)
|
||||
let audioTracks = try await audioAsset.loadTracks(withMediaType: .audio)
|
||||
|
||||
guard let srcVideo = videoTracks.first,
|
||||
let srcAudio = audioTracks.first else {
|
||||
throw DownloadError.muxFailed("Downloaded streams missing expected tracks")
|
||||
}
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let compVideo = composition.addMutableTrack(
|
||||
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
),
|
||||
let compAudio = composition.addMutableTrack(
|
||||
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
throw DownloadError.muxFailed("Could not create composition tracks")
|
||||
}
|
||||
|
||||
let videoDuration = try await videoAsset.load(.duration)
|
||||
let audioDuration = try await audioAsset.load(.duration)
|
||||
let duration = CMTimeMinimum(videoDuration, audioDuration)
|
||||
let range = CMTimeRange(start: .zero, duration: duration)
|
||||
|
||||
try compVideo.insertTimeRange(range, of: srcVideo, at: .zero)
|
||||
try compAudio.insertTimeRange(range, of: srcAudio, at: .zero)
|
||||
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
// Try passthrough (no re-encode) first; fall back to quality export on failure.
|
||||
let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality]
|
||||
var lastError: Error?
|
||||
|
||||
for preset in presets {
|
||||
guard let exporter = AVAssetExportSession(asset: composition, presetName: preset) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try await exporter.export(to: outputURL, as: .mp4)
|
||||
return
|
||||
} catch {
|
||||
lastError = error
|
||||
// Clean up partial output before retrying with next preset.
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw DownloadError.muxFailed(lastError?.localizedDescription ?? "Unknown export failure")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URLSession progress delegate
|
||||
|
||||
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
let onProgress: @Sendable (Double) -> Void
|
||||
|
||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
onProgress(Double(totalBytesWritten) / Double(totalBytesExpectedToWrite))
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Not used — `URLSession.download(for:delegate:)` returns the temp URL directly.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// In-session "learning steps" queue for vocab practice — the short-term
|
||||
/// scheduling layer that sits on top of the cross-session SM-2 schedule.
|
||||
///
|
||||
/// A card is requeued a relative number of positions ahead based on the
|
||||
/// rating, mirroring Anki's learning steps but position-based instead of
|
||||
/// time-based:
|
||||
/// Again → reappears 5–8 cards later
|
||||
/// Hard → reappears 7–10 cards later
|
||||
/// Good → first time: advances to `review`, reappears ~20 cards later
|
||||
/// already in review: graduates (leaves the session)
|
||||
/// Easy → graduates immediately
|
||||
///
|
||||
/// `answer` returns a `ReviewQuality` only when the card graduates — that's
|
||||
/// the single rating fed to the long-term `VerbReviewStore`. Intermediate
|
||||
/// Again/Hard presses don't touch the cross-session schedule.
|
||||
struct VocabSessionQueue {
|
||||
|
||||
enum CardState: String {
|
||||
case new // never answered this session
|
||||
case learning // answered Again/Hard at least once
|
||||
case review // answered Good once, one confirmation pass to go
|
||||
}
|
||||
|
||||
enum Rating {
|
||||
case again, hard, good, easy
|
||||
}
|
||||
|
||||
struct Entry: Identifiable {
|
||||
let id = UUID()
|
||||
let verb: Verb
|
||||
var state: CardState
|
||||
}
|
||||
|
||||
private(set) var queue: [Entry]
|
||||
private(set) var learnedCount: Int = 0
|
||||
private let originalVerbs: [Verb]
|
||||
|
||||
init(verbs: [Verb]) {
|
||||
originalVerbs = verbs
|
||||
queue = verbs.map { Entry(verb: $0, state: .new) }
|
||||
}
|
||||
|
||||
/// Resume a persisted group: rebuild the queue from saved (verb, state)
|
||||
/// pairs in order, restoring the learned count. The exact requeue
|
||||
/// positions aren't persisted — the queue order itself is what's saved.
|
||||
init(entries: [(verb: Verb, state: CardState)], learnedCount: Int) {
|
||||
originalVerbs = entries.map(\.verb)
|
||||
queue = entries.map { Entry(verb: $0.verb, state: $0.state) }
|
||||
self.learnedCount = learnedCount
|
||||
}
|
||||
|
||||
/// Current queue (un-graduated cards) in order — for persistence.
|
||||
func snapshot() -> [(verbId: Int, state: CardState)] {
|
||||
queue.map { ($0.verb.id, $0.state) }
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var current: Entry? { queue.first }
|
||||
var isComplete: Bool { queue.isEmpty }
|
||||
var remainingCount: Int { queue.count }
|
||||
|
||||
/// 0–1, climbs as cards graduate. Requeuing a card lowers it slightly but
|
||||
/// it always trends to 1 as the session drains.
|
||||
var progress: Double {
|
||||
let total = learnedCount + queue.count
|
||||
return total == 0 ? 1 : Double(learnedCount) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Answering
|
||||
|
||||
/// Apply a rating to the current card. Returns the `ReviewQuality` to
|
||||
/// record in the long-term SRS *iff* the card graduated; nil if it was
|
||||
/// requeued for more in-session practice.
|
||||
@discardableResult
|
||||
mutating func answer(_ rating: Rating) -> ReviewQuality? {
|
||||
guard !queue.isEmpty else { return nil }
|
||||
var entry = queue.removeFirst()
|
||||
|
||||
switch rating {
|
||||
// Again/Hard always (re)set the card to `.learning`. A card already in
|
||||
// `.review` therefore drops back a step — an intentional lapse, matching
|
||||
// Anki: missing a card you'd previously passed sends it back through the
|
||||
// learning steps rather than letting it graduate.
|
||||
case .again:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 5...8))
|
||||
return nil
|
||||
|
||||
case .hard:
|
||||
entry.state = .learning
|
||||
insert(entry, offset: Int.random(in: 7...10))
|
||||
return nil
|
||||
|
||||
case .good:
|
||||
if entry.state == .review {
|
||||
learnedCount += 1
|
||||
return .good
|
||||
}
|
||||
entry.state = .review
|
||||
insert(entry, offset: Int.random(in: 16...24))
|
||||
return nil
|
||||
|
||||
case .easy:
|
||||
learnedCount += 1
|
||||
return .easy
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the session from the same verb set (re-shuffled) — "Study Again".
|
||||
mutating func restart() {
|
||||
queue = originalVerbs.shuffled().map { Entry(verb: $0, state: .new) }
|
||||
learnedCount = 0
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Insert `entry` `offset` positions from the front, i.e. `offset` other
|
||||
/// cards will be shown before it reappears. Clamps to the queue's end.
|
||||
private mutating func insert(_ entry: Entry, offset: Int) {
|
||||
let idx = min(queue.count, offset)
|
||||
queue.insert(entry, at: idx)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session verb pool
|
||||
|
||||
/// Builds a vocab-practice session: level-filtered verbs ordered due-first
|
||||
/// (per the cross-session `VerbReviewCard` schedule) and capped so a single
|
||||
/// sitting is bounded — proper SRS behaviour rather than a 100+ card slog.
|
||||
enum VocabVerbPool {
|
||||
|
||||
/// Maximum verbs in one session, from the "Cards per session" setting
|
||||
/// (`vocabSessionCardLimit`). Defaults to 20 when unset; 999 means "All".
|
||||
/// Overdue cards are pulled first, then new (never-reviewed) verbs.
|
||||
static var sessionCardLimit: Int {
|
||||
let stored = UserDefaults.standard.integer(forKey: "vocabSessionCardLimit")
|
||||
return stored == 0 ? 20 : stored
|
||||
}
|
||||
|
||||
/// Max brand-new verbs to introduce per session, from the "New verbs per
|
||||
/// session" setting. 0 is valid (review-only), so "unset" defaults to 10;
|
||||
/// 999 means "no throttle". Mirrors `LexemePool.newWordsPerSession`.
|
||||
static var newWordsPerSession: Int {
|
||||
let key = "vocabNewWordsPerSession"
|
||||
guard UserDefaults.standard.object(forKey: key) != nil else { return 10 }
|
||||
return UserDefaults.standard.integer(forKey: key)
|
||||
}
|
||||
|
||||
static func sessionVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let levels = Set(progress.selectedVerbLevels.map(\.rawValue))
|
||||
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let pool = levels.isEmpty
|
||||
? store.fetchVerbs()
|
||||
: store.fetchVerbs(selectedLevels: levels)
|
||||
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let cardByVerbId = Dictionary(
|
||||
reviewCards.map { ($0.verbId, $0) },
|
||||
uniquingKeysWith: { existing, _ in existing }
|
||||
)
|
||||
|
||||
let now = Date()
|
||||
var due: [(verb: Verb, dueDate: Date)] = []
|
||||
var fresh: [Verb] = []
|
||||
for verb in pool {
|
||||
if let card = cardByVerbId[verb.id] {
|
||||
if card.dueDate <= now {
|
||||
due.append((verb, card.dueDate))
|
||||
}
|
||||
// Not yet due → intentionally skipped; that's the SRS schedule.
|
||||
} else {
|
||||
fresh.append(verb)
|
||||
}
|
||||
}
|
||||
|
||||
// Most-overdue first, then new verbs (lower rank = more common first).
|
||||
due.sort { $0.dueDate < $1.dueDate }
|
||||
fresh.sort { $0.rank < $1.rank }
|
||||
|
||||
// Reviews take priority: due verbs fill the session first, then up to
|
||||
// `newMax` fresh verbs take whatever room is left. 999 = no throttle.
|
||||
let cap = sessionCardLimit
|
||||
let newMax = newWordsPerSession
|
||||
let dueTaken = Array(due.map(\.verb).prefix(cap))
|
||||
let remaining = cap - dueTaken.count
|
||||
let newTaken = Array(fresh.prefix(min(newMax, remaining)))
|
||||
return dueTaken + newTaken
|
||||
}
|
||||
|
||||
/// Verbs the user has already studied at least once (have a
|
||||
/// `VerbReviewCard`), most-recently-studied first. Used by the
|
||||
/// "Review Learned" consolidation pass — ignores due dates and the
|
||||
/// Level filter, and is uncapped: it's a deliberate cram over
|
||||
/// everything you've learned.
|
||||
static func reviewLearnedVerbs(
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> [Verb] {
|
||||
let reviewCards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let sorted = reviewCards.sorted {
|
||||
($0.lastReviewDate ?? .distantPast) > ($1.lastReviewDate ?? .distantPast)
|
||||
}
|
||||
|
||||
let allVerbs = ReferenceStore(context: localContext).fetchVerbs()
|
||||
let byId = Dictionary(allVerbs.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
return sorted.compactMap { byId[$0.verbId] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical 6-tense set for `VerbExampleGenerator`. Its `@Generable` schema
|
||||
/// requires exactly 6 examples, so callers must pass 6 distinct tense IDs.
|
||||
enum VocabExampleTenseIds {
|
||||
static let canonical: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Status breakdown for a word type, scoped to the levels the user currently
|
||||
/// has enabled. Buckets are mutually exclusive and sum to `total` (the count
|
||||
/// of enabled-level words of that type).
|
||||
struct StatusCounts: Equatable {
|
||||
var new = 0 // never studied (no review card)
|
||||
var overdue = 0 // card due before today
|
||||
var dueToday = 0 // card due today
|
||||
var upcoming = 0 // scheduled for the future, still maturing (interval < mature)
|
||||
var learned = 0 // scheduled for the future and mature (interval >= mature)
|
||||
|
||||
var total: Int { new + overdue + dueToday + upcoming + learned }
|
||||
}
|
||||
|
||||
/// Counts words by SRS status for the Settings progress readout. Pure counting
|
||||
/// over the level-scoped reference pool + review cards — no mutation.
|
||||
enum WordStatusMetrics {
|
||||
/// Anki convention: a card with an interval of three weeks or more is
|
||||
/// considered "mature" rather than still being learned.
|
||||
private static let matureIntervalDays = 21
|
||||
|
||||
static func verbCounts(
|
||||
selectedLevels: Set<VerbLevel>,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> StatusCounts {
|
||||
let store = ReferenceStore(context: localContext)
|
||||
let levelStrings = Set(selectedLevels.map(\.rawValue))
|
||||
// Mirror `VocabVerbPool.sessionVerbs`: an empty selection means "all".
|
||||
let verbs = levelStrings.isEmpty ? store.fetchVerbs() : store.fetchVerbs(selectedLevels: levelStrings)
|
||||
|
||||
let cards = (try? cloudContext.fetch(FetchDescriptor<VerbReviewCard>())) ?? []
|
||||
let byId = Dictionary(cards.map { ($0.verbId, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
|
||||
var counts = StatusCounts()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
for verb in verbs {
|
||||
if let card = byId[verb.id] {
|
||||
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
|
||||
} else {
|
||||
counts.new += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
static func lexemeCounts(
|
||||
partOfSpeech: String,
|
||||
drillMode: String = "recall",
|
||||
selectedLevels: Set<LexemeLevel>,
|
||||
localContext: ModelContext,
|
||||
cloudContext: ModelContext
|
||||
) -> StatusCounts {
|
||||
let lexDescriptor = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { $0.partOfSpeech == partOfSpeech }
|
||||
)
|
||||
let all = (try? localContext.fetch(lexDescriptor)) ?? []
|
||||
// Scope to the enabled CEFR levels — same gate the fresh pool uses.
|
||||
let pool = all.filter { selectedLevels.contains(LexemeLevel.level(forRank: $0.frequencyRank)) }
|
||||
|
||||
let cardDescriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == partOfSpeech && $0.drillMode == drillMode
|
||||
}
|
||||
)
|
||||
let cards = (try? cloudContext.fetch(cardDescriptor)) ?? []
|
||||
let byId = Dictionary(cards.map { ($0.lexemeId, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
|
||||
var counts = StatusCounts()
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
for lexeme in pool {
|
||||
if let card = byId[lexeme.id] {
|
||||
bump(&counts, interval: card.interval, dueDate: card.dueDate, now: now, calendar: calendar)
|
||||
} else {
|
||||
counts.new += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Classify one studied card into a bucket. Due-state takes precedence over
|
||||
/// maturity: a mature card that's due still counts as overdue/due-today.
|
||||
private static func bump(
|
||||
_ counts: inout StatusCounts,
|
||||
interval: Int,
|
||||
dueDate: Date,
|
||||
now: Date,
|
||||
calendar: Calendar
|
||||
) {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let dueDay = calendar.startOfDay(for: dueDate)
|
||||
if dueDay < today {
|
||||
counts.overdue += 1
|
||||
} else if dueDay == today {
|
||||
counts.dueToday += 1
|
||||
} else if interval >= matureIntervalDays {
|
||||
counts.learned += 1
|
||||
} else {
|
||||
counts.upcoming += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Curated YouTube-video lookup for guide + grammar items (Issue #21).
|
||||
/// Loads the bundled `youtube_videos.json` at init, serves tense-guide and
|
||||
/// grammar-note videos by id. The data is static after load; `static let shared`
|
||||
/// lets services access it without environment injection.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class YouTubeVideoStore {
|
||||
|
||||
struct VideoEntry: Codable, Hashable, Sendable, Identifiable {
|
||||
let videoId: String
|
||||
let title: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
static let shared = YouTubeVideoStore()
|
||||
|
||||
private(set) var tenseVideos: [String: VideoEntry] = [:]
|
||||
private(set) var grammarVideos: [String: VideoEntry] = [:]
|
||||
|
||||
init(bundle: Bundle = .main) {
|
||||
load(from: bundle)
|
||||
}
|
||||
|
||||
/// Returns the curated video for a tense guide, or nil if unmapped.
|
||||
func video(forTenseId id: String) -> VideoEntry? {
|
||||
tenseVideos[id]
|
||||
}
|
||||
|
||||
/// Returns the curated video for a grammar note, or nil if unmapped.
|
||||
func video(forGrammarNoteId id: String) -> VideoEntry? {
|
||||
grammarVideos[id]
|
||||
}
|
||||
|
||||
/// All distinct videoIds present in the store. Useful for bulk operations
|
||||
/// like "download all" or cache cleanup.
|
||||
var allVideoIds: Set<String> {
|
||||
Set(tenseVideos.values.map(\.videoId)).union(grammarVideos.values.map(\.videoId))
|
||||
}
|
||||
|
||||
private func load(from bundle: Bundle) {
|
||||
guard let url = bundle.url(forResource: "youtube_videos", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url) else {
|
||||
print("[YouTubeVideoStore] bundled youtube_videos.json not found")
|
||||
return
|
||||
}
|
||||
struct Root: Decodable {
|
||||
let tenseGuides: [String: VideoEntry]
|
||||
let grammarNotes: [String: VideoEntry]
|
||||
}
|
||||
do {
|
||||
let root = try JSONDecoder().decode(Root.self, from: data)
|
||||
tenseVideos = root.tenseGuides
|
||||
grammarVideos = root.grammarNotes
|
||||
print("[YouTubeVideoStore] loaded \(tenseVideos.count) tense + \(grammarVideos.count) grammar entries")
|
||||
} catch {
|
||||
print("[YouTubeVideoStore] decode failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,11 @@ final class PracticeViewModel {
|
||||
currentSpans = []
|
||||
hasCards = true
|
||||
isLoading = true
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: localContext,
|
||||
cloudContext: cloudContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
|
||||
clearCurrentCard()
|
||||
hasCards = false
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -585,6 +585,7 @@ struct CourseQuizView: View {
|
||||
)
|
||||
cloudModelContext.insert(result)
|
||||
try? cloudModelContext.save()
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,18 @@ import SwiftData
|
||||
struct CourseView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
||||
@Query(sort: \TextbookChapter.number) private var textbookChapters: [TextbookChapter]
|
||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||
@State private var testResults: [TestResult] = []
|
||||
@State private var extraStudyCounts: [Int: Int] = [:]
|
||||
|
||||
private var textbookCourses: [String] {
|
||||
Array(Set(textbookChapters.map(\.courseName))).sorted()
|
||||
}
|
||||
|
||||
private var activeCourseIsTextbook: Bool {
|
||||
textbookCourses.contains(activeCourse)
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -47,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: "")
|
||||
@@ -62,6 +77,32 @@ struct CourseView: View {
|
||||
description: Text("Course data is loading...")
|
||||
)
|
||||
} else {
|
||||
// Textbook entry (shown above course picker when available)
|
||||
if !textbookCourses.isEmpty {
|
||||
Section {
|
||||
ForEach(textbookCourses, id: \.self) { name in
|
||||
NavigationLink(value: TextbookDestination(courseName: name)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "book.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.indigo)
|
||||
.frame(width: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read chapters, do exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Textbook")
|
||||
}
|
||||
}
|
||||
|
||||
// Course picker
|
||||
if courseNames.count > 1 {
|
||||
Section {
|
||||
@@ -80,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) {
|
||||
@@ -138,6 +201,28 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra Study row — only when there are marks for this week
|
||||
if !activeCourseIsTextbook, let markCount = extraStudyCounts[week], markCount > 0 {
|
||||
NavigationLink(value: ExtraStudyDestination(courseName: activeCourse, weekNumber: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.yellow)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Extra Study")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(markCount) marked card\(markCount == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -145,22 +230,58 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||
.onAppear(perform: loadTestResults)
|
||||
.onAppear {
|
||||
loadTestResults()
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.onChange(of: activeCourse) { _, _ in
|
||||
loadExtraStudyCounts()
|
||||
}
|
||||
.navigationDestination(for: CourseDeck.self) { deck in
|
||||
DeckStudyView(deck: deck)
|
||||
}
|
||||
.navigationDestination(for: ExtraStudyDestination.self) { dest in
|
||||
ExtraStudyView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: WeekTestDestination.self) { dest in
|
||||
WeekTestView(courseName: dest.courseName, weekNumber: dest.weekNumber)
|
||||
}
|
||||
.navigationDestination(for: CheckpointDestination.self) { dest in
|
||||
CheckpointExamView(courseName: dest.courseName, throughWeek: dest.throughWeek)
|
||||
}
|
||||
.navigationDestination(for: TextbookDestination.self) { dest in
|
||||
TextbookChapterListView(courseName: dest.courseName)
|
||||
}
|
||||
.navigationDestination(for: TextbookChapter.self) { chapter in
|
||||
TextbookChapterView(chapter: chapter)
|
||||
}
|
||||
.navigationDestination(for: TextbookExerciseDestination.self) { dest in
|
||||
textbookExerciseView(for: dest)
|
||||
}
|
||||
.navigationDestination(for: CourseMaterialDestination.self) { dest in
|
||||
CourseMaterialView(weekNumber: dest.weekNumber, courseName: dest.courseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func textbookExerciseView(for dest: TextbookExerciseDestination) -> some View {
|
||||
if let chapter = textbookChapters.first(where: { $0.id == dest.chapterId }) {
|
||||
TextbookExerciseView(chapter: chapter, blockIndex: dest.blockIndex)
|
||||
} else {
|
||||
ContentUnavailableView("Exercise unavailable", systemImage: "questionmark.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTestResults() {
|
||||
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||
}
|
||||
|
||||
private func loadExtraStudyCounts() {
|
||||
guard !activeCourse.isEmpty else { return }
|
||||
extraStudyCounts = ExtraStudyStore(context: cloudModelContext)
|
||||
.countsByWeek(courseName: activeCourse)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
@@ -175,6 +296,15 @@ struct CheckpointDestination: Hashable {
|
||||
let throughWeek: Int
|
||||
}
|
||||
|
||||
struct TextbookDestination: Hashable {
|
||||
let courseName: String
|
||||
}
|
||||
|
||||
struct ExtraStudyDestination: Hashable {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -5,9 +5,29 @@ import SwiftData
|
||||
struct DeckStudyView: View {
|
||||
let deck: CourseDeck
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var textbookChapters: [TextbookChapter]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var isStudying = false
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var deckCards: [VocabCard] = []
|
||||
@State private var expandedConjugations: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
deck.title.localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var isTextbookDeck: Bool {
|
||||
textbookChapters.contains { $0.courseName == deck.courseName }
|
||||
}
|
||||
|
||||
private var markContext: ExtraStudyMarkContext? {
|
||||
guard !isTextbookDeck else { return nil }
|
||||
return ExtraStudyMarkContext(
|
||||
courseName: deck.courseName,
|
||||
weekNumber: deck.weekNumber
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
cardListView
|
||||
@@ -19,7 +39,12 @@ struct DeckStudyView: View {
|
||||
VocabFlashcardView(
|
||||
cards: deckCards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: { isStudying = false }
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
isStudying = false
|
||||
},
|
||||
deckTitle: deck.title,
|
||||
markContext: markContext
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -30,6 +55,24 @@ struct DeckStudyView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reversed stem-change decks have `front` as English, so prefer the
|
||||
/// Spanish side when the card is stored that way. Strip parenthetical
|
||||
/// notes and the reflexive `-se` ending for verb-table lookup.
|
||||
private func inferInfinitive(card: VocabCard) -> String {
|
||||
let raw: String
|
||||
if deck.isReversed {
|
||||
raw = card.back
|
||||
} else {
|
||||
raw = card.front
|
||||
}
|
||||
var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let paren = t.firstIndex(of: "(") {
|
||||
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if t.hasSuffix("se") && t.count > 4 { t = String(t.dropLast(2)) }
|
||||
return t
|
||||
}
|
||||
|
||||
private func loadCards() {
|
||||
let deckId = deck.id
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
@@ -107,6 +150,36 @@ struct DeckStudyView: View {
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
// Stem-change conjugation toggle
|
||||
if isStemChangingDeck {
|
||||
let verb = inferInfinitive(card: card)
|
||||
let isOpen = expandedConjugations.contains(verb)
|
||||
Button {
|
||||
withAnimation(.smooth) {
|
||||
if isOpen {
|
||||
expandedConjugations.remove(verb)
|
||||
} else {
|
||||
expandedConjugations.insert(verb)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
isOpen ? "Hide conjugation" : "Show conjugation",
|
||||
systemImage: isOpen ? "chevron.up" : "chevron.down"
|
||||
)
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.blue)
|
||||
.padding(.leading, 42)
|
||||
|
||||
if isOpen {
|
||||
StemChangeConjugationView(infinitive: verb)
|
||||
.padding(.leading, 42)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// Example sentences
|
||||
if !card.examplesES.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Study session containing only cards the user has marked for extra study,
|
||||
/// scoped to a specific (courseName, weekNumber). Resolves marks by re-hashing
|
||||
/// each VocabCard via `CourseCardStore.reviewKey` so the matching is robust to
|
||||
/// duplicate (deckId, front, back) tuples that differ in examples.
|
||||
struct ExtraStudyView: View {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var cards: [VocabCard] = []
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var loaded = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !loaded {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
} else if cards.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Marked Cards",
|
||||
systemImage: "star",
|
||||
description: Text("Tap the star on a card during study to add it here.")
|
||||
)
|
||||
} else {
|
||||
VocabFlashcardView(
|
||||
cards: cards.shuffled(),
|
||||
speechService: speechService,
|
||||
onDone: {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
dismiss()
|
||||
},
|
||||
deckTitle: "Extra Study",
|
||||
markContext: ExtraStudyMarkContext(
|
||||
courseName: courseName,
|
||||
weekNumber: weekNumber
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Extra Study · Week \(weekNumber)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { load() }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard !loaded else { return }
|
||||
let marks = ExtraStudyStore(context: cloudContext)
|
||||
.fetch(courseName: courseName, weekNumber: weekNumber)
|
||||
let markIds = Set(marks.map(\.id))
|
||||
let deckIds = Set(marks.map(\.deckId))
|
||||
|
||||
var collected: [VocabCard] = []
|
||||
for deckId in deckIds {
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
let deckCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
collected.append(contentsOf: deckCards.filter {
|
||||
markIds.contains(CourseCardStore.reviewKey(for: $0))
|
||||
})
|
||||
}
|
||||
cards = collected
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Shows the present-tense conjugation of a verb (identified by infinitive),
|
||||
/// with any irregular/stem-change spans highlighted. Designed to drop into
|
||||
/// stem-changing verb flashcards so learners can see the conjugation in-place.
|
||||
struct StemChangeConjugationView: View {
|
||||
let infinitive: String
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var rows: [ConjugationRow] = []
|
||||
|
||||
private static let personLabels = ["yo", "tú", "él/ella/Ud.", "nosotros", "vosotros", "ellos/ellas/Uds."]
|
||||
private static let tenseId = "ind_presente"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Present tense")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
if rows.isEmpty {
|
||||
Text("Conjugation not available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(rows) { row in
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(row.person)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 130, alignment: .leading)
|
||||
IrregularHighlightText(
|
||||
form: row.form,
|
||||
spans: row.spans,
|
||||
font: .callout.monospaced(),
|
||||
showLabels: false
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.blue.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
.onAppear(perform: loadForms)
|
||||
}
|
||||
|
||||
private func loadForms() {
|
||||
// Find the verb by infinitive (lowercase exact match).
|
||||
let normalized = infinitive.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let verbDescriptor = FetchDescriptor<Verb>(
|
||||
predicate: #Predicate<Verb> { $0.infinitive == normalized }
|
||||
)
|
||||
guard let verb = (try? modelContext.fetch(verbDescriptor))?.first else {
|
||||
rows = []
|
||||
return
|
||||
}
|
||||
|
||||
let verbId = verb.id
|
||||
let tenseId = Self.tenseId
|
||||
let formDescriptor = FetchDescriptor<VerbForm>(
|
||||
predicate: #Predicate<VerbForm> { $0.verbId == verbId && $0.tenseId == tenseId },
|
||||
sortBy: [SortDescriptor(\VerbForm.personIndex)]
|
||||
)
|
||||
let forms = (try? modelContext.fetch(formDescriptor)) ?? []
|
||||
|
||||
rows = forms.map { f in
|
||||
ConjugationRow(
|
||||
id: f.personIndex,
|
||||
person: Self.personLabels[safe: f.personIndex] ?? "",
|
||||
form: f.form,
|
||||
spans: f.spans ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConjugationRow: Identifiable {
|
||||
let id: Int
|
||||
let person: String
|
||||
let form: String
|
||||
let spans: [IrregularSpan]
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct TextbookChapterListView: View {
|
||||
let courseName: String
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query(sort: \TextbookChapter.number) private var allChapters: [TextbookChapter]
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var attempts: [TextbookExerciseAttempt] = []
|
||||
|
||||
private var chapters: [TextbookChapter] {
|
||||
allChapters.filter { $0.courseName == courseName }
|
||||
}
|
||||
|
||||
private var byPart: [(part: Int, chapters: [TextbookChapter])] {
|
||||
let grouped = Dictionary(grouping: chapters, by: \.part)
|
||||
return grouped.keys.sorted().map { p in
|
||||
(p, grouped[p]!.sorted { $0.number < $1.number })
|
||||
}
|
||||
}
|
||||
|
||||
private func progressFor(_ chapter: TextbookChapter) -> (correct: Int, total: Int) {
|
||||
let chNum = chapter.number
|
||||
let chAttempts = attempts.filter {
|
||||
$0.courseName == courseName && $0.chapterNumber == chNum
|
||||
}
|
||||
let total = chAttempts.reduce(0) { $0 + $1.totalCount }
|
||||
let correct = chAttempts.reduce(0) { $0 + $1.correctCount + $1.closeCount }
|
||||
return (correct, total)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if chapters.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Textbook loading",
|
||||
systemImage: "book.closed",
|
||||
description: Text("Textbook content is being prepared…")
|
||||
)
|
||||
} else {
|
||||
ForEach(byPart, id: \.part) { part, partChapters in
|
||||
Section {
|
||||
ForEach(partChapters, id: \.id) { chapter in
|
||||
NavigationLink(value: chapter) {
|
||||
chapterRow(chapter)
|
||||
}
|
||||
.accessibilityIdentifier("textbook-chapter-row-\(chapter.number)")
|
||||
}
|
||||
} header: {
|
||||
if part > 0 {
|
||||
Text("Part \(part)")
|
||||
} else {
|
||||
Text("Chapters")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Textbook")
|
||||
.onAppear(perform: loadAttempts)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func chapterRow(_ chapter: TextbookChapter) -> some View {
|
||||
let p = progressFor(chapter)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 3)
|
||||
.frame(width: 36, height: 36)
|
||||
if p.total > 0 {
|
||||
Circle()
|
||||
.trim(from: 0, to: CGFloat(p.correct) / CGFloat(p.total))
|
||||
.stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.frame(width: 36, height: 36)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
Text("\(chapter.number)")
|
||||
.font(.footnote.weight(.bold))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
if chapter.exerciseCount > 0 {
|
||||
Label("\(chapter.exerciseCount)", systemImage: "pencil.and.list.clipboard")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if chapter.vocabTableCount > 0 {
|
||||
Label("\(chapter.vocabTableCount)", systemImage: "list.bullet.rectangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if p.total > 0 {
|
||||
Text("\(p.correct)/\(p.total)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func loadAttempts() {
|
||||
attempts = (try? cloudModelContext.fetch(FetchDescriptor<TextbookExerciseAttempt>())) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TextbookChapterListView(courseName: "Complete Spanish Step-by-Step")
|
||||
}
|
||||
.modelContainer(for: [TextbookChapter.self], inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct TextbookChapterView: View {
|
||||
let chapter: TextbookChapter
|
||||
|
||||
@State private var expandedVocab: Set<Int> = []
|
||||
|
||||
private var blocks: [TextbookBlock] { chapter.blocks() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
headerView
|
||||
Divider()
|
||||
ForEach(blocks) { block in
|
||||
blockView(block)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.navigationTitle(chapter.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if chapter.part > 0 {
|
||||
Text("Part \(chapter.part)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Chapter \(chapter.number)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(chapter.title)
|
||||
.font(.largeTitle.bold())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func blockView(_ block: TextbookBlock) -> some View {
|
||||
switch block.kind {
|
||||
case .heading:
|
||||
headingView(block)
|
||||
case .paragraph:
|
||||
paragraphView(block)
|
||||
case .keyVocabHeader:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "star.fill").foregroundStyle(.orange)
|
||||
Text("Key Vocabulary")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
case .vocabTable:
|
||||
vocabTableView(block)
|
||||
case .exercise:
|
||||
exerciseLinkView(block)
|
||||
}
|
||||
}
|
||||
|
||||
private func headingView(_ block: TextbookBlock) -> some View {
|
||||
let level = block.level ?? 3
|
||||
let font: Font
|
||||
switch level {
|
||||
case 2: font = .title.bold()
|
||||
case 3: font = .title2.bold()
|
||||
case 4: font = .title3.weight(.semibold)
|
||||
default: font = .headline
|
||||
}
|
||||
return Text(stripInlineEmphasis(block.text ?? ""))
|
||||
.font(font)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
private func paragraphView(_ block: TextbookBlock) -> some View {
|
||||
Text(attributedFromMarkdownish(block.text ?? ""))
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private func vocabTableView(_ block: TextbookBlock) -> some View {
|
||||
let expanded = expandedVocab.contains(block.index)
|
||||
let cards = block.cards ?? []
|
||||
let lines = block.ocrLines ?? []
|
||||
let itemCount = cards.isEmpty ? lines.count : cards.count
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
Button {
|
||||
if expanded { expandedVocab.remove(block.index) } else { expandedVocab.insert(block.index) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: expanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption)
|
||||
Text("Vocabulary (\(itemCount) items)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if expanded {
|
||||
if cards.isEmpty {
|
||||
// Fallback: no paired cards available — show raw OCR lines.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
} else {
|
||||
vocabGrid(cards: cards)
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func vocabGrid(cards: [TextbookVocabPair]) -> some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
ForEach(Array(cards.enumerated()), id: \.offset) { _, card in
|
||||
GridRow {
|
||||
Text(card.front)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
Text(card.back)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exerciseLinkView(_ block: TextbookBlock) -> some View {
|
||||
NavigationLink(value: TextbookExerciseDestination(
|
||||
chapterId: chapter.id,
|
||||
chapterNumber: chapter.number,
|
||||
blockIndex: block.index
|
||||
)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "pencil.and.list.clipboard")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Exercise \(block.exerciseId ?? "")")
|
||||
.font(.headline)
|
||||
if let inst = block.instruction, !inst.isEmpty {
|
||||
Text(stripInlineEmphasis(inst))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Strip our ad-hoc ** / * markers from parsed text
|
||||
private func stripInlineEmphasis(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "**", with: "")
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
}
|
||||
|
||||
private func attributedFromMarkdownish(_ s: String) -> AttributedString {
|
||||
// Parser emits `**bold**` and `*italic*`. Try to render via AttributedString markdown.
|
||||
if let parsed = try? AttributedString(markdown: s, options: .init(allowsExtendedAttributes: true)) {
|
||||
return parsed
|
||||
}
|
||||
return AttributedString(stripInlineEmphasis(s))
|
||||
}
|
||||
}
|
||||
|
||||
struct TextbookExerciseDestination: Hashable {
|
||||
let chapterId: String
|
||||
let chapterNumber: Int
|
||||
let blockIndex: Int
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TextbookChapterView(chapter: TextbookChapter(
|
||||
id: "ch1",
|
||||
number: 1,
|
||||
title: "Sample",
|
||||
part: 1,
|
||||
courseName: "Preview",
|
||||
bodyJSON: Data(),
|
||||
exerciseCount: 0,
|
||||
vocabTableCount: 0
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import PencilKit
|
||||
|
||||
/// Interactive fill-in-the-blank view for one textbook exercise.
|
||||
/// Supports keyboard typing OR Apple Pencil handwriting input per prompt.
|
||||
struct TextbookExerciseView: View {
|
||||
let chapter: TextbookChapter
|
||||
let blockIndex: Int
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var answers: [Int: String] = [:]
|
||||
@State private var drawings: [Int: PKDrawing] = [:]
|
||||
@State private var grades: [Int: TextbookGrade] = [:]
|
||||
@State private var inputMode: InputMode = .keyboard
|
||||
@State private var activePencilPromptNumber: Int?
|
||||
@State private var isRecognizing = false
|
||||
@State private var isChecked = false
|
||||
@State private var recognizedTextForActive: String = ""
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
enum InputMode: String {
|
||||
case keyboard
|
||||
case pencil
|
||||
}
|
||||
|
||||
private var block: TextbookBlock? {
|
||||
chapter.blocks().first { $0.index == blockIndex }
|
||||
}
|
||||
|
||||
private var answerByNumber: [Int: TextbookAnswerItem] {
|
||||
guard let items = block?.answerItems else { return [:] }
|
||||
var out: [Int: TextbookAnswerItem] = [:]
|
||||
for it in items {
|
||||
out[it.number] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let b = block {
|
||||
headerView(b)
|
||||
inputModePicker
|
||||
exerciseBody(b)
|
||||
checkButton(b)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Exercise not found",
|
||||
systemImage: "questionmark.circle"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Exercise \(block?.exerciseId ?? "")")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadPreviousAttempt)
|
||||
}
|
||||
|
||||
private func headerView(_ b: TextbookBlock) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Chapter \(chapter.number): \(chapter.title)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Exercise \(b.exerciseId ?? "")")
|
||||
.font(.title2.bold())
|
||||
if let inst = b.instruction, !inst.isEmpty {
|
||||
Text(stripInlineEmphasis(inst))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let extra = b.extra, !extra.isEmpty {
|
||||
ForEach(Array(extra.enumerated()), id: \.offset) { _, e in
|
||||
Text(stripInlineEmphasis(e))
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inputModePicker: some View {
|
||||
Picker("Input mode", selection: $inputMode) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(InputMode.keyboard)
|
||||
Label("Pencil", systemImage: "pencil.tip").tag(InputMode.pencil)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
private func exerciseBody(_ b: TextbookBlock) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if b.freeform == true {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Freeform exercise", systemImage: "text.bubble")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Answers will vary. Use this space to write your own responses; they won't be auto-checked.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
let rawPrompts = b.prompts ?? []
|
||||
let prompts = rawPrompts.isEmpty ? synthesizedPrompts(b) : rawPrompts
|
||||
if prompts.isEmpty && b.extra?.isEmpty == false {
|
||||
Text("Fill in the blanks above; answers will be graded when you tap Check.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(prompts.enumerated()), id: \.offset) { i, prompt in
|
||||
promptRow(index: i, prompt: prompt, expected: answerByNumber[i + 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When the source exercise prompts were embedded in a bitmap (common in
|
||||
/// this textbook), we have no text for each question — only the answer
|
||||
/// key. Synthesize numbered placeholders so the user still gets one input
|
||||
/// field per answer.
|
||||
private func synthesizedPrompts(_ b: TextbookBlock) -> [String] {
|
||||
guard let items = b.answerItems, !items.isEmpty else { return [] }
|
||||
return items.map { "\($0.number)." }
|
||||
}
|
||||
|
||||
private func promptRow(index: Int, prompt: String, expected: TextbookAnswerItem?) -> some View {
|
||||
let number = index + 1
|
||||
let grade = grades[number]
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let grade {
|
||||
Image(systemName: iconFor(grade))
|
||||
.foregroundStyle(colorFor(grade))
|
||||
.font(.title3)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Text(stripInlineEmphasis(prompt))
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
switch inputMode {
|
||||
case .keyboard:
|
||||
TextField("Your answer", text: binding(for: number))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.font(.body)
|
||||
.disabled(isChecked)
|
||||
case .pencil:
|
||||
pencilRow(number: number)
|
||||
}
|
||||
|
||||
if isChecked, let grade, grade != .correct, let expected {
|
||||
HStack(spacing: 6) {
|
||||
Text("Answer:")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(expected.answer)
|
||||
.font(.caption)
|
||||
if !expected.alternates.isEmpty {
|
||||
Text("(also: \(expected.alternates.joined(separator: ", ")))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(colorFor(grade))
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(backgroundFor(grade), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func pencilRow(number: Int) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HandwritingCanvas(
|
||||
drawing: bindingDrawing(for: number),
|
||||
onDrawingChanged: { recognizePencil(for: number) }
|
||||
)
|
||||
.frame(height: 100)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.separator, lineWidth: 1))
|
||||
|
||||
HStack {
|
||||
if let typed = answers[number], !typed.isEmpty {
|
||||
Text("Recognized: \(typed)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Clear") {
|
||||
drawings[number] = PKDrawing()
|
||||
answers[number] = ""
|
||||
}
|
||||
.font(.caption)
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkButton(_ b: TextbookBlock) -> some View {
|
||||
let hasAnyAnswer = answers.values.contains { !$0.isEmpty }
|
||||
let disabled = b.freeform == true || (!isChecked && !hasAnyAnswer)
|
||||
return Button {
|
||||
if isChecked {
|
||||
resetExercise()
|
||||
} else {
|
||||
checkAnswers(b)
|
||||
}
|
||||
} label: {
|
||||
Text(isChecked ? "Try again" : "Check answers")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func checkAnswers(_ b: TextbookBlock) {
|
||||
guard let prompts = b.prompts else { return }
|
||||
var newGrades: [Int: TextbookGrade] = [:]
|
||||
var states: [TextbookPromptState] = []
|
||||
for (i, _) in prompts.enumerated() {
|
||||
let number = i + 1
|
||||
let user = answers[number] ?? ""
|
||||
let expected = answerByNumber[number]
|
||||
let canonical = expected?.answer ?? ""
|
||||
let alts = expected?.alternates ?? []
|
||||
let grade: TextbookGrade
|
||||
if canonical.isEmpty {
|
||||
grade = .wrong
|
||||
} else {
|
||||
grade = AnswerChecker.grade(userText: user, canonical: canonical, alternates: alts)
|
||||
}
|
||||
newGrades[number] = grade
|
||||
states.append(TextbookPromptState(number: number, userText: user, grade: grade))
|
||||
}
|
||||
grades = newGrades
|
||||
isChecked = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
saveAttempt(states: states, exerciseId: b.exerciseId ?? "")
|
||||
}
|
||||
|
||||
private func resetExercise() {
|
||||
answers.removeAll()
|
||||
drawings.removeAll()
|
||||
grades.removeAll()
|
||||
isChecked = false
|
||||
}
|
||||
|
||||
private func recognizePencil(for number: Int) {
|
||||
guard let drawing = drawings[number], !drawing.strokes.isEmpty else { return }
|
||||
isRecognizing = true
|
||||
Task {
|
||||
let result = await HandwritingRecognizer.recognize(drawing: drawing)
|
||||
await MainActor.run {
|
||||
answers[number] = result.text
|
||||
isRecognizing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempt(states: [TextbookPromptState], exerciseId: String) {
|
||||
let attemptId = TextbookExerciseAttempt.attemptId(
|
||||
courseName: chapter.courseName,
|
||||
exerciseId: exerciseId
|
||||
)
|
||||
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
|
||||
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
|
||||
)
|
||||
let context = cloudModelContext
|
||||
let existing = (try? context.fetch(descriptor))?.first
|
||||
let attempt = existing ?? TextbookExerciseAttempt(
|
||||
id: attemptId,
|
||||
courseName: chapter.courseName,
|
||||
chapterNumber: chapter.number,
|
||||
exerciseId: exerciseId
|
||||
)
|
||||
if existing == nil { context.insert(attempt) }
|
||||
attempt.lastAttemptAt = Date()
|
||||
attempt.setPromptStates(states)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private func loadPreviousAttempt() {
|
||||
guard let b = block else { return }
|
||||
let attemptId = TextbookExerciseAttempt.attemptId(
|
||||
courseName: chapter.courseName,
|
||||
exerciseId: b.exerciseId ?? ""
|
||||
)
|
||||
let descriptor = FetchDescriptor<TextbookExerciseAttempt>(
|
||||
predicate: #Predicate<TextbookExerciseAttempt> { $0.id == attemptId }
|
||||
)
|
||||
guard let attempt = (try? cloudModelContext.fetch(descriptor))?.first else { return }
|
||||
for s in attempt.promptStates() {
|
||||
answers[s.number] = s.userText
|
||||
grades[s.number] = s.grade
|
||||
}
|
||||
isChecked = !grades.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
private func binding(for number: Int) -> Binding<String> {
|
||||
Binding(
|
||||
get: { answers[number] ?? "" },
|
||||
set: { answers[number] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func bindingDrawing(for number: Int) -> Binding<PKDrawing> {
|
||||
Binding(
|
||||
get: { drawings[number] ?? PKDrawing() },
|
||||
set: { drawings[number] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UI helpers
|
||||
|
||||
private func iconFor(_ grade: TextbookGrade) -> String {
|
||||
switch grade {
|
||||
case .correct: return "checkmark.circle.fill"
|
||||
case .close: return "circle.lefthalf.filled"
|
||||
case .wrong: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func colorFor(_ grade: TextbookGrade) -> Color {
|
||||
switch grade {
|
||||
case .correct: return .green
|
||||
case .close: return .orange
|
||||
case .wrong: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundFor(_ grade: TextbookGrade?) -> Color {
|
||||
guard let grade else { return Color.secondary.opacity(0.05) }
|
||||
switch grade {
|
||||
case .correct: return .green.opacity(0.12)
|
||||
case .close: return .orange.opacity(0.12)
|
||||
case .wrong: return .red.opacity(0.12)
|
||||
}
|
||||
}
|
||||
|
||||
private func stripInlineEmphasis(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "**", with: "")
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,23 @@ struct VocabFlashcardView: View {
|
||||
let cards: [VocabCard]
|
||||
let speechService: SpeechService
|
||||
let onDone: () -> Void
|
||||
/// Optional deck context — when present and the title indicates a stem-
|
||||
/// changing deck, each card gets an inline conjugation toggle.
|
||||
var deckTitle: String? = nil
|
||||
/// When set, a star button appears next to the speaker on reveal so the
|
||||
/// user can mark the card for extra study.
|
||||
var markContext: ExtraStudyMarkContext? = nil
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var showConjugation = false
|
||||
@State private var markedIds: Set<String> = []
|
||||
|
||||
private var isStemChangingDeck: Bool {
|
||||
(deckTitle ?? "").localizedCaseInsensitiveContains("stem changing")
|
||||
}
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -53,14 +65,48 @@ struct VocabFlashcardView: View {
|
||||
.font(.title.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
speechService.speak(card.front)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
|
||||
if markContext != nil {
|
||||
Button {
|
||||
toggleMark()
|
||||
} label: {
|
||||
Image(systemName: currentIsMarked ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.padding(12)
|
||||
.foregroundStyle(currentIsMarked ? .yellow : .secondary)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel(currentIsMarked ? "Unmark for extra study" : "Mark for extra study")
|
||||
}
|
||||
}
|
||||
|
||||
if isStemChangingDeck {
|
||||
Button {
|
||||
withAnimation(.smooth) { showConjugation.toggle() }
|
||||
} label: {
|
||||
Label(
|
||||
showConjugation ? "Hide conjugation" : "Show conjugation",
|
||||
systemImage: showConjugation ? "chevron.up" : "chevron.down"
|
||||
)
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
|
||||
if showConjugation {
|
||||
StemChangeConjugationView(infinitive: stripToInfinitive(card.front))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
}
|
||||
.transition(.blurReplace)
|
||||
} else {
|
||||
@@ -111,6 +157,7 @@ struct VocabFlashcardView: View {
|
||||
guard currentIndex > 0 else { return }
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex -= 1
|
||||
}
|
||||
} label: {
|
||||
@@ -125,6 +172,7 @@ struct VocabFlashcardView: View {
|
||||
Button {
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex += 1
|
||||
}
|
||||
} label: {
|
||||
@@ -165,6 +213,34 @@ struct VocabFlashcardView: View {
|
||||
}
|
||||
.animation(.smooth, value: isRevealed)
|
||||
.animation(.smooth, value: currentIndex)
|
||||
.task { loadMarks() }
|
||||
}
|
||||
|
||||
private var currentIsMarked: Bool {
|
||||
guard let card = currentCard else { return false }
|
||||
return markedIds.contains(CourseCardStore.reviewKey(for: card))
|
||||
}
|
||||
|
||||
private func loadMarks() {
|
||||
guard let ctx = markContext else { return }
|
||||
markedIds = ExtraStudyStore(context: cloudModelContext)
|
||||
.fetchIds(courseName: ctx.courseName, weekNumber: ctx.weekNumber)
|
||||
}
|
||||
|
||||
private func toggleMark() {
|
||||
guard let card = currentCard, let ctx = markContext else { return }
|
||||
let store = ExtraStudyStore(context: cloudModelContext)
|
||||
let isNowMarked = store.toggle(
|
||||
card: card,
|
||||
courseName: ctx.courseName,
|
||||
weekNumber: ctx.weekNumber
|
||||
)
|
||||
let key = CourseCardStore.reviewKey(for: card)
|
||||
if isNowMarked {
|
||||
markedIds.insert(key)
|
||||
} else {
|
||||
markedIds.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
@@ -189,9 +265,25 @@ struct VocabFlashcardView: View {
|
||||
// Next card
|
||||
withAnimation(.smooth) {
|
||||
isRevealed = false
|
||||
showConjugation = false
|
||||
currentIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Card fronts may be plain infinitives ("cerrar") or, in reversed decks,
|
||||
/// stored as English. Strip any reflexive-se suffix or parenthetical notes
|
||||
/// to improve the verb lookup hit rate.
|
||||
private func stripToInfinitive(_ s: String) -> String {
|
||||
var t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let paren = t.firstIndex(of: "(") {
|
||||
t = String(t[..<paren]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if t.hasSuffix("se") && t.count > 4 {
|
||||
// "acostarse" → "acostar" for verb lookup
|
||||
t = String(t.dropLast(2))
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -288,7 +288,10 @@ struct DashboardView: View {
|
||||
}
|
||||
|
||||
private func loadData() {
|
||||
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
// Reset a stale streak before rendering so the dashboard never lies.
|
||||
progress.validateStreakIfStale(context: cloudModelContext)
|
||||
userProgress = progress
|
||||
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
||||
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
||||
)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct GrammarExerciseView: View {
|
||||
let noteId: String
|
||||
let noteTitle: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var exercises: [GrammarExercise] = []
|
||||
@State private var currentIndex = 0
|
||||
@@ -96,6 +99,7 @@ struct GrammarExerciseView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Standalone grammar notes view with its own NavigationStack (used outside GuideView).
|
||||
struct GrammarNotesView: View {
|
||||
@@ -19,9 +21,9 @@ struct GrammarNotesListView: View {
|
||||
@Binding var selectedNote: GrammarNote?
|
||||
|
||||
private var groupedNotes: [(String, [GrammarNote])] {
|
||||
let grouped = Dictionary(grouping: GrammarNote.allNotes, by: \.category)
|
||||
let grouped = Dictionary(grouping: GrammarNote.allNotesIncludingGenerated, by: \.category)
|
||||
var seen: [String] = []
|
||||
for note in GrammarNote.allNotes {
|
||||
for note in GrammarNote.allNotesIncludingGenerated {
|
||||
if !seen.contains(note.category) {
|
||||
seen.append(note.category)
|
||||
}
|
||||
@@ -67,6 +69,24 @@ private struct GrammarNoteRow: View {
|
||||
|
||||
struct GrammarNoteDetailView: View {
|
||||
let note: GrammarNote
|
||||
var onJumpToTense: ((TenseGuide) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
@State private var relatedTenses: [TenseGuide] = []
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forGrammarNoteId: note.id)
|
||||
}
|
||||
|
||||
private func loadRelatedTenses() {
|
||||
guard let container = SharedStore.localContainer else {
|
||||
relatedTenses = []
|
||||
return
|
||||
}
|
||||
let context = ModelContext(container)
|
||||
let guides = ReferenceStore(context: context).fetchGuides()
|
||||
let byId = Dictionary(uniqueKeysWithValues: guides.map { ($0.tenseId, $0) })
|
||||
relatedTenses = GuideCrossLinks.tenseIds(forNote: note.id).compactMap { byId[$0] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -83,6 +103,12 @@ struct GrammarNoteDetailView: View {
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
|
||||
if !relatedTenses.isEmpty {
|
||||
relatedTensesSection
|
||||
}
|
||||
|
||||
videoSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Parsed body
|
||||
@@ -107,6 +133,49 @@ struct GrammarNoteDetailView: View {
|
||||
}
|
||||
.navigationTitle(note.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadRelatedTenses)
|
||||
.onChange(of: note.id) { _, _ in loadRelatedTenses() }
|
||||
}
|
||||
|
||||
private var relatedTensesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Used in tenses", systemImage: "clock.arrow.circlepath")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedTenses, id: \.tenseId) { guide in
|
||||
Button {
|
||||
onJumpToTense?(guide)
|
||||
} label: {
|
||||
Text(TenseInfo.find(guide.tenseId)?.english ?? guide.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.orange.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,15 +41,20 @@ struct GuideView: View {
|
||||
.navigationTitle("Guide")
|
||||
.task { loadGuides() }
|
||||
.onAppear(perform: loadGuides)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
selectedGuide = nil
|
||||
selectedNote = nil
|
||||
.onChange(of: selectedTab) { _, newTab in
|
||||
// Only clear the *other* tab's selection so programmatic
|
||||
// cross-link jumps (chip taps in the detail pane) can keep
|
||||
// their newly-set selection on the destination tab.
|
||||
switch newTab {
|
||||
case .tenses: selectedNote = nil
|
||||
case .grammar: selectedGuide = nil
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let guide = selectedGuide {
|
||||
GuideDetailView(guide: guide)
|
||||
GuideDetailView(guide: guide, onJumpToNote: jumpToNote)
|
||||
} else if let note = selectedNote {
|
||||
GrammarNoteDetailView(note: note)
|
||||
GrammarNoteDetailView(note: note, onJumpToTense: jumpToTense)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Topic", systemImage: "book", description: Text("Choose a tense or grammar topic to learn more."))
|
||||
}
|
||||
@@ -79,6 +84,16 @@ struct GuideView: View {
|
||||
GrammarNotesListView(selectedNote: $selectedNote)
|
||||
}
|
||||
|
||||
private func jumpToNote(_ note: GrammarNote) {
|
||||
selectedTab = .grammar
|
||||
selectedNote = note
|
||||
}
|
||||
|
||||
private func jumpToTense(_ guide: TenseGuide) {
|
||||
selectedTab = .tenses
|
||||
selectedGuide = guide
|
||||
}
|
||||
|
||||
private func loadGuides() {
|
||||
// Hit the shared local container directly, bypassing @Environment.
|
||||
guard let container = SharedStore.localContainer else {
|
||||
@@ -127,11 +142,23 @@ private struct TenseRowView: View {
|
||||
|
||||
struct GuideDetailView: View {
|
||||
let guide: TenseGuide
|
||||
var onJumpToNote: ((GrammarNote) -> Void)? = nil
|
||||
@Environment(YouTubeVideoStore.self) private var videoStore
|
||||
|
||||
private var relatedNotes: [GrammarNote] {
|
||||
GuideCrossLinks.noteIds(forTense: guide.tenseId).compactMap { id in
|
||||
GrammarNote.allNotesIncludingGenerated.first { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
private var tenseInfo: TenseInfo? {
|
||||
TenseInfo.find(guide.tenseId)
|
||||
}
|
||||
|
||||
private var curatedVideo: YouTubeVideoStore.VideoEntry? {
|
||||
videoStore.video(forTenseId: guide.tenseId)
|
||||
}
|
||||
|
||||
private var endingTable: TenseEndingTable? {
|
||||
TenseEndingTable.find(guide.tenseId)
|
||||
}
|
||||
@@ -146,6 +173,14 @@ struct GuideDetailView: View {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Related grammar notes — cross-links into the Grammar tab
|
||||
if !relatedNotes.isEmpty {
|
||||
relatedNotesSection
|
||||
}
|
||||
|
||||
// Video section (Issue #21)
|
||||
videoSection
|
||||
|
||||
// Conjugation ending table
|
||||
if let table = endingTable {
|
||||
conjugationTableSection(table)
|
||||
@@ -180,6 +215,51 @@ struct GuideDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Related grammar notes
|
||||
|
||||
private var relatedNotesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Related grammar", systemImage: "book")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(relatedNotes, id: \.id) { note in
|
||||
Button {
|
||||
onJumpToNote?(note)
|
||||
} label: {
|
||||
Text(note.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 7)
|
||||
.background(.indigo.opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video (Issue #21)
|
||||
|
||||
@ViewBuilder
|
||||
private var videoSection: some View {
|
||||
if let video = curatedVideo {
|
||||
VideoActionsButtonRow(video: video)
|
||||
} else {
|
||||
Label("No video yet", systemImage: "play.slash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(.fill.quinary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -450,14 +530,17 @@ struct GuideContent {
|
||||
var spanishLine: String?
|
||||
|
||||
func flushUsage() {
|
||||
if currentUsageNumber > 0 {
|
||||
// Only emit a usage if it has at least one example. This suppresses
|
||||
// the implicit "Usage 1" seeded when we enter an unnumbered
|
||||
// *Usages* block but the body actually has numbered headers below.
|
||||
if currentUsageNumber > 0 && !currentExamples.isEmpty {
|
||||
usages.append(GuideUsage(
|
||||
number: currentUsageNumber,
|
||||
title: currentUsageTitle,
|
||||
examples: currentExamples
|
||||
))
|
||||
currentExamples = []
|
||||
}
|
||||
currentExamples = []
|
||||
}
|
||||
|
||||
for line in lines {
|
||||
@@ -491,6 +574,11 @@ struct GuideContent {
|
||||
let title = String(match.1).replacingOccurrences(of: "*", with: "")
|
||||
if title.lowercased().contains("usage") {
|
||||
inUsages = true
|
||||
// Seed an implicit Usage 1 so content that follows without a
|
||||
// numbered "*1 Title*" header still gets captured. Any numbered
|
||||
// header below will replace this via flushUsage().
|
||||
currentUsageNumber = 1
|
||||
currentUsageTitle = "Usage"
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -563,4 +651,5 @@ struct GuideExample: Identifiable {
|
||||
#Preview {
|
||||
GuideView()
|
||||
.modelContainer(for: TenseGuide.self, inMemory: true)
|
||||
.environment(YouTubeVideoStore())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import AVKit
|
||||
import SharedModels
|
||||
|
||||
/// Three-button row for a curated YouTube video (Issue #21):
|
||||
/// - **Stream** — opens in the YouTube app (falls back to Safari).
|
||||
/// - **Download** — pulls the MP4 via YouTubeKit, shows progress, then enables Play.
|
||||
/// - **Play** — enabled only when the video exists on disk; plays via AVPlayer.
|
||||
///
|
||||
/// Used by both `GuideDetailView` and `GrammarNoteDetailView` to keep the
|
||||
/// video affordances consistent.
|
||||
struct VideoActionsButtonRow: View {
|
||||
let video: YouTubeVideoStore.VideoEntry
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var isDownloaded: Bool
|
||||
@State private var playerVideoId: String?
|
||||
@State private var downloadError: String?
|
||||
@State private var confirmDelete: Bool = false
|
||||
|
||||
init(video: YouTubeVideoStore.VideoEntry) {
|
||||
self.video = video
|
||||
self._isDownloaded = State(initialValue: VideoDownloadService.isDownloaded(videoId: video.videoId))
|
||||
}
|
||||
|
||||
private var activeStatus: VideoDownloadService.DownloadStatus? {
|
||||
downloadService.activeDownloads[video.videoId]
|
||||
}
|
||||
|
||||
private var isDownloading: Bool {
|
||||
activeStatus != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(video.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
streamButton
|
||||
downloadButton
|
||||
playButton
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.fullScreenCover(item: Binding(
|
||||
get: { playerVideoId.map { LocalVideoID(videoId: $0) } },
|
||||
set: { playerVideoId = $0?.videoId }
|
||||
)) { id in
|
||||
LocalVideoPlayerSheet(videoId: id.videoId, title: video.title)
|
||||
}
|
||||
.alert("Download failed", isPresented: .init(
|
||||
get: { downloadError != nil },
|
||||
set: { if !$0 { downloadError = nil } }
|
||||
)) {
|
||||
Button("OK") { downloadError = nil }
|
||||
} message: {
|
||||
Text(downloadError ?? "")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete this downloaded video?",
|
||||
isPresented: $confirmDelete,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
downloadService.delete(videoId: video.videoId, modelContext: modelContext)
|
||||
isDownloaded = false
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("You can re-download it at any time.")
|
||||
}
|
||||
.onAppear {
|
||||
// Refresh on appear in case the user deleted the file via Settings.
|
||||
isDownloaded = VideoDownloadService.isDownloaded(videoId: video.videoId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Buttons
|
||||
|
||||
private var streamButton: some View {
|
||||
Button {
|
||||
if let url = URL(string: "https://www.youtube.com/watch?v=\(video.videoId)") {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Stream", systemImage: "play.rectangle.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadButton: some View {
|
||||
// Single slot whose role flips through the download lifecycle:
|
||||
// Download → progress/label (disabled) → Delete.
|
||||
if let status = activeStatus {
|
||||
Button {} label: {
|
||||
VStack(spacing: 4) {
|
||||
if let progress = status.progress {
|
||||
ProgressView(value: progress).frame(width: 56)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text(status.label)
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
.disabled(true)
|
||||
} else if isDownloaded {
|
||||
Button(role: .destructive) {
|
||||
confirmDelete = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.red)
|
||||
.controlSize(.large)
|
||||
} else {
|
||||
Button {
|
||||
Task { await startDownload() }
|
||||
} label: {
|
||||
Label("Download", systemImage: "arrow.down.to.line")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
playerVideoId = video.videoId
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.labelStyle(VideoActionLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.green)
|
||||
.controlSize(.large)
|
||||
.disabled(!isDownloaded)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func startDownload() async {
|
||||
do {
|
||||
try await downloadService.download(
|
||||
videoId: video.videoId,
|
||||
title: video.title,
|
||||
into: modelContext
|
||||
)
|
||||
isDownloaded = true
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
} catch {
|
||||
downloadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper identifiable wrapper so .sheet(item:) can use a plain String
|
||||
|
||||
private struct LocalVideoID: Identifiable {
|
||||
let videoId: String
|
||||
var id: String { videoId }
|
||||
}
|
||||
|
||||
// Stacks the icon above the title so three equal-width buttons fit an iPhone
|
||||
// row without wrapping mid-word.
|
||||
private struct VideoActionLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
configuration.icon
|
||||
.imageScale(.large)
|
||||
configuration.title
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local playback sheet
|
||||
|
||||
struct LocalVideoPlayerSheet: View {
|
||||
let videoId: String
|
||||
let title: String
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var player: AVPlayer
|
||||
|
||||
init(videoId: String, title: String) {
|
||||
self.videoId = videoId
|
||||
self.title = title
|
||||
self._player = State(initialValue: AVPlayer(url: VideoDownloadService.fileURL(for: videoId)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
.onDisappear { player.pause() }
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.black, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ struct OnboardingView: View {
|
||||
|
||||
private func completeOnboarding() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress.selectedVerbLevel = selectedLevel
|
||||
progress.selectedVerbLevels = [selectedLevel]
|
||||
if progress.enabledTenseIDs.isEmpty {
|
||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Due-card review for the adjective flashcard SRS — non-verb analog of
|
||||
/// `VocabReviewView`. Pulls every `LexemeReviewCard` with
|
||||
/// `partOfSpeech == "adjective"` whose `dueDate` is in the past, shows the
|
||||
/// Spanish base form on the front, reveals the English, then rates via the
|
||||
/// SRS so the schedule moves forward.
|
||||
struct AdjectiveReviewView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var dueCards: [LexemeReviewCard] = []
|
||||
@State private var lexemesByID: [String: Lexeme] = [:]
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var sessionTotal = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isFinished || dueCards.isEmpty {
|
||||
finishedView
|
||||
} else if let card = dueCards[safe: currentIndex] {
|
||||
cardView(card)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Adjective Review")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadDueCards)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cardView(_ card: LexemeReviewCard) -> some View {
|
||||
let lexeme = lexemesByID[card.lexemeId]
|
||||
VStack(spacing: 24) {
|
||||
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||
.tint(.pink)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(lexeme?.baseForm ?? "")
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isRevealed {
|
||||
Text(lexeme?.english ?? "")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ratingButton("Again", color: .red, quality: .again)
|
||||
ratingButton("Hard", color: .orange, quality: .hard)
|
||||
ratingButton("Good", color: .green, quality: .good)
|
||||
ratingButton("Easy", color: .blue, quality: .easy)
|
||||
}
|
||||
} else {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Show Answer")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: dueCards.isEmpty ? "checkmark.seal.fill" : "star.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(dueCards.isEmpty ? .green : .yellow)
|
||||
|
||||
if dueCards.isEmpty {
|
||||
Text("All caught up!").font(.title2.bold())
|
||||
Text("No adjective cards are due for review.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(sessionCorrect) / \(sessionTotal)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
Text("Review complete!").font(.title3).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, quality: ReviewQuality) -> some View {
|
||||
Button {
|
||||
rate(quality: quality)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(color)
|
||||
}
|
||||
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
currentEase: card.easeFactor,
|
||||
currentInterval: card.interval,
|
||||
currentReps: card.repetitions
|
||||
)
|
||||
card.easeFactor = result.easeFactor
|
||||
card.interval = result.interval
|
||||
card.repetitions = result.repetitions
|
||||
card.dueDate = SRSEngine.nextDueDate(interval: result.interval)
|
||||
card.lastReviewDate = Date()
|
||||
try? cloudContext.save()
|
||||
|
||||
sessionTotal += 1
|
||||
if quality != .again { sessionCorrect += 1 }
|
||||
|
||||
isRevealed = false
|
||||
if currentIndex + 1 < dueCards.count {
|
||||
currentIndex += 1
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDueCards() {
|
||||
let now = Date()
|
||||
let pos = "adjective"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
},
|
||||
sortBy: [SortDescriptor(\LexemeReviewCard.dueDate)]
|
||||
)
|
||||
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
|
||||
let ids = Set(dueCards.map(\.lexemeId))
|
||||
let lexDesc = FetchDescriptor<Lexeme>(
|
||||
predicate: #Predicate<Lexeme> { ids.contains($0.id) }
|
||||
)
|
||||
let all = (try? localContext.fetch(lexDesc)) ?? []
|
||||
lexemesByID = Dictionary(all.map { ($0.id, $0) }, uniquingKeysWith: { existing, _ in existing })
|
||||
}
|
||||
|
||||
static func dueCount(context: ModelContext) -> Int {
|
||||
let now = Date()
|
||||
let pos = "adjective"
|
||||
let descriptor = FetchDescriptor<LexemeReviewCard>(
|
||||
predicate: #Predicate<LexemeReviewCard> {
|
||||
$0.partOfSpeech == pos && $0.dueDate <= now
|
||||
}
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct BookChapterListView: View {
|
||||
let book: Book
|
||||
|
||||
@Query private var allChapters: [BookChapter]
|
||||
|
||||
init(book: Book) {
|
||||
self.book = book
|
||||
let slug = book.slug
|
||||
_allChapters = Query(
|
||||
filter: #Predicate<BookChapter> { $0.bookSlug == slug },
|
||||
sort: \BookChapter.number
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(allChapters) { chapter in
|
||||
NavigationLink(value: chapter) {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(chapter.number)")
|
||||
.font(.subheadline.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, alignment: .trailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text("\(chapter.paragraphCount) paragraph\(chapter.paragraphCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(book.title.prefix(while: { $0 != ":" }).description)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Route value for pushing the books library. Lets `PracticeView` use a
|
||||
/// value-based link so the entire books navigation chain is consistent —
|
||||
/// mixing a view-based push with value-based pushes deeper in the same
|
||||
/// NavigationStack made pushed screens pop back immediately.
|
||||
enum BooksRoute: Hashable {
|
||||
case library
|
||||
}
|
||||
|
||||
struct BookLibraryView: View {
|
||||
@Query(sort: \Book.title) private var books: [Book]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if books.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Books",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("Books bundled with the app will appear here.")
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(books) { book in
|
||||
NavigationLink(value: book) {
|
||||
BookCard(book: book)
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Books")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookCard: View {
|
||||
let book: Book
|
||||
|
||||
private var accentColor: Color {
|
||||
Color(hex: book.accentColorHex) ?? .indigo
|
||||
}
|
||||
|
||||
private var shortTitle: String {
|
||||
// Trim "Volume X" subtitle if present — most book titles are way too long.
|
||||
if let colon = book.title.firstIndex(of: ":") {
|
||||
return String(book.title[..<colon])
|
||||
}
|
||||
return book.title
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(accentColor.gradient)
|
||||
.frame(width: 48, height: 64)
|
||||
.overlay {
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(shortTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
if !book.author.isEmpty {
|
||||
Text(book.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("\(book.chapterCount) chapter\(book.chapterCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private extension Color {
|
||||
init?(hex: String) {
|
||||
var s = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if s.hasPrefix("#") { s.removeFirst() }
|
||||
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
|
||||
let r = Double((v >> 16) & 0xFF) / 255.0
|
||||
let g = Double((v >> 8) & 0xFF) / 255.0
|
||||
let b = Double(v & 0xFF) / 255.0
|
||||
self = Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
struct BookReaderView: View {
|
||||
let chapter: BookChapter
|
||||
|
||||
/// The book this chapter belongs to, resolved by slug — used for the
|
||||
/// pre-computed glossary. A @Query is safe here because the reader is
|
||||
/// built lazily by `navigationDestination`: one instance, when opened.
|
||||
@Query private var bookMatches: [Book]
|
||||
private var book: Book? { bookMatches.first }
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var speech = BookSpeechController()
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var showEnglish = false
|
||||
@State private var showVoicePicker = false
|
||||
@State private var wasReadingBeforeTap = false
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
/// The book's pre-computed glossary, decoded once on appear.
|
||||
@State private var glossary: [String: WordGloss] = [:]
|
||||
|
||||
@AppStorage("bookReaderVoiceId") private var storedVoiceId: String = ""
|
||||
@AppStorage("bookReaderRate") private var storedRate: Double = 0.45
|
||||
|
||||
init(chapter: BookChapter) {
|
||||
self.chapter = chapter
|
||||
let slug = chapter.bookSlug
|
||||
_bookMatches = Query(filter: #Predicate<Book> { $0.slug == slug })
|
||||
}
|
||||
|
||||
private var paragraphsES: [String] { chapter.paragraphsES() }
|
||||
private var paragraphsEN: [String] { chapter.paragraphsEN() }
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 18) {
|
||||
Text(chapter.title)
|
||||
.font(.title2.bold())
|
||||
.padding(.bottom, 4)
|
||||
.id(-1)
|
||||
|
||||
ForEach(Array(paragraphsES.enumerated()), id: \.offset) { index, paragraph in
|
||||
paragraphView(index: index, paragraph: paragraph)
|
||||
.id(index)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.onChange(of: speech.currentParagraphIndex) { _, newIndex in
|
||||
guard let newIndex else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
proxy.scrollTo(newIndex, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chapter \(chapter.number)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showVoicePicker = true
|
||||
} label: {
|
||||
Image(systemName: "waveform.circle")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel("Voice & speed")
|
||||
|
||||
Button {
|
||||
toggleReadAloud()
|
||||
} label: {
|
||||
Image(systemName: speech.isReading ? "stop.circle.fill" : "play.circle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
.accessibilityLabel(speech.isReading ? "Stop reading" : "Read aloud")
|
||||
|
||||
Button {
|
||||
withAnimation { showEnglish.toggle() }
|
||||
} label: {
|
||||
Image(systemName: showEnglish ? "character.book.closed.fill.he" : "character.book.closed")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.accessibilityLabel(showEnglish ? "Show Spanish" : "Show English")
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedWord, onDismiss: handleSheetDismiss) { word in
|
||||
WordDetailSheet(word: word)
|
||||
.presentationDetents([.height(220)])
|
||||
}
|
||||
.sheet(isPresented: $showVoicePicker) {
|
||||
BookVoicePickerSheet(voiceIdentifier: voiceBinding, rate: rateBinding)
|
||||
}
|
||||
.onAppear {
|
||||
speech.voiceIdentifier = storedVoiceId.isEmpty ? nil : storedVoiceId
|
||||
speech.rate = Float(storedRate)
|
||||
if glossary.isEmpty {
|
||||
glossary = book?.glossary() ?? [:]
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
speech.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func paragraphView(index: Int, paragraph: String) -> some View {
|
||||
if showEnglish {
|
||||
Text(translation(for: index))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
TappableParagraph(
|
||||
text: paragraph,
|
||||
highlightedWordIndex: speech.currentParagraphIndex == index ? speech.currentWordIndex : nil
|
||||
) { word in
|
||||
handleTap(word: word, paragraph: paragraph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func translation(for index: Int) -> String {
|
||||
guard index < paragraphsEN.count else { return "" }
|
||||
let en = paragraphsEN[index]
|
||||
return en.isEmpty ? "[translation unavailable]" : en
|
||||
}
|
||||
|
||||
// MARK: - Read-along controls
|
||||
|
||||
private func toggleReadAloud() {
|
||||
if speech.isReading {
|
||||
speech.stop()
|
||||
} else {
|
||||
// Start from the first non-vocab paragraph at or after the topmost
|
||||
// visible one. For V1 we start from the chapter top — adding
|
||||
// "start from visible paragraph" would need a scroll-position
|
||||
// observer, which isn't worth the complexity yet.
|
||||
speech.start(paragraphs: paragraphsES)
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceBinding: Binding<String?> {
|
||||
Binding(
|
||||
get: { storedVoiceId.isEmpty ? nil : storedVoiceId },
|
||||
set: { newValue in
|
||||
storedVoiceId = newValue ?? ""
|
||||
speech.voiceIdentifier = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var rateBinding: Binding<Float> {
|
||||
Binding(
|
||||
get: { Float(storedRate) },
|
||||
set: { newValue in
|
||||
storedRate = Double(newValue)
|
||||
speech.rate = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Word tap → definition
|
||||
|
||||
private func handleTap(word: String, paragraph: String) {
|
||||
let cleaned = cleanWord(word)
|
||||
if cleaned.isEmpty { return }
|
||||
|
||||
// If reading aloud, pause immediately. Remember so we can resume when
|
||||
// the user dismisses the definition sheet.
|
||||
if speech.isReading, !speech.isPaused {
|
||||
speech.pause()
|
||||
wasReadingBeforeTap = true
|
||||
}
|
||||
|
||||
// Fall-through chain, best source first. Whichever resource answers,
|
||||
// the popup names it so a curated glossary hit reads differently from
|
||||
// a best-effort on-device LLM guess.
|
||||
if let cached = lookupCache[cleaned] {
|
||||
selectedWord = cached
|
||||
return
|
||||
}
|
||||
if let gloss = glossary[cleaned] {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: gloss.baseForm,
|
||||
english: gloss.english,
|
||||
partOfSpeech: gloss.partOfSpeech,
|
||||
source: "Book glossary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
if let entry = dictionary.lookup(cleaned) {
|
||||
let annotation = WordAnnotation(
|
||||
word: cleaned,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
source: "Dictionary"
|
||||
)
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Looking up...", partOfSpeech: "")
|
||||
Task {
|
||||
do {
|
||||
var annotation = try await WordLookup.lookup(word: cleaned, inContext: paragraph)
|
||||
annotation.source = "AI guess"
|
||||
lookupCache[cleaned] = annotation
|
||||
selectedWord = annotation
|
||||
} catch {
|
||||
selectedWord = WordAnnotation(word: cleaned, baseForm: cleaned, english: "Lookup unavailable", partOfSpeech: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSheetDismiss() {
|
||||
guard wasReadingBeforeTap else { return }
|
||||
wasReadingBeforeTap = false
|
||||
speech.resume()
|
||||
}
|
||||
|
||||
private func cleanWord(_ word: String) -> String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tappable paragraph
|
||||
|
||||
private struct TappableParagraph: View {
|
||||
let text: String
|
||||
let highlightedWordIndex: Int?
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
let words = text.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
FlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { idx, word in
|
||||
WordButton(word: word, isHighlighted: idx == highlightedWordIndex, onTap: onTap)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let isHighlighted: Bool
|
||||
let onTap: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(word)
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, isHighlighted ? 2 : 0)
|
||||
.padding(.vertical, 1)
|
||||
.background(
|
||||
isHighlighted
|
||||
? Color.yellow.opacity(0.35)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 4)
|
||||
)
|
||||
.animation(.easeInOut(duration: 0.15), value: isHighlighted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow layout
|
||||
|
||||
private struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows {
|
||||
height += row.map { $0.height }.max() ?? 0
|
||||
}
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var subviewIndex = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rowHeight = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[subviewIndex].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
subviewIndex += 1
|
||||
}
|
||||
y += rowHeight + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var currentWidth: CGFloat = 0
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if currentWidth + size.width > maxWidth && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
currentWidth = 0
|
||||
}
|
||||
rows[rows.count - 1].append(size)
|
||||
currentWidth += size.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word detail sheet
|
||||
|
||||
private struct WordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !word.source.isEmpty {
|
||||
Text(sourceLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var sourceLabel: String {
|
||||
word.source == "AI guess"
|
||||
? "AI guess · on-device estimate, may be approximate"
|
||||
: "Source: \(word.source)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-demand word lookup (matches StoryReaderView's WordLookup)
|
||||
|
||||
@MainActor
|
||||
private enum WordLookup {
|
||||
@Generable
|
||||
struct WordInfo {
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, conjunction, article, pronoun, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
static func lookup(word: String, inContext sentence: String) async throws -> WordAnnotation {
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish dictionary. Given a word and the sentence it appears in, \
|
||||
provide its base form, English translation, and part of speech.
|
||||
""")
|
||||
let response = try await session.respond(
|
||||
to: "Word: \"\(word)\" in sentence: \"\(sentence)\"",
|
||||
generating: WordInfo.self
|
||||
)
|
||||
let info = response.content
|
||||
return WordAnnotation(
|
||||
word: word,
|
||||
baseForm: info.baseForm,
|
||||
english: info.english,
|
||||
partOfSpeech: info.partOfSpeech
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
|
||||
/// Voice + speed picker shown from the book reader's toolbar. Lists Spanish
|
||||
/// voices currently installed on the device grouped by quality, and offers a
|
||||
/// shortcut to the iOS Settings app where the user can download premium voices
|
||||
/// (no public deep-link to the Accessibility section exists, so we open the
|
||||
/// app's own Settings page with a hint).
|
||||
struct BookVoicePickerSheet: View {
|
||||
@Binding var voiceIdentifier: String?
|
||||
@Binding var rate: Float
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private struct VoiceGroup: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let voices: [AVSpeechSynthesisVoice]
|
||||
}
|
||||
|
||||
private var groups: [VoiceGroup] {
|
||||
let all = AVSpeechSynthesisVoice.speechVoices()
|
||||
.filter { $0.language.hasPrefix("es") }
|
||||
let buckets: [(String, AVSpeechSynthesisVoiceQuality)] = [
|
||||
("Premium", .premium),
|
||||
("Enhanced", .enhanced),
|
||||
("Default", .default),
|
||||
]
|
||||
return buckets.compactMap { (title, quality) in
|
||||
let voices = all
|
||||
.filter { $0.quality == quality }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.language != rhs.language { return lhs.language < rhs.language }
|
||||
return lhs.name < rhs.name
|
||||
}
|
||||
return voices.isEmpty ? nil : VoiceGroup(id: title, title: title, voices: voices)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Speed") {
|
||||
Picker("Speed", selection: $rate) {
|
||||
Text("Slow").tag(Float(0.40))
|
||||
Text("Normal").tag(Float(0.50))
|
||||
Text("Fast").tag(Float(0.55))
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if groups.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No Spanish voices",
|
||||
systemImage: "person.wave.2",
|
||||
description: Text("Install a Spanish voice in Settings → Accessibility → Spoken Content → Voices.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(groups) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.voices, id: \.identifier) { voice in
|
||||
voiceRow(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
openSettings()
|
||||
} label: {
|
||||
Label("Download more voices…", systemImage: "arrow.down.circle")
|
||||
}
|
||||
} footer: {
|
||||
Text("Opens Settings. Navigate to Accessibility → Spoken Content → Voices → Spanish to install premium or enhanced voices.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Read aloud")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceRow(_ voice: AVSpeechSynthesisVoice) -> some View {
|
||||
Button {
|
||||
voiceIdentifier = voice.identifier
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(voice.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(voice.language)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if voice.identifier == voiceIdentifier {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import FoundationModels
|
||||
|
||||
@Generable
|
||||
private struct ChatWordInfo {
|
||||
@Guide(description: "Dictionary base form") var baseForm: String
|
||||
@Guide(description: "English translation") var english: String
|
||||
@Guide(description: "Part of speech") var partOfSpeech: String
|
||||
}
|
||||
|
||||
struct ChatView: View {
|
||||
let conversation: Conversation
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var service = ConversationService()
|
||||
@State private var messages: [ChatMessage] = []
|
||||
@State private var inputText = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var hasStarted = false
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@@ -21,8 +32,10 @@ struct ChatView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(messages) { message in
|
||||
ChatBubble(message: message)
|
||||
.id(message.id)
|
||||
ChatBubble(message: message, dictionary: dictionary, lookupCache: $lookupCache) { word in
|
||||
selectedWord = word
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
if service.isResponding {
|
||||
@@ -68,6 +81,10 @@ struct ChatView: View {
|
||||
}
|
||||
.navigationTitle(conversation.scenario)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
ChatWordDetailSheet(word: word)
|
||||
.presentationDetents([.height(200)])
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
@@ -102,6 +119,7 @@ struct ChatView: View {
|
||||
messages = conversation.decodedMessages
|
||||
inputText = ""
|
||||
try? cloudContext.save()
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
|
||||
Task {
|
||||
do {
|
||||
@@ -121,6 +139,9 @@ struct ChatView: View {
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
let dictionary: DictionaryService
|
||||
@Binding var lookupCache: [String: WordAnnotation]
|
||||
let onWordTap: (WordAnnotation) -> Void
|
||||
|
||||
private var isUser: Bool { message.role == "user" }
|
||||
|
||||
@@ -129,11 +150,15 @@ private struct ChatBubble: View {
|
||||
if isUser { Spacer(minLength: 60) }
|
||||
|
||||
VStack(alignment: isUser ? .trailing : .leading, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(isUser ? AnyShapeStyle(.green.opacity(0.2)) : AnyShapeStyle(.fill.quaternary), in: RoundedRectangle(cornerRadius: 16))
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.green.opacity(0.2), in: RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
tappableBubble
|
||||
}
|
||||
|
||||
if let correction = message.correction, !correction.isEmpty {
|
||||
Text(correction)
|
||||
@@ -147,4 +172,179 @@ private struct ChatBubble: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
private var tappableBubble: some View {
|
||||
let words = message.content.components(separatedBy: " ")
|
||||
return ChatFlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||
ChatWordButton(word: word, dictionary: dictionary, cache: lookupCache) { annotation in
|
||||
if annotation.english.isEmpty {
|
||||
lookupWordAsync(annotation.word)
|
||||
} else {
|
||||
onWordTap(annotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func lookupWordAsync(_ word: String) {
|
||||
// Try dictionary first
|
||||
if let entry = dictionary.lookup(word) {
|
||||
let annotation = WordAnnotation(word: word, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||
lookupCache[word] = annotation
|
||||
onWordTap(annotation)
|
||||
return
|
||||
}
|
||||
|
||||
// Show loading then AI lookup
|
||||
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: ""))
|
||||
|
||||
Task {
|
||||
do {
|
||||
let session = LanguageModelSession(instructions: "You are a Spanish dictionary. Provide base form, English translation, and part of speech.")
|
||||
let response = try await session.respond(to: "Word: \"\(word)\"", generating: ChatWordInfo.self)
|
||||
let info = response.content
|
||||
let annotation = WordAnnotation(word: word, baseForm: info.baseForm, english: info.english, partOfSpeech: info.partOfSpeech)
|
||||
lookupCache[word] = annotation
|
||||
onWordTap(annotation)
|
||||
} catch {
|
||||
onWordTap(WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Word Button
|
||||
|
||||
private struct ChatWordButton: View {
|
||||
let word: String
|
||||
let dictionary: DictionaryService
|
||||
let cache: [String: WordAnnotation]
|
||||
let onTap: (WordAnnotation) -> Void
|
||||
|
||||
private var cleaned: String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private var annotation: WordAnnotation? {
|
||||
if let cached = cache[cleaned] { return cached }
|
||||
if let entry = dictionary.lookup(cleaned) {
|
||||
return WordAnnotation(word: cleaned, baseForm: entry.baseForm, english: entry.english, partOfSpeech: entry.partOfSpeech)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(annotation ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: ""))
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.underline(annotation != nil, color: .teal.opacity(0.3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Detail Sheet
|
||||
|
||||
private struct ChatWordDetailSheet: View {
|
||||
let word: WordAnnotation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if word.english == "Looking up..." {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Looking up word...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !word.baseForm.isEmpty && word.baseForm != word.word {
|
||||
HStack {
|
||||
Text("Base form:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.baseForm)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
if !word.english.isEmpty {
|
||||
HStack {
|
||||
Text("English:")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(word.english)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Flow Layout
|
||||
|
||||
private struct ChatFlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY; var idx = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX; let rh = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width; idx += 1
|
||||
}
|
||||
y += rh + spacing
|
||||
}
|
||||
}
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let mw = proposal.width ?? .infinity; var rows: [[CGSize]] = [[]]; var cw: CGFloat = 0
|
||||
for sv in subviews {
|
||||
let s = sv.sizeThatFits(.unspecified)
|
||||
if cw + s.width > mw && !rows[rows.count - 1].isEmpty { rows.append([]); cw = 0 }
|
||||
rows[rows.count - 1].append(s); cw += s.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ struct ClozeView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -20,6 +20,7 @@ struct FullTableView: View {
|
||||
@State private var useHandwriting = false
|
||||
@State private var sessionCount = 0
|
||||
@State private var sessionCorrect = 0
|
||||
@State private var noEligibleVerbs = false
|
||||
|
||||
// Handwriting state per field
|
||||
@State private var drawings: [PKDrawing] = Array(repeating: PKDrawing(), count: 6)
|
||||
@@ -53,35 +54,39 @@ struct FullTableView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
headerSection(verb: verb, tense: tense)
|
||||
}
|
||||
|
||||
// Input mode toggle
|
||||
HStack {
|
||||
Picker("Input", selection: $useHandwriting) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(false)
|
||||
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
||||
if noEligibleVerbs {
|
||||
emptyPoolError
|
||||
} else {
|
||||
VStack(spacing: 32) {
|
||||
// Header
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
headerSection(verb: verb, tense: tense)
|
||||
}
|
||||
|
||||
// Input mode toggle
|
||||
HStack {
|
||||
Picker("Input", selection: $useHandwriting) {
|
||||
Label("Keyboard", systemImage: "keyboard").tag(false)
|
||||
Label("Pencil", systemImage: "pencil.and.outline").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Input fields
|
||||
inputSection
|
||||
|
||||
// Check / Next button
|
||||
actionButton
|
||||
|
||||
// Score
|
||||
if sessionCount > 0 {
|
||||
scoreSection
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Input fields
|
||||
inputSection
|
||||
|
||||
// Check / Next button
|
||||
actionButton
|
||||
|
||||
// Score
|
||||
if sessionCount > 0 {
|
||||
scoreSection
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Full Table")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -91,6 +96,22 @@ struct FullTableView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty pool error
|
||||
|
||||
private var emptyPoolError: some View {
|
||||
VStack(spacing: 16) {
|
||||
ContentUnavailableView(
|
||||
"No regular verbs available",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(
|
||||
"None of the selected tenses have any fully-regular verbs in the current settings. Enable more tenses, or turn off the Reflexive-only toggle in Settings."
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private func headerSection(verb: Verb, tense: TenseInfo) -> some View {
|
||||
@@ -243,15 +264,27 @@ struct FullTableView: View {
|
||||
results = Array(repeating: nil, count: 6)
|
||||
correctForms = []
|
||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
guard let prompt = service.randomFullTablePrompt() else {
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
guard let prompt = service.randomFullTablePrompt(
|
||||
previousTenseId: currentTense?.id,
|
||||
previousEnding: currentVerb?.ending
|
||||
) else {
|
||||
// Genuinely no eligible (verb, tense) combo. Surface a clear error
|
||||
// instead of a blank screen — the previous behaviour silently
|
||||
// rendered an empty header and inputs.
|
||||
currentVerb = nil
|
||||
currentTense = nil
|
||||
userAnswers = Array(repeating: "", count: 6)
|
||||
focusedField = nil
|
||||
noEligibleVerbs = true
|
||||
return
|
||||
}
|
||||
|
||||
noEligibleVerbs = false
|
||||
currentVerb = prompt.verb
|
||||
currentTense = prompt.tenseInfo
|
||||
correctForms = prompt.forms
|
||||
@@ -312,7 +345,11 @@ struct FullTableView: View {
|
||||
if allCorrect { sessionCorrect += 1 }
|
||||
|
||||
if let verb = currentVerb, let tense = currentTense {
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
let service = PracticeSessionService(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext,
|
||||
reflexiveBaseInfinitives: ReflexiveVerbStore.shared.baseInfinitives
|
||||
)
|
||||
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct ListeningView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var pronunciation = PronunciationService()
|
||||
@State private var speechService = SpeechService()
|
||||
|
||||
@@ -122,6 +124,7 @@ struct ListeningView: View {
|
||||
Button {
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Check")
|
||||
@@ -164,6 +167,7 @@ struct ListeningView: View {
|
||||
score = result.score
|
||||
wordMatches = result.matches
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isRevealed = true }
|
||||
} else {
|
||||
pronunciation.startRecording()
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
@State private var selectedWord: LyricsWordLookup?
|
||||
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
@@ -15,6 +22,10 @@ struct LyricsReaderView: View {
|
||||
}
|
||||
.navigationTitle(song.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
LyricsWordDetailSheet(word: word)
|
||||
.presentationDetents([.height(260)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -56,15 +67,6 @@ struct LyricsReaderView: View {
|
||||
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
||||
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
||||
let lineCount = max(spanishLines.count, englishLines.count)
|
||||
let _ = {
|
||||
print("[LyricsReader] ES lines: \(spanishLines.count), EN lines: \(englishLines.count), rendering: \(lineCount)")
|
||||
for i in 0..<min(15, lineCount) {
|
||||
let es = i < spanishLines.count ? spanishLines[i] : "(none)"
|
||||
let en = i < englishLines.count ? englishLines[i] : "(none)"
|
||||
print(" [\(i)] ES: \(es.isEmpty ? "(blank)" : es)")
|
||||
print(" EN: \(en.isEmpty ? "(blank)" : en)")
|
||||
}
|
||||
}()
|
||||
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(0..<lineCount, id: \.self) { index in
|
||||
@@ -78,8 +80,7 @@ struct LyricsReaderView: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if !es.isEmpty {
|
||||
Text(es)
|
||||
.font(.body.weight(.medium))
|
||||
spanishLine(es)
|
||||
}
|
||||
if !en.isEmpty {
|
||||
Text(en)
|
||||
@@ -94,4 +95,184 @@ struct LyricsReaderView: View {
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func spanishLine(_ line: String) -> some View {
|
||||
let tokens = line.components(separatedBy: " ")
|
||||
return LyricsFlowLayout(spacing: 0) {
|
||||
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
||||
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
selectedWord = word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
|
||||
let cleaned = rawToken.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
|
||||
if let cached = lookupCache[cleaned] { return cached }
|
||||
guard let entry = dictionary.lookup(cleaned) else { return nil }
|
||||
|
||||
let displayWord = rawToken
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
|
||||
|
||||
let lookup = LyricsWordLookup(
|
||||
word: displayWord.isEmpty ? entry.word : displayWord,
|
||||
baseForm: entry.baseForm,
|
||||
english: entry.english,
|
||||
partOfSpeech: entry.partOfSpeech,
|
||||
tenseDisplay: tenseDisplay,
|
||||
person: entry.person
|
||||
)
|
||||
lookupCache[cleaned] = lookup
|
||||
return lookup
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Lookup Model
|
||||
|
||||
private struct LyricsWordLookup: Identifiable, Hashable {
|
||||
let word: String
|
||||
let baseForm: String
|
||||
let english: String
|
||||
let partOfSpeech: String
|
||||
let tenseDisplay: String?
|
||||
let person: String?
|
||||
|
||||
var id: String { word }
|
||||
}
|
||||
|
||||
// MARK: - Word View
|
||||
|
||||
private struct LyricsWordView: View {
|
||||
let token: String
|
||||
let lookup: LyricsWordLookup?
|
||||
let onLookup: (LyricsWordLookup) -> Void
|
||||
|
||||
var body: some View {
|
||||
Text(token + " ")
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.underline(lookup != nil, color: .teal.opacity(0.35))
|
||||
.contentShape(Rectangle())
|
||||
.onLongPressGesture(minimumDuration: 0.35) {
|
||||
if let lookup {
|
||||
onLookup(lookup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Sheet
|
||||
|
||||
private struct LyricsWordDetailSheet: View {
|
||||
let word: LyricsWordLookup
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text(word.word)
|
||||
.font(.title2.bold())
|
||||
Spacer()
|
||||
if !word.partOfSpeech.isEmpty {
|
||||
Text(word.partOfSpeech)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.fill.tertiary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
|
||||
detailRow(label: "Base form", value: word.baseForm, italic: true)
|
||||
}
|
||||
|
||||
if !word.english.isEmpty {
|
||||
detailRow(label: "English", value: word.english)
|
||||
}
|
||||
|
||||
if let tenseDisplay = word.tenseDisplay {
|
||||
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
|
||||
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("\(label):")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 86, alignment: .leading)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.italic(italic)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout
|
||||
|
||||
private struct LyricsFlowLayout: Layout {
|
||||
var spacing: CGFloat = 0
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var height: CGFloat = 0
|
||||
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
||||
height += CGFloat(max(0, rows.count - 1)) * spacing
|
||||
return CGSize(width: proposal.width ?? 0, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let rows = computeRows(proposal: proposal, subviews: subviews)
|
||||
var y = bounds.minY
|
||||
var idx = 0
|
||||
for row in rows {
|
||||
var x = bounds.minX
|
||||
let rh = row.map { $0.height }.max() ?? 0
|
||||
for size in row {
|
||||
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||
x += size.width
|
||||
idx += 1
|
||||
}
|
||||
y += rh + spacing
|
||||
}
|
||||
}
|
||||
|
||||
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
||||
let mw = proposal.width ?? .infinity
|
||||
var rows: [[CGSize]] = [[]]
|
||||
var cw: CGFloat = 0
|
||||
for sv in subviews {
|
||||
let s = sv.sizeThatFits(.unspecified)
|
||||
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
|
||||
rows.append([])
|
||||
cw = 0
|
||||
}
|
||||
rows[rows.count - 1].append(s)
|
||||
cw += s.width
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -21,12 +27,29 @@ struct PracticeView: View {
|
||||
practiceHomeView
|
||||
}
|
||||
}
|
||||
// Book navigation is value-based and declared once here, at the
|
||||
// stack root. Eager `NavigationLink { destination }` forms inside
|
||||
// the List/LazyVStack of the book screens caused an infinite
|
||||
// render loop; value-based links build destinations lazily.
|
||||
.navigationDestination(for: BooksRoute.self) { _ in
|
||||
BookLibraryView()
|
||||
}
|
||||
.navigationDestination(for: Book.self) { book in
|
||||
BookChapterListView(book: book)
|
||||
}
|
||||
.navigationDestination(for: BookChapter.self) { chapter in
|
||||
BookReaderView(chapter: chapter)
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
.onAppear {
|
||||
loadProgress()
|
||||
refreshLexemeDueCounts()
|
||||
}
|
||||
.onChange(of: isPracticing) { _, practicing in
|
||||
if !practicing {
|
||||
loadProgress()
|
||||
refreshLexemeDueCounts()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
@@ -74,12 +97,10 @@ struct PracticeView: View {
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// Mode selection
|
||||
VStack(spacing: 12) {
|
||||
Text("Choose a Mode")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// === Section: Conjugation ===
|
||||
sectionHeader("Conjugation")
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(PracticeMode.allCases) { mode in
|
||||
ModeButton(mode: mode) {
|
||||
viewModel.practiceMode = mode
|
||||
@@ -98,6 +119,23 @@ struct PracticeView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
conjugationFocusButtons
|
||||
|
||||
// === Section: Vocabulary ===
|
||||
sectionHeader("Vocabulary")
|
||||
vocabSection
|
||||
|
||||
// === Section: Nouns ===
|
||||
sectionHeader("Nouns")
|
||||
nounsSection
|
||||
|
||||
// === Section: Adjectives ===
|
||||
sectionHeader("Adjectives")
|
||||
adjectivesSection
|
||||
|
||||
// === Section: Reading ===
|
||||
sectionHeader("Reading")
|
||||
|
||||
// Lyrics
|
||||
NavigationLink {
|
||||
LyricsLibraryView()
|
||||
@@ -253,166 +291,33 @@ struct PracticeView: View {
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Books
|
||||
NavigationLink(value: BooksRoute.library) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.indigo)
|
||||
|
||||
// Vocab review
|
||||
NavigationLink {
|
||||
VocabReviewView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vocab Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due vocabulary cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
|
||||
if dueCount > 0 {
|
||||
Text("\(dueCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal, in: Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Books")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read full-length books with tap-to-define")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Common tenses focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Common Tenses")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice the 6 most essential tenses")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak verbs focus
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Weak Verbs")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Focus on verbs you struggle with")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") {
|
||||
startIrregularityDrill(.spelling)
|
||||
}
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") {
|
||||
startIrregularityDrill(.stemChange)
|
||||
}
|
||||
Button("Unique Irregulars (ser, ir, ...)") {
|
||||
startIrregularityDrill(.uniqueIrregular)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "wand.and.stars")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.purple)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Irregularity Drills")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Practice by irregularity type")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Session stats summary
|
||||
@@ -442,6 +347,352 @@ struct PracticeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section header
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Conjugation focus buttons (Common Tenses / Weak Verbs / Irregularity)
|
||||
|
||||
private var conjugationFocusButtons: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Common Tenses
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .commonTenses
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
subtitle: "Practice the 6 most essential tenses")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Weak Verbs
|
||||
Button {
|
||||
viewModel.practiceMode = .flashcard
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "exclamationmark.triangle", color: .red,
|
||||
title: "Weak Verbs",
|
||||
subtitle: "Focus on verbs you struggle with")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Irregularity drills
|
||||
Menu {
|
||||
Button("Spelling Changes (c→qu, z→c, ...)") { startIrregularityDrill(.spelling) }
|
||||
Button("Stem Changes (o→ue, e→ie, ...)") { startIrregularityDrill(.stemChange) }
|
||||
Button("Unique Irregulars (ser, ir, ...)") { startIrregularityDrill(.uniqueIrregular) }
|
||||
} label: {
|
||||
practiceRowLabel(icon: "wand.and.stars", color: .purple,
|
||||
title: "Irregularity Drills",
|
||||
subtitle: "Practice by irregularity type")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Vocabulary section
|
||||
|
||||
private var vocabSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Vocab Flashcards (verb pool, filtered by Settings levels)
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
subtitle: "Verb meaning → infinitive recall")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Vocab Multiple Choice (same verb pool)
|
||||
NavigationLink {
|
||||
VocabMultipleChoicePracticeView()
|
||||
} label: {
|
||||
practiceRowLabel(icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
subtitle: "Pick the Spanish infinitive from 4 options")
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
// Review Learned — consolidation cram over already-studied verbs
|
||||
NavigationLink {
|
||||
VocabFlashcardPracticeView(kind: .reviewLearned)
|
||||
} label: {
|
||||
practiceRowLabel(icon: "clock.arrow.circlepath", color: .purple,
|
||||
title: "Review Learned — 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()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "rectangle.stack.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.teal)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Vocab Review")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Review due vocabulary cards")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let dueCount = VocabReviewView.dueCount(context: cloudModelContext)
|
||||
if dueCount > 0 {
|
||||
Text("\(dueCount)")
|
||||
.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: - 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)
|
||||
.font(.title3)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Practice Session View
|
||||
|
||||
@ViewBuilder
|
||||
@@ -560,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
|
||||
|
||||
@@ -4,6 +4,8 @@ import SwiftData
|
||||
|
||||
struct SentenceBuilderView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentCard: VocabCard?
|
||||
@State private var exampleIndex: Int = 0
|
||||
@@ -316,6 +318,7 @@ struct SentenceBuilderView: View {
|
||||
if isCorrect {
|
||||
sessionCorrect += 1
|
||||
}
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
}
|
||||
|
||||
private func fetchRandomSentenceSelection() -> (card: VocabCard, exampleIndex: Int, spanish: String)? {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct StoryQuizView: View {
|
||||
let story: Story
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@@ -85,6 +89,7 @@ struct StoryQuizView: View {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
ReviewStore.recordActivity(context: cloudModelContext)
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
|
||||
@@ -0,0 +1,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)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb flashcards with two modes, switched from the toolbar:
|
||||
///
|
||||
/// - **Quiz** — the SRS path. `VocabSessionQueue` learning-step queue:
|
||||
/// tap to reveal, rate Again/Hard/Good/Easy, cards requeue, graduation
|
||||
/// feeds the long-term `VerbReviewStore` schedule.
|
||||
/// - **Learn** — no-pressure browsing. Both sides shown at once (English +
|
||||
/// Spanish + example), Next/Previous step through the same session pool
|
||||
/// on a loop. No rating, no SRS side effects.
|
||||
/// Which pool a flashcard session draws from.
|
||||
enum VocabSessionKind {
|
||||
/// Due-first + new verbs, capped — the standard SRS session. Ratings
|
||||
/// update the long-term schedule.
|
||||
case standard
|
||||
/// Verbs already studied at least once, most-recent first, uncapped — a
|
||||
/// consolidation cram. Ratings drive the in-session queue only and do NOT
|
||||
/// reschedule (the long-term SM-2 due dates are left untouched).
|
||||
case reviewLearned
|
||||
}
|
||||
|
||||
struct VocabFlashcardPracticeView: View {
|
||||
enum Mode: String { case quiz, learn }
|
||||
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage("vocabFlashcardMode") private var modeRaw: String = Mode.quiz.rawValue
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var learnIndex: Int = 0
|
||||
@State private var revealed: Bool = false
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// The session's un-graduated verbs, derived live from the quiz queue so
|
||||
/// Learn mode walks exactly what's left to learn — it stays in sync as
|
||||
/// quiz mode graduates cards rather than browsing a stale frozen pool.
|
||||
private var sessionVerbs: [Verb] {
|
||||
session?.queue.map(\.verb) ?? []
|
||||
}
|
||||
|
||||
private var mode: Mode {
|
||||
get { Mode(rawValue: modeRaw) ?? .quiz }
|
||||
nonmutating set { modeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
private var currentVerb: Verb? {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
return session?.current?.verb
|
||||
case .learn:
|
||||
guard !sessionVerbs.isEmpty else { return nil }
|
||||
return sessionVerbs[learnIndex % sessionVerbs.count]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerBar
|
||||
switch mode {
|
||||
case .quiz:
|
||||
quizContent
|
||||
case .learn:
|
||||
learnContent
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Flashcards")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Picker("Mode", selection: Binding(
|
||||
get: { mode },
|
||||
set: { mode = $0 }
|
||||
)) {
|
||||
Label("Quiz (SRS)", systemImage: "checklist").tag(Mode.quiz)
|
||||
Label("Learn", systemImage: "book").tag(Mode.learn)
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
mode == .learn ? "Learn" : "Quiz",
|
||||
systemImage: mode == .learn ? "book" : "checklist"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.onChange(of: modeRaw) { _, _ in
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
.animation(.smooth, value: revealed)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
switch mode {
|
||||
case .quiz:
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(quizProgressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .learn:
|
||||
if sessionVerbs.isEmpty {
|
||||
Text(emptyPoolMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("\(learnIndex % sessionVerbs.count + 1) of \(sessionVerbs.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if kind == .reviewLearned {
|
||||
Text("Practice pass — your review schedule won't change.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyPoolMessage: String {
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs match the levels enabled in Settings"
|
||||
case .reviewLearned:
|
||||
return "Nothing studied yet — finish a Vocab Flashcards session first"
|
||||
}
|
||||
}
|
||||
|
||||
private var quizProgressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Quiz mode
|
||||
|
||||
@ViewBuilder
|
||||
private var quizContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if revealed {
|
||||
quizRevealed(verb)
|
||||
} else {
|
||||
tapToReveal
|
||||
}
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
|
||||
private var tapToReveal: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hand.tap")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Tap to reveal")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 200)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 20))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.smooth) { revealed = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func quizRevealed(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text(completionTitle)
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label(
|
||||
kind == .reviewLearned ? "Study Again" : "Next Set",
|
||||
systemImage: kind == .reviewLearned ? "arrow.clockwise" : "arrow.right"
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionTitle: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
switch kind {
|
||||
case .standard:
|
||||
return learned > 0 ? "Session Complete" : "Nothing Due"
|
||||
case .reviewLearned:
|
||||
return learned > 0 ? "Review Complete" : "Nothing to Review"
|
||||
}
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
let noun = kind == .reviewLearned ? "reviewed" : "learned"
|
||||
return "\(learned) verb\(learned == 1 ? "" : "s") \(noun)"
|
||||
}
|
||||
switch kind {
|
||||
case .standard:
|
||||
return "No verbs are due right now. Study Again to review anyway."
|
||||
case .reviewLearned:
|
||||
return "Finish a Vocab Flashcards session first, then come back to consolidate."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Learn mode
|
||||
|
||||
@ViewBuilder
|
||||
private var learnContent: some View {
|
||||
if let verb = currentVerb {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
spanishRow(verb)
|
||||
exampleBlock(for: verb)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
learnStep(-1)
|
||||
} label: {
|
||||
Label("Previous", systemImage: "chevron.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.secondary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Button {
|
||||
learnStep(1)
|
||||
} label: {
|
||||
Label("Next", systemImage: "chevron.right")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(.purple)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Nothing to Learn",
|
||||
systemImage: "book",
|
||||
description: Text("Enable some verb levels in Settings.")
|
||||
)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
}
|
||||
|
||||
private func learnStep(_ delta: Int) {
|
||||
guard !sessionVerbs.isEmpty else { return }
|
||||
let count = sessionVerbs.count
|
||||
learnIndex = ((learnIndex + delta) % count + count) % count
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
// MARK: - Shared card pieces
|
||||
|
||||
private func spanishRow(_ verb: Verb) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title3)
|
||||
.padding(10)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func loadIfNeeded() {
|
||||
guard session == nil else { return }
|
||||
switch kind {
|
||||
case .standard:
|
||||
loadStandardSession()
|
||||
case .reviewLearned:
|
||||
let verbs = VocabVerbPool.reviewLearnedVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
}
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
/// Resume the persisted, cross-device study group if one is active;
|
||||
/// otherwise start a fresh group and persist it.
|
||||
private func loadStandardSession() {
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if let group = store.activeGroup() {
|
||||
let stored = group.entries
|
||||
if !stored.isEmpty {
|
||||
let byId = verbsByID(Set(stored.map(\.verbId)))
|
||||
let entries: [(verb: Verb, state: VocabSessionQueue.CardState)] = stored.compactMap { e in
|
||||
guard let verb = byId[e.verbId] else { return nil }
|
||||
return (verb, VocabSessionQueue.CardState(rawValue: e.state) ?? .new)
|
||||
}
|
||||
// Resume only if EVERY stored verb resolved. A partial resume
|
||||
// would desync learnedCount from a shrunken queue and then
|
||||
// persist that loss — fall through to a fresh rebuild instead.
|
||||
if entries.count == stored.count {
|
||||
session = VocabSessionQueue(entries: entries, learnedCount: group.learnedCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active group (or it couldn't be fully resolved) — start fresh and
|
||||
// persist immediately so closing the app right away still resumes this set.
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
}
|
||||
|
||||
private func verbsByID(_ ids: Set<Int>) -> [Int: Verb] {
|
||||
let all = (try? localContext.fetch(FetchDescriptor<Verb>())) ?? []
|
||||
var map: [Int: Verb] = [:]
|
||||
for verb in all where ids.contains(verb.id) { map[verb.id] = verb }
|
||||
return map
|
||||
}
|
||||
|
||||
/// Write the standard session's progress to the cloud-synced study group,
|
||||
/// or clear the group when the set is fully learned.
|
||||
private func persistGroup() {
|
||||
guard kind == .standard, let session else { return }
|
||||
let store = VocabStudyGroupStore(context: cloudContext)
|
||||
if session.isComplete {
|
||||
store.clear()
|
||||
} else {
|
||||
let entries = session.snapshot().map {
|
||||
StoredVocabEntry(verbId: $0.verbId, state: $0.state.rawValue)
|
||||
}
|
||||
store.persist(entries: entries, learnedCount: session.learnedCount)
|
||||
}
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
switch kind {
|
||||
case .standard:
|
||||
// Clear the finished group explicitly before building a fresh one,
|
||||
// so a fresh set is never appended onto a stale group record.
|
||||
VocabStudyGroupStore(context: cloudContext).clear()
|
||||
let verbs = VocabVerbPool.sessionVerbs(localContext: localContext, cloudContext: cloudContext)
|
||||
session = VocabSessionQueue(verbs: verbs)
|
||||
persistGroup()
|
||||
case .reviewLearned:
|
||||
session?.restart()
|
||||
}
|
||||
learnIndex = 0
|
||||
revealed = false
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
// Review Learned is a cram pass — graduation drives the in-session
|
||||
// queue only; the long-term SM-2 schedule is left untouched.
|
||||
if let graduation, kind == .standard {
|
||||
VerbReviewStore(context: cloudContext).rate(verbId: verbId, quality: graduation)
|
||||
}
|
||||
persistGroup()
|
||||
withAnimation(.smooth) { revealed = false }
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {
|
||||
// Silent — the example block just stays hidden.
|
||||
}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// English-first verb multiple choice, driven by `VocabSessionQueue`. 4 options
|
||||
/// (1 correct + 3 random distractors from the session pool). After answering:
|
||||
/// reveal correct/incorrect, the verb infinitive, an example sentence, and SRS
|
||||
/// rating buttons. Again/Hard requeue; a second Good or an Easy graduates.
|
||||
struct VocabMultipleChoicePracticeView: View {
|
||||
var kind: VocabSessionKind = .standard
|
||||
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var session: VocabSessionQueue?
|
||||
@State private var distractorPool: [Verb] = []
|
||||
@State private var options: [Verb] = []
|
||||
@State private var selectedOption: Verb? = nil
|
||||
@State private var exampleByVerbId: [Int: VerbExample] = [:]
|
||||
@State private var generatingVerbIds: Set<Int> = []
|
||||
@State private var speech = SpeechService()
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentVerb: Verb? { session?.current?.verb }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
progressBar
|
||||
if let verb = currentVerb {
|
||||
questionBody(verb)
|
||||
} else {
|
||||
completionView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 720)
|
||||
}
|
||||
.navigationTitle(kind == .reviewLearned ? "Review Learned" : "Vocab Multiple Choice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadIfNeeded)
|
||||
.animation(.smooth, value: selectedOption?.id)
|
||||
.animation(.smooth, value: currentVerb?.id)
|
||||
}
|
||||
|
||||
// MARK: - Progress
|
||||
|
||||
private var progressBar: some View {
|
||||
VStack(spacing: 6) {
|
||||
ProgressView(value: session?.progress ?? 0)
|
||||
.tint(.purple)
|
||||
Text(progressLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let session else { return "Loading…" }
|
||||
if session.isComplete { return "Done" }
|
||||
return "\(session.learnedCount) learned · \(session.remainingCount) to go"
|
||||
}
|
||||
|
||||
// MARK: - Question
|
||||
|
||||
@ViewBuilder
|
||||
private func questionBody(_ verb: Verb) -> some View {
|
||||
Text(verb.english)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 12)
|
||||
|
||||
if selectedOption == nil {
|
||||
optionGrid
|
||||
} else {
|
||||
revealedContent(verb)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionGrid: some View {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(options, id: \.id) { option in
|
||||
Button {
|
||||
selectedOption = option
|
||||
} label: {
|
||||
Text(option.infinitive)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func revealedContent(_ verb: Verb) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
answerFeedback(verb)
|
||||
exampleBlock(for: verb)
|
||||
ratingButtons
|
||||
}
|
||||
}
|
||||
|
||||
private func answerFeedback(_ verb: Verb) -> some View {
|
||||
let correct = (selectedOption?.id == verb.id)
|
||||
return VStack(spacing: 6) {
|
||||
Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
Text(correct ? "Correct!" : "Not quite")
|
||||
.font(.headline)
|
||||
.foregroundStyle(correct ? .green : .red)
|
||||
HStack(spacing: 10) {
|
||||
Text(verb.infinitive)
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Button {
|
||||
speech.speak(verb.infinitive)
|
||||
} label: {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.body)
|
||||
.padding(8)
|
||||
}
|
||||
.glassEffect(in: .circle)
|
||||
.accessibilityLabel("Say it out loud")
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exampleBlock(for verb: Verb) -> some View {
|
||||
if let example = exampleByVerbId[verb.id] {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(example.spanish).font(.subheadline).italic()
|
||||
Text(example.english).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
} else if generatingVerbIds.contains(verb.id) {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Generating example…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private var ratingButtons: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("How well did you know it?")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
ratingButton("Again", color: .red, rating: .again)
|
||||
ratingButton("Hard", color: .orange, rating: .hard)
|
||||
ratingButton("Good", color: .green, rating: .good)
|
||||
ratingButton("Easy", color: .blue, rating: .easy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ratingButton(_ label: String, color: Color, rating: VocabSessionQueue.Rating) -> some View {
|
||||
Button {
|
||||
answer(rating)
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.tint(color)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Completion
|
||||
|
||||
private var completionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.green)
|
||||
Text((session?.learnedCount ?? 0) > 0 ? "Session Complete" : "Nothing Due")
|
||||
.font(.title2.bold())
|
||||
Text(completionDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
studyAgain()
|
||||
} label: {
|
||||
Label("Study Again", systemImage: "arrow.clockwise")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
private var completionDetail: String {
|
||||
let learned = session?.learnedCount ?? 0
|
||||
if learned > 0 {
|
||||
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: [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()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func studyAgain() {
|
||||
session?.restart()
|
||||
selectedOption = nil
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func prepareOptions() {
|
||||
guard let verb = currentVerb else { options = []; return }
|
||||
let candidates = distractorPool.filter { $0.id != verb.id }
|
||||
let distractors = Array(candidates.shuffled().prefix(3))
|
||||
options = ([verb] + distractors).shuffled()
|
||||
}
|
||||
|
||||
private func answer(_ rating: VocabSessionQueue.Rating) {
|
||||
guard let verbId = currentVerb?.id else { return }
|
||||
let graduation = session?.answer(rating) ?? nil
|
||||
// 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
|
||||
prepareOptions()
|
||||
primeExampleForCurrent()
|
||||
}
|
||||
|
||||
private func primeExampleForCurrent() {
|
||||
guard let verb = currentVerb else { return }
|
||||
let verbId = verb.id
|
||||
if exampleByVerbId[verbId] != nil || generatingVerbIds.contains(verbId) { return }
|
||||
if let cached = exampleCache.examples(for: verbId)?.first {
|
||||
exampleByVerbId[verbId] = cached
|
||||
return
|
||||
}
|
||||
guard VerbExampleGenerator.isAvailable else { return }
|
||||
generatingVerbIds.insert(verbId)
|
||||
let infinitive = verb.infinitive
|
||||
let english = verb.english
|
||||
let formsByTense = ReferenceStore(context: localContext)
|
||||
.conjugatedForms(verbId: verbId, tenseIds: VocabExampleTenseIds.canonical)
|
||||
Task {
|
||||
do {
|
||||
let examples = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: infinitive,
|
||||
verbEnglish: english,
|
||||
tenseIds: VocabExampleTenseIds.canonical,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
exampleCache.setExamples(examples, for: verbId)
|
||||
let pick = examples.first { $0.tenseId == "ind_presente" } ?? examples.first
|
||||
if let pick, currentVerb?.id == verbId {
|
||||
exampleByVerbId[verbId] = pick
|
||||
}
|
||||
} catch {}
|
||||
generatingVerbIds.remove(verbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ struct VocabReviewView: View {
|
||||
private func rate(quality: ReviewQuality) {
|
||||
guard let card = dueCards[safe: currentIndex] else { return }
|
||||
|
||||
ReviewStore.recordActivity(context: cloudContext)
|
||||
let store = CourseReviewStore(context: cloudContext)
|
||||
let result = SRSEngine.review(
|
||||
quality: quality,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
/// Lists downloaded YouTube videos with per-item deletion and total-size
|
||||
/// summary (Issue #21, phase 4). Files live in the local (non-synced)
|
||||
/// SwiftData container and the app's documents directory.
|
||||
struct DownloadedVideosView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query(sort: \DownloadedVideo.downloadedAt, order: .reverse)
|
||||
private var downloads: [DownloadedVideo]
|
||||
|
||||
@State private var downloadService = VideoDownloadService.shared
|
||||
@State private var confirmDeleteAll = false
|
||||
|
||||
private var totalBytes: Int {
|
||||
downloads.reduce(0) { $0 + $1.byteCount }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if downloads.isEmpty {
|
||||
Section {
|
||||
ContentUnavailableView(
|
||||
"No downloads",
|
||||
systemImage: "arrow.down.to.line",
|
||||
description: Text("Tap Download on any guide or grammar video to save it for offline viewing.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
LabeledContent("Total size", value: sizeString(totalBytes))
|
||||
if totalBytes > 500_000_000 {
|
||||
Label("Downloads exceed 500 MB", systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Videos") {
|
||||
ForEach(downloads) { download in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(download.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(2)
|
||||
HStack {
|
||||
Text(sizeString(download.byteCount))
|
||||
Text("·")
|
||||
Text(download.downloadedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
confirmDeleteAll = true
|
||||
} label: {
|
||||
Label("Delete all downloads", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Downloaded Videos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"Delete all \(downloads.count) downloaded videos?",
|
||||
isPresented: $confirmDeleteAll,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete All", role: .destructive) {
|
||||
for download in downloads {
|
||||
downloadService.delete(videoId: download.videoId, modelContext: modelContext)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
private func sizeString(_ bytes: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
.modelContainer(for: DownloadedVideo.self, inMemory: true)
|
||||
}
|
||||
@@ -3,15 +3,17 @@ import SwiftUI
|
||||
struct FeatureReferenceView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Verb Conjugation Practice") {
|
||||
// MARK: Conjugation
|
||||
|
||||
Section("Practice — Conjugation") {
|
||||
featureRow(
|
||||
icon: "rectangle.stack", color: .blue,
|
||||
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
|
||||
details: [
|
||||
"Pulls from verb conjugation database (1,750 verbs)",
|
||||
"Pulls from the verb conjugation database (1,750 verbs)",
|
||||
"Filtered by your Level setting",
|
||||
"Filtered by your Enabled Tenses",
|
||||
"Respects Include Vosotros setting",
|
||||
"Respects the Include Vosotros setting",
|
||||
"Due cards (SRS) shown first, then random",
|
||||
]
|
||||
)
|
||||
@@ -21,13 +23,11 @@ struct FeatureReferenceView: View {
|
||||
title: "Full Table",
|
||||
details: [
|
||||
"Shows all 6 person forms for one verb + tense",
|
||||
"Random verb from your Level",
|
||||
"Drawn from any regular verb — Level filter is ignored here on purpose, since regular conjugation patterns transfer across vocabulary",
|
||||
"Random tense from your Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Quick Actions") {
|
||||
featureRow(
|
||||
icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
@@ -57,20 +57,86 @@ struct FeatureReferenceView: View {
|
||||
"Filtered by your Level and Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Vocabulary
|
||||
|
||||
Section("Practice — Vocabulary") {
|
||||
featureRow(
|
||||
icon: "rectangle.on.rectangle.angled", color: .purple,
|
||||
title: "Vocab Flashcards",
|
||||
details: [
|
||||
"English meaning → recall the Spanish verb",
|
||||
"Pool = verbs at your enabled Levels (the same Level set the Verbs tab filters by)",
|
||||
"Session size set by Settings → Cards per session",
|
||||
"Overdue verbs pulled first, then new verbs by frequency",
|
||||
"Quiz mode: tap to reveal, rate Again/Hard/Good/Easy. Again/Hard requeue the card a few cards later; a second Good or an Easy graduates it. Graduation updates the long-term SRS schedule.",
|
||||
"Learn mode (toolbar toggle): both sides shown at once, Next/Previous to browse, loops — no rating, no pressure",
|
||||
"Example sentence generated on-device; speaker button reads the verb aloud",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checklist", color: .purple,
|
||||
title: "Vocab Multiple Choice",
|
||||
details: [
|
||||
"English meaning → pick the Spanish verb from 4 options",
|
||||
"Distractors prefer the same part of speech",
|
||||
"Same level-filtered pool and SRS session queue as Vocab Flashcards",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "rectangle.stack.fill", color: .teal,
|
||||
title: "Vocab Review",
|
||||
details: [
|
||||
"Reviews vocabulary cards that are due (SRS scheduled)",
|
||||
"Reviews course/textbook vocabulary cards that are due (SRS scheduled)",
|
||||
"Cards become due after you study them in Course quizzes",
|
||||
"Rate Again/Hard/Good/Easy to schedule next review",
|
||||
"Uses all course vocabulary, not filtered by level",
|
||||
"Rate Again/Hard/Good/Easy to schedule the next review",
|
||||
"Distinct from Vocab Flashcards — this is course vocab, not the verb table",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Practice Activities") {
|
||||
// MARK: Reading
|
||||
|
||||
Section("Practice — Reading") {
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for a definition",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "books.vertical.fill", color: .indigo,
|
||||
title: "Books",
|
||||
details: [
|
||||
"Full-length bilingual books bundled with the app",
|
||||
"Chapter list; read a chapter paragraph by paragraph",
|
||||
"Tap any word for a definition (offline dictionary, on-device AI fallback)",
|
||||
"Toggle between Spanish and pre-translated English",
|
||||
"Read-aloud: a voice reads the chapter with the current word highlighted; tap a word to pause and look it up",
|
||||
"Voice and speed picker in the read-aloud controls",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English",
|
||||
"Long-press a word for a definition",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "bubble.left.and.bubble.right.fill", color: .green,
|
||||
title: "Conversation",
|
||||
@@ -79,8 +145,7 @@ struct FeatureReferenceView: View {
|
||||
"10 scenario types (restaurant, directions, etc.)",
|
||||
"AI adapts vocabulary to your Level setting",
|
||||
"Corrections provided inline when you make mistakes",
|
||||
"Conversations saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Requires an Apple Intelligence-capable device",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -91,8 +156,6 @@ struct FeatureReferenceView: View {
|
||||
"Listen & Type: hear a sentence, type what you heard",
|
||||
"Pronunciation: read a sentence aloud, get scored on accuracy",
|
||||
"Sentences pulled from course vocabulary examples",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
"Pronunciation requires microphone permission",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -101,50 +164,23 @@ struct FeatureReferenceView: View {
|
||||
title: "Cloze Practice",
|
||||
details: [
|
||||
"Fill in the missing word in a Spanish sentence",
|
||||
"Sentences from course vocabulary examples",
|
||||
"4 multiple-choice options (1 correct + 3 distractors)",
|
||||
"Distractors are other vocabulary words from same pool",
|
||||
"Uses all course vocab (not filtered by level)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "music.note.list", color: .pink,
|
||||
title: "Lyrics",
|
||||
details: [
|
||||
"Search and save Spanish song lyrics",
|
||||
"Side-by-side Spanish and English translations",
|
||||
"User-curated library, not filtered by level",
|
||||
"Saved to iCloud for sync across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "book.fill", color: .teal,
|
||||
title: "Stories",
|
||||
details: [
|
||||
"AI-generated one-paragraph Spanish stories",
|
||||
"Matched to your Level and Enabled Tenses",
|
||||
"Every word is tappable for definition",
|
||||
"Known words use offline dictionary (175K+ verb forms)",
|
||||
"Unknown words looked up via on-device AI",
|
||||
"English translation hidden by default (toggle to reveal)",
|
||||
"3-question comprehension quiz at the end",
|
||||
"Saved to iCloud for revisiting",
|
||||
"Requires Apple Intelligence-capable device",
|
||||
"Sentences from course vocabulary examples",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Guide
|
||||
|
||||
Section("Guide") {
|
||||
featureRow(
|
||||
icon: "book", color: .brown,
|
||||
title: "Tense Guides",
|
||||
details: [
|
||||
"Detailed explanation of each of the 20 verb tenses",
|
||||
"Conjugation ending tables for -ar, -er, -ir verbs",
|
||||
"Usage patterns with example sentences",
|
||||
"Essential tenses marked with orange badge",
|
||||
"In-depth guide to each of the 20 verb tenses",
|
||||
"Conjugation ending tables, common irregulars, mnemonics",
|
||||
"Usage patterns, pitfalls, and contrast with neighbouring tenses",
|
||||
"Essential tenses marked with an orange badge",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -152,14 +188,24 @@ struct FeatureReferenceView: View {
|
||||
icon: "doc.text", color: .brown,
|
||||
title: "Grammar Notes",
|
||||
details: [
|
||||
"23 grammar topics (ser vs estar, por vs para, etc.)",
|
||||
"Interactive exercises available for 5 topics",
|
||||
"Tap 'Practice This' on notes that have exercises",
|
||||
"Content grouped by category with card-based layout",
|
||||
"36 grammar topics (ser vs estar, por vs para, WEIRDO, etc.)",
|
||||
"Each with a mnemonic, contrast examples, and common pitfalls",
|
||||
"Interactive exercises available on selected topics",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "arrow.triangle.branch", color: .indigo,
|
||||
title: "Cross-links",
|
||||
details: [
|
||||
"Tense guides show \"Related grammar\" chips that jump to the matching grammar note",
|
||||
"Grammar notes show \"Used in tenses\" chips that jump back",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Course
|
||||
|
||||
Section("Course") {
|
||||
featureRow(
|
||||
icon: "list.clipboard", color: .orange,
|
||||
@@ -167,11 +213,21 @@ struct FeatureReferenceView: View {
|
||||
details: [
|
||||
"Vocabulary from specific course weeks",
|
||||
"Multiple quiz types: MC, typing, handwriting, cloze",
|
||||
"Focus Area mode for missed words",
|
||||
"Not filtered by Level (uses course structure)",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "star.circle.fill", color: .yellow,
|
||||
title: "Extra Study",
|
||||
details: [
|
||||
"Star a card during a course flashcard session to mark it for extra study",
|
||||
"Each week shows an \"Extra Study\" row when it has starred cards",
|
||||
"Launches a session of just the starred cards for that week",
|
||||
"Marks are iCloud-synced across devices",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "checkmark.seal", color: .orange,
|
||||
title: "Checkpoint Exams",
|
||||
@@ -183,23 +239,27 @@ struct FeatureReferenceView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Dashboard
|
||||
|
||||
Section("Dashboard") {
|
||||
featureRow(
|
||||
icon: "clock.fill", color: .mint,
|
||||
title: "Study Time",
|
||||
details: [
|
||||
"Tracks time the app is in the foreground",
|
||||
"Starts when app becomes active, stops on background",
|
||||
"Shows today's time and all-time total",
|
||||
"Shows today's time and an all-time total",
|
||||
"7-day bar chart of daily study time",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
Section("Settings That Affect Practice") {
|
||||
settingRow(name: "Level", affects: "Verb practice, Full Table, Quick Actions, Stories, Conversation")
|
||||
settingRow(name: "Enabled Tenses", affects: "Verb practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Verb practice, Full Table, Quick Actions")
|
||||
settingRow(name: "Level", affects: "Conjugation practice, Vocab Flashcards & Multiple Choice, Stories, Conversation. Shared with the Verbs tab filter. Full Table ignores level.")
|
||||
settingRow(name: "Enabled Tenses", affects: "Conjugation practice, Full Table, Irregularity Drills, Stories")
|
||||
settingRow(name: "Include Vosotros", affects: "Conjugation practice, Full Table, Common Tenses")
|
||||
settingRow(name: "Cards per session", affects: "How many verbs a Vocab Flashcards / Multiple Choice session draws")
|
||||
settingRow(name: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,37 @@ import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
/// Local reference store (verbs, lexemes) — needed for the status metrics.
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@State private var progress: UserProgress?
|
||||
|
||||
@State private var dailyGoal: Double = 50
|
||||
@State private var showVosotros: Bool = true
|
||||
@State private var autoFillStem: Bool = false
|
||||
@State private var selectedLevel: VerbLevel = .basic
|
||||
|
||||
/// 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]
|
||||
|
||||
/// New (never-studied) words introduced per session, per type. 0 =
|
||||
/// review-only; 999 = "All" (no throttle).
|
||||
@AppStorage("vocabNewWordsPerSession") private var vocabNewWordsPerSession: Int = 10
|
||||
@AppStorage("nounNewWordsPerSession") private var nounNewWordsPerSession: Int = 10
|
||||
@AppStorage("adjectiveNewWordsPerSession") private var adjectiveNewWordsPerSession: Int = 10
|
||||
private let newWordsSizes: [Int] = [0, 5, 10, 15, 20, 999]
|
||||
|
||||
/// SRS status breakdowns, scoped to the enabled levels. Recomputed on
|
||||
/// appear and whenever the level toggles change.
|
||||
@State private var verbStatus = StatusCounts()
|
||||
@State private var nounStatus = StatusCounts()
|
||||
@State private var adjectiveStatus = StatusCounts()
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private let irregularCategories: [IrregularSpan.SpanCategory] = [
|
||||
.spelling, .stemChange, .uniqueIrregular
|
||||
]
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
@@ -40,19 +63,87 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Level") {
|
||||
Picker("Current Level", selection: $selectedLevel) {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Text(level.displayName).tag(level)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedLevel) { _, newValue in
|
||||
progress?.selectedVerbLevel = newValue
|
||||
saveProgress()
|
||||
}
|
||||
Section {
|
||||
sessionSizePicker("Verbs per session", selection: $vocabSessionCardLimit)
|
||||
sessionSizePicker("Nouns per session", selection: $nounSessionCardLimit)
|
||||
sessionSizePicker("Adjectives per session", selection: $adjectiveSessionCardLimit)
|
||||
} header: {
|
||||
Text("Cards Per Session")
|
||||
} footer: {
|
||||
Text("How many cards each flashcard or multiple-choice session draws, per word type. Overdue cards are pulled first, then new ones.")
|
||||
}
|
||||
|
||||
Section("Tenses") {
|
||||
Section {
|
||||
newWordsPicker("New verbs per session", selection: $vocabNewWordsPerSession)
|
||||
newWordsPicker("New nouns per session", selection: $nounNewWordsPerSession)
|
||||
newWordsPicker("New adjectives per session", selection: $adjectiveNewWordsPerSession)
|
||||
} header: {
|
||||
Text("New Words Per Session")
|
||||
} footer: {
|
||||
Text("How many brand-new words a session introduces. Overdue reviews are shown first; new words fill whatever room is left, up to this number. 0 = reviews only.")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Toggle(level.displayName, isOn: Binding(
|
||||
get: {
|
||||
progress?.selectedVerbLevels.contains(level) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
saveProgress()
|
||||
refreshMetrics()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Verb Levels")
|
||||
} footer: {
|
||||
Text("Practice pulls only from verbs whose level is enabled. Turn on multiple to mix.")
|
||||
}
|
||||
|
||||
Section {
|
||||
statusRows(verbStatus)
|
||||
} header: {
|
||||
Text("Verb Status")
|
||||
} footer: {
|
||||
Text("Counts reflect only the verb levels enabled above.")
|
||||
}
|
||||
|
||||
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()
|
||||
refreshMetrics()
|
||||
}
|
||||
))
|
||||
}
|
||||
} 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 {
|
||||
statusRows(nounStatus)
|
||||
} header: {
|
||||
Text("Noun Status")
|
||||
} footer: {
|
||||
Text("Counts reflect only the vocabulary levels enabled above.")
|
||||
}
|
||||
|
||||
Section("Adjective Status") {
|
||||
statusRows(adjectiveStatus)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
Toggle(tense.english, isOn: Binding(
|
||||
get: {
|
||||
@@ -65,6 +156,41 @@ struct SettingsView: View {
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Tenses")
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(irregularCategories, id: \.self) { category in
|
||||
Toggle(category.rawValue, isOn: Binding(
|
||||
get: {
|
||||
progress?.enabledIrregularCategories.contains(category) ?? false
|
||||
},
|
||||
set: { enabled in
|
||||
guard let progress else { return }
|
||||
progress.setIrregularCategoryEnabled(category, enabled: enabled)
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
}
|
||||
} header: {
|
||||
Text("Irregular Types")
|
||||
} footer: {
|
||||
Text("Leave all off to include regular and irregular verbs. Enable any to restrict practice to those irregularity types.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Reflexive verbs only", isOn: Binding(
|
||||
get: { progress?.showReflexiveVerbsOnly ?? false },
|
||||
set: { enabled in
|
||||
progress?.showReflexiveVerbsOnly = enabled
|
||||
saveProgress()
|
||||
}
|
||||
))
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
Text("When on, practice pulls only from the curated list of common reflexive verbs.")
|
||||
}
|
||||
|
||||
Section("Stats") {
|
||||
@@ -79,6 +205,9 @@ struct SettingsView: View {
|
||||
NavigationLink("How Features Work") {
|
||||
FeatureReferenceView()
|
||||
}
|
||||
NavigationLink("Downloaded Videos") {
|
||||
DownloadedVideosView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
@@ -90,13 +219,61 @@ 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 newWordsPicker(_ title: String, selection: Binding<Int>) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(newWordsSizes, id: \.self) { size in
|
||||
Text(size == 999 ? "All" : "\(size)").tag(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusRows(_ counts: StatusCounts) -> some View {
|
||||
LabeledContent("New", value: "\(counts.new)")
|
||||
LabeledContent("Overdue", value: "\(counts.overdue)")
|
||||
LabeledContent("Due today", value: "\(counts.dueToday)")
|
||||
LabeledContent("Upcoming", value: "\(counts.upcoming)")
|
||||
LabeledContent("Learned", value: "\(counts.learned)")
|
||||
LabeledContent("Total", value: "\(counts.total)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress = resolved
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
autoFillStem = resolved.autoFillStem
|
||||
selectedLevel = resolved.selectedVerbLevel
|
||||
refreshMetrics()
|
||||
}
|
||||
|
||||
private func refreshMetrics() {
|
||||
guard let progress else { return }
|
||||
verbStatus = WordStatusMetrics.verbCounts(
|
||||
selectedLevels: progress.selectedVerbLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
nounStatus = WordStatusMetrics.lexemeCounts(
|
||||
partOfSpeech: "noun",
|
||||
selectedLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
adjectiveStatus = WordStatusMetrics.lexemeCounts(
|
||||
partOfSpeech: "adjective",
|
||||
selectedLevels: progress.selectedLexemeLevels,
|
||||
localContext: localContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
|
||||
@@ -4,14 +4,40 @@ import SwiftData
|
||||
|
||||
struct VerbDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(VerbExampleCache.self) private var exampleCache
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var speechService = SpeechService()
|
||||
let verb: Verb
|
||||
@State private var selectedTense: TenseInfo = TenseInfo.all[0]
|
||||
|
||||
@State private var examples: [VerbExample] = []
|
||||
@State private var examplesState: ExamplesState = .idle
|
||||
|
||||
private enum ExamplesState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case unavailable
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private static let exampleTenseIds: [String] = [
|
||||
TenseID.ind_presente.rawValue,
|
||||
TenseID.ind_preterito.rawValue,
|
||||
TenseID.ind_imperfecto.rawValue,
|
||||
TenseID.ind_futuro.rawValue,
|
||||
TenseID.subj_presente.rawValue,
|
||||
TenseID.imp_afirmativo.rawValue,
|
||||
]
|
||||
|
||||
private var formsForTense: [VerbForm] {
|
||||
ReferenceStore(context: modelContext).fetchForms(verbId: verb.id, tenseId: selectedTense.id)
|
||||
}
|
||||
|
||||
private var reflexiveEntries: [ReflexiveVerb] {
|
||||
reflexiveStore.entries(for: verb.infinitive)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -25,6 +51,10 @@ struct VerbDetailView: View {
|
||||
Text("Info")
|
||||
}
|
||||
|
||||
if !reflexiveEntries.isEmpty {
|
||||
reflexiveSection
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Tense", selection: $selectedTense) {
|
||||
ForEach(TenseInfo.all) { tense in
|
||||
@@ -66,6 +96,8 @@ struct VerbDetailView: View {
|
||||
} header: {
|
||||
Text("Conjugation")
|
||||
}
|
||||
|
||||
examplesSection
|
||||
}
|
||||
.navigationTitle(verb.infinitive)
|
||||
.toolbar {
|
||||
@@ -78,6 +110,129 @@ struct VerbDetailView: View {
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
.task(id: verb.id) {
|
||||
await loadExamples()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reflexive
|
||||
|
||||
private var reflexiveSection: some View {
|
||||
Section {
|
||||
ForEach(Array(reflexiveEntries.enumerated()), id: \.offset) { _, entry in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(entry.infinitive)
|
||||
.font(.body.weight(.semibold))
|
||||
.italic()
|
||||
if let hint = entry.usageHint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
Text(entry.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
} header: {
|
||||
Text("Reflexive")
|
||||
} footer: {
|
||||
if reflexiveEntries.contains(where: { $0.usageHint != nil }) {
|
||||
Text("Highlighted words are prepositions or phrases this verb commonly pairs with.")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Examples
|
||||
|
||||
@ViewBuilder
|
||||
private var examplesSection: some View {
|
||||
Section {
|
||||
switch examplesState {
|
||||
case .idle, .loading:
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Generating examples…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .unavailable:
|
||||
Label("Examples require Apple Intelligence on this device.", systemImage: "sparkles")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .failed(let message):
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case .loaded:
|
||||
if examples.isEmpty {
|
||||
Label("No examples available.", systemImage: "text.quote")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(examples.enumerated()), id: \.offset) { _, example in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let info = TenseInfo.find(example.tenseId) {
|
||||
Text(info.english)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text(example.spanish)
|
||||
.font(.body)
|
||||
.italic()
|
||||
Text(example.english)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Examples")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExamples() async {
|
||||
// Reset state when navigating between verbs via NavigationSplitView.
|
||||
examples = []
|
||||
examplesState = .idle
|
||||
|
||||
if let cached = exampleCache.examples(for: verb.id), !cached.isEmpty {
|
||||
examples = cached
|
||||
examplesState = .loaded
|
||||
return
|
||||
}
|
||||
|
||||
guard VerbExampleGenerator.isAvailable else {
|
||||
examplesState = .unavailable
|
||||
return
|
||||
}
|
||||
|
||||
examplesState = .loading
|
||||
do {
|
||||
let formsByTense = ReferenceStore(context: modelContext)
|
||||
.conjugatedForms(verbId: verb.id, tenseIds: Self.exampleTenseIds)
|
||||
let generated = try await VerbExampleGenerator.generate(
|
||||
verbInfinitive: verb.infinitive,
|
||||
verbEnglish: verb.english,
|
||||
tenseIds: Self.exampleTenseIds,
|
||||
formsByTense: formsByTense
|
||||
)
|
||||
guard !generated.isEmpty else {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
return
|
||||
}
|
||||
exampleCache.setExamples(generated, for: verb.id)
|
||||
examples = generated
|
||||
examplesState = .loaded
|
||||
} catch {
|
||||
examplesState = .failed("Could not generate examples.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +241,6 @@ struct VerbDetailView: View {
|
||||
VerbDetailView(verb: Verb(id: 1, infinitive: "hablar", english: "to speak", rank: 1, ending: "ar", reflexive: 0, level: "basic"))
|
||||
}
|
||||
.modelContainer(for: [Verb.self, VerbForm.self], inMemory: true)
|
||||
.environment(VerbExampleCache())
|
||||
.environment(ReflexiveVerbStore())
|
||||
}
|
||||
|
||||
@@ -2,17 +2,67 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
enum IrregularityCategory: String, CaseIterable, Identifiable {
|
||||
case anyIrregular = "Any Irregular"
|
||||
case spelling = "Spelling Change"
|
||||
case stemChange = "Stem Change"
|
||||
case uniqueIrregular = "Unique Irregular"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .anyIrregular: "asterisk"
|
||||
case .spelling: "character.cursor.ibeam"
|
||||
case .stemChange: "arrow.triangle.2.circlepath"
|
||||
case .uniqueIrregular: "star"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VerbListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(ReflexiveVerbStore.self) private var reflexiveStore
|
||||
@State private var verbs: [Verb] = []
|
||||
@State private var irregularityByVerbId: [Int: Set<IrregularityCategory>] = [:]
|
||||
@State private var searchText = ""
|
||||
@State private var selectedLevel: String?
|
||||
@State private var progress: UserProgress?
|
||||
@State private var selectedIrregularity: IrregularityCategory?
|
||||
@State private var reflexiveOnly: Bool = false
|
||||
@State private var selectedVerb: Verb?
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
/// Levels currently enabled in `UserProgress` — the same set that drives
|
||||
/// what Practice picks from. The Verbs tab reads and writes the same
|
||||
/// state so changes here propagate to Practice and vice versa.
|
||||
private var selectedLevels: Set<VerbLevel> {
|
||||
progress?.selectedVerbLevels ?? []
|
||||
}
|
||||
|
||||
/// True when the user has every available level enabled (or none, which
|
||||
/// we treat as "no filter applied" on the Verbs list specifically).
|
||||
private var allLevelsActive: Bool {
|
||||
selectedLevels.isEmpty || selectedLevels.count == VerbLevel.allCases.count
|
||||
}
|
||||
|
||||
private var filteredVerbs: [Verb] {
|
||||
var result = verbs
|
||||
if let level = selectedLevel {
|
||||
result = result.filter { VerbLevelGroup.matches($0.level, selectedLevel: level) }
|
||||
if !allLevelsActive {
|
||||
let activeRaws = Set(selectedLevels.map(\.rawValue))
|
||||
result = result.filter { verb in
|
||||
activeRaws.contains { VerbLevelGroup.matches(verb.level, selectedLevel: $0) }
|
||||
}
|
||||
}
|
||||
if let category = selectedIrregularity {
|
||||
result = result.filter { verb in
|
||||
guard let cats = irregularityByVerbId[verb.id] else { return false }
|
||||
return category == .anyIrregular ? !cats.isEmpty : cats.contains(category)
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
result = result.filter { reflexiveStore.isReflexive(baseInfinitive: $0.infinitive) }
|
||||
}
|
||||
if !searchText.isEmpty {
|
||||
let query = searchText.lowercased()
|
||||
@@ -24,31 +74,75 @@ struct VerbListView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
private let levels = ["basic", "elementary", "intermediate", "advanced", "expert"]
|
||||
private let levels: [VerbLevel] = VerbLevel.allCases
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(filteredVerbs, selection: $selectedVerb) { verb in
|
||||
NavigationLink(value: verb) {
|
||||
VerbRowView(verb: verb)
|
||||
VerbRowView(verb: verb, irregularities: irregularityByVerbId[verb.id] ?? [])
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verbs")
|
||||
.searchable(text: $searchText, prompt: "Search verbs...")
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
if hasActiveFilter {
|
||||
activeFilterBar
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("All Levels") { selectedLevel = nil }
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button(level.capitalized) { selectedLevel = level }
|
||||
Section("Level") {
|
||||
Button {
|
||||
setAllLevels(enabled: true)
|
||||
} label: {
|
||||
Label("All Levels", systemImage: allLevelsActive ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(levels, id: \.self) { level in
|
||||
Button {
|
||||
toggleLevel(level)
|
||||
} label: {
|
||||
Label(level.displayName, systemImage: selectedLevels.contains(level) ? "checkmark" : "circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Irregularity") {
|
||||
Button {
|
||||
selectedIrregularity = nil
|
||||
} label: {
|
||||
Label("All Verbs", systemImage: selectedIrregularity == nil ? "checkmark" : "circle")
|
||||
}
|
||||
ForEach(IrregularityCategory.allCases) { category in
|
||||
Button {
|
||||
selectedIrregularity = category
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: selectedIrregularity == category ? "checkmark" : category.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Reflexive") {
|
||||
Button {
|
||||
reflexiveOnly.toggle()
|
||||
} label: {
|
||||
Label("Reflexive verbs only", systemImage: reflexiveOnly ? "checkmark" : "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Filter", systemImage: "line.3.horizontal.decrease.circle")
|
||||
Label("Filter", systemImage: hasActiveFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadVerbs() }
|
||||
.onAppear { loadVerbs() }
|
||||
.task {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
.onAppear {
|
||||
loadVerbs()
|
||||
loadProgress()
|
||||
}
|
||||
} detail: {
|
||||
if let verb = selectedVerb {
|
||||
VerbDetailView(verb: verb)
|
||||
@@ -58,6 +152,56 @@ struct VerbListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var hasActiveFilter: Bool {
|
||||
!allLevelsActive || selectedIrregularity != nil || reflexiveOnly
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var activeFilterBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
if !allLevelsActive {
|
||||
filterChip(text: levelChipLabel, systemImage: "graduationcap") {
|
||||
setAllLevels(enabled: true)
|
||||
}
|
||||
}
|
||||
if let cat = selectedIrregularity {
|
||||
filterChip(text: cat.rawValue, systemImage: cat.systemImage) {
|
||||
selectedIrregularity = nil
|
||||
}
|
||||
}
|
||||
if reflexiveOnly {
|
||||
filterChip(text: "Reflexive", systemImage: "arrow.triangle.2.circlepath") {
|
||||
reflexiveOnly = false
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(filteredVerbs.count)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private func filterChip(text: String, systemImage: String, onClear: @escaping () -> Void) -> some View {
|
||||
Button(action: onClear) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.caption2)
|
||||
Text(text)
|
||||
.font(.caption.weight(.medium))
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption2)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(.blue.opacity(0.15), in: Capsule())
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func loadVerbs() {
|
||||
// Hit the shared local container directly, bypassing @Environment.
|
||||
guard let container = SharedStore.localContainer else {
|
||||
@@ -69,12 +213,64 @@ struct VerbListView: View {
|
||||
}
|
||||
let context = ModelContext(container)
|
||||
verbs = ReferenceStore(context: context).fetchVerbs()
|
||||
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
|
||||
irregularityByVerbId = buildIrregularityIndex(context: context)
|
||||
print("[VerbListView] loaded \(verbs.count) verbs, \(irregularityByVerbId.count) flagged irregular (container: \(ObjectIdentifier(container)))")
|
||||
}
|
||||
|
||||
// MARK: - Level filter (shared with Practice via UserProgress)
|
||||
|
||||
private var levelChipLabel: String {
|
||||
let names = selectedLevels
|
||||
.sorted { $0.rawValue < $1.rawValue }
|
||||
.map(\.displayName)
|
||||
if names.isEmpty { return "No levels" }
|
||||
if names.count == 1 { return names[0] }
|
||||
return "\(names.count) levels"
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
}
|
||||
|
||||
private func toggleLevel(_ level: VerbLevel) {
|
||||
guard let progress else { return }
|
||||
let enabled = !progress.selectedVerbLevels.contains(level)
|
||||
progress.setLevelEnabled(level, enabled: enabled)
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func setAllLevels(enabled: Bool) {
|
||||
guard let progress else { return }
|
||||
if enabled {
|
||||
progress.selectedVerbLevels = Set(VerbLevel.allCases)
|
||||
} else {
|
||||
// Practice treats an empty set as "no verbs", so guard against
|
||||
// leaving the user with nothing — keep at least `basic`.
|
||||
progress.selectedVerbLevels = [.basic]
|
||||
}
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func buildIrregularityIndex(context: ModelContext) -> [Int: Set<IrregularityCategory>] {
|
||||
let spans = (try? context.fetch(FetchDescriptor<IrregularSpan>())) ?? []
|
||||
var index: [Int: Set<IrregularityCategory>] = [:]
|
||||
for span in spans {
|
||||
let category: IrregularityCategory
|
||||
switch span.spanType {
|
||||
case 100..<200: category = .spelling
|
||||
case 200..<300: category = .stemChange
|
||||
case 300..<400: category = .uniqueIrregular
|
||||
default: continue
|
||||
}
|
||||
index[span.verbId, default: []].insert(category)
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
struct VerbRowView: View {
|
||||
let verb: Verb
|
||||
var irregularities: Set<IrregularityCategory> = []
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
@@ -88,14 +284,39 @@ struct VerbRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(verb.level.prefix(3).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.clipShape(Capsule())
|
||||
HStack(spacing: 4) {
|
||||
ForEach(orderedIrregularities, id: \.self) { cat in
|
||||
Image(systemName: cat.systemImage)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(irregularityColor(cat))
|
||||
.help(cat.rawValue)
|
||||
.accessibilityLabel(cat.rawValue)
|
||||
}
|
||||
|
||||
Text(verb.level.prefix(3).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(levelColor(verb.level).opacity(0.15))
|
||||
.foregroundStyle(levelColor(verb.level))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedIrregularities: [IrregularityCategory] {
|
||||
// Order: unique > stem > spelling (most notable first)
|
||||
let order: [IrregularityCategory] = [.uniqueIrregular, .stemChange, .spelling]
|
||||
return order.filter { irregularities.contains($0) }
|
||||
}
|
||||
|
||||
private func irregularityColor(_ category: IrregularityCategory) -> Color {
|
||||
switch category {
|
||||
case .uniqueIrregular: return .purple
|
||||
case .stemChange: return .orange
|
||||
case .spelling: return .teal
|
||||
case .anyIrregular: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
[
|
||||
{"infinitive": "aburrirse", "baseInfinitive": "aburrir", "english": "to get bored"},
|
||||
{"infinitive": "acercarse", "baseInfinitive": "acercar", "english": "to get close to", "usageHint": "a"},
|
||||
{"infinitive": "acordarse", "baseInfinitive": "acordar", "english": "to remember", "usageHint": "de"},
|
||||
{"infinitive": "acostarse", "baseInfinitive": "acostar", "english": "to lay down / to go to bed"},
|
||||
{"infinitive": "acostumbrarse", "baseInfinitive": "acostumbrar", "english": "to get used to", "usageHint": "a"},
|
||||
{"infinitive": "afeitarse", "baseInfinitive": "afeitar", "english": "to shave"},
|
||||
{"infinitive": "alegrarse", "baseInfinitive": "alegrar", "english": "to be glad / happy / pleased"},
|
||||
{"infinitive": "alejarse", "baseInfinitive": "alejar", "english": "to get away from", "usageHint": "de"},
|
||||
{"infinitive": "animarse", "baseInfinitive": "animar", "english": "to cheer up / to dare to do something", "usageHint": "a"},
|
||||
{"infinitive": "apurarse", "baseInfinitive": "apurar", "english": "to hurry"},
|
||||
{"infinitive": "aprovecharse", "baseInfinitive": "aprovechar", "english": "to take advantage of", "usageHint": "de"},
|
||||
{"infinitive": "asustarse", "baseInfinitive": "asustar", "english": "to get or become afraid"},
|
||||
{"infinitive": "atreverse", "baseInfinitive": "atrever", "english": "to dare to", "usageHint": "a"},
|
||||
{"infinitive": "bañarse", "baseInfinitive": "bañar", "english": "to take a bath / shower"},
|
||||
{"infinitive": "burlarse", "baseInfinitive": "burlar", "english": "to make fun of", "usageHint": "de"},
|
||||
{"infinitive": "caerse", "baseInfinitive": "caer", "english": "to fall down"},
|
||||
{"infinitive": "calmarse", "baseInfinitive": "calmar", "english": "to calm down"},
|
||||
{"infinitive": "cansarse", "baseInfinitive": "cansar", "english": "to get tired (of)", "usageHint": "(de)"},
|
||||
{"infinitive": "casarse", "baseInfinitive": "casar", "english": "to marry", "usageHint": "con"},
|
||||
{"infinitive": "cepillarse", "baseInfinitive": "cepillar", "english": "to brush (hair, teeth)"},
|
||||
{"infinitive": "deprimirse", "baseInfinitive": "deprimir", "english": "to become depressed"},
|
||||
{"infinitive": "conformarse", "baseInfinitive": "conformar", "english": "to resign oneself to", "usageHint": "con"},
|
||||
{"infinitive": "volverse", "baseInfinitive": "volver", "english": "to become / to turn into / to return"},
|
||||
{"infinitive": "darse", "baseInfinitive": "dar", "english": "to realize", "usageHint": "cuenta de"},
|
||||
{"infinitive": "dedicarse", "baseInfinitive": "dedicar", "english": "to dedicate oneself to / to do for a living", "usageHint": "a"},
|
||||
{"infinitive": "despedirse", "baseInfinitive": "despedir", "english": "to say goodbye", "usageHint": "(de)"},
|
||||
{"infinitive": "despertarse", "baseInfinitive": "despertar", "english": "to wake up"},
|
||||
{"infinitive": "desvestirse", "baseInfinitive": "desvestir", "english": "to undress"},
|
||||
{"infinitive": "dirigirse", "baseInfinitive": "dirigir", "english": "to go to / make one's way toward / to address", "usageHint": "a"},
|
||||
{"infinitive": "hacerse", "baseInfinitive": "hacer", "english": "to become / to pretend"},
|
||||
{"infinitive": "divertirse", "baseInfinitive": "divertir", "english": "to have fun"},
|
||||
{"infinitive": "dormirse", "baseInfinitive": "dormir", "english": "to fall asleep / to oversleep"},
|
||||
{"infinitive": "ducharse", "baseInfinitive": "duchar", "english": "to shower"},
|
||||
{"infinitive": "echarse", "baseInfinitive": "echar", "english": "to begin (usually suddenly) to do something / to break into", "usageHint": "a"},
|
||||
{"infinitive": "enamorarse", "baseInfinitive": "enamorar", "english": "to fall in love with", "usageHint": "de"},
|
||||
{"infinitive": "encargarse", "baseInfinitive": "encargar", "english": "to take charge of or be responsible for", "usageHint": "de"},
|
||||
{"infinitive": "encogerse", "baseInfinitive": "encoger", "english": "to shrug (shoulders)", "usageHint": "(de hombros)"},
|
||||
{"infinitive": "encontrarse", "baseInfinitive": "encontrar", "english": "to meet with / to run into someone", "usageHint": "(con)"},
|
||||
{"infinitive": "enfermarse", "baseInfinitive": "enfermar", "english": "to get sick"},
|
||||
{"infinitive": "enojarse", "baseInfinitive": "enojar", "english": "to get or become angry"},
|
||||
{"infinitive": "enterarse", "baseInfinitive": "enterar", "english": "to find out, to realize", "usageHint": "de"},
|
||||
{"infinitive": "exponerse", "baseInfinitive": "exponer", "english": "to expose oneself to or run the risk of", "usageHint": "a"},
|
||||
{"infinitive": "fijarse", "baseInfinitive": "fijar", "english": "to pay attention to / to take a look"},
|
||||
{"infinitive": "jugarse", "baseInfinitive": "jugar", "english": "to risk"},
|
||||
{"infinitive": "lastimarse", "baseInfinitive": "lastimar", "english": "to get hurt or hurt oneself"},
|
||||
{"infinitive": "lavarse", "baseInfinitive": "lavar", "english": "to wash (a body part)"},
|
||||
{"infinitive": "levantarse", "baseInfinitive": "levantar", "english": "to get up"},
|
||||
{"infinitive": "maquillarse", "baseInfinitive": "maquillar", "english": "to put makeup on"},
|
||||
{"infinitive": "meterse", "baseInfinitive": "meter", "english": "to get into / to pick on / to pick a fight with", "usageHint": "en / con"},
|
||||
{"infinitive": "motivarse", "baseInfinitive": "motivar", "english": "to become or get motivated to"},
|
||||
{"infinitive": "moverse", "baseInfinitive": "mover", "english": "to move oneself"},
|
||||
{"infinitive": "mudarse", "baseInfinitive": "mudar", "english": "to move (change residence)"},
|
||||
{"infinitive": "negarse", "baseInfinitive": "negar", "english": "to refuse to", "usageHint": "a"},
|
||||
{"infinitive": "obsesionarse", "baseInfinitive": "obsesionar", "english": "to be or get obsessed with", "usageHint": "con"},
|
||||
{"infinitive": "ocuparse", "baseInfinitive": "ocupar", "english": "to look after", "usageHint": "de"},
|
||||
{"infinitive": "olvidarse", "baseInfinitive": "olvidar", "english": "to forget", "usageHint": "de"},
|
||||
{"infinitive": "parecerse", "baseInfinitive": "parecer", "english": "to look like someone or something", "usageHint": "a"},
|
||||
{"infinitive": "peinarse", "baseInfinitive": "peinar", "english": "to comb your hair"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to put on (clothing) / to get or become"},
|
||||
{"infinitive": "ponerse", "baseInfinitive": "poner", "english": "to come to an agreement with someone", "usageHint": "de acuerdo"},
|
||||
{"infinitive": "preocuparse", "baseInfinitive": "preocupar", "english": "to worry about", "usageHint": "por"},
|
||||
{"infinitive": "prepararse", "baseInfinitive": "preparar", "english": "to prepare to"},
|
||||
{"infinitive": "probarse", "baseInfinitive": "probar", "english": "to try on"},
|
||||
{"infinitive": "quebrarse", "baseInfinitive": "quebrar", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "quejarse", "baseInfinitive": "quejar", "english": "to complain about", "usageHint": "de"},
|
||||
{"infinitive": "quedarse", "baseInfinitive": "quedar", "english": "to remain / to stay"},
|
||||
{"infinitive": "quemarse", "baseInfinitive": "quemar", "english": "to burn oneself / one's body"},
|
||||
{"infinitive": "quitarse", "baseInfinitive": "quitar", "english": "to take off (clothing, etc.)"},
|
||||
{"infinitive": "reírse", "baseInfinitive": "reír", "english": "to laugh about", "usageHint": "de"},
|
||||
{"infinitive": "resignarse", "baseInfinitive": "resignar", "english": "to resign oneself to", "usageHint": "a"},
|
||||
{"infinitive": "romperse", "baseInfinitive": "romper", "english": "to break (an arm, leg, etc.)"},
|
||||
{"infinitive": "secarse", "baseInfinitive": "secar", "english": "to dry (a body part)"},
|
||||
{"infinitive": "sentarse", "baseInfinitive": "sentar", "english": "to sit down"},
|
||||
{"infinitive": "sentirse", "baseInfinitive": "sentir", "english": "to feel"},
|
||||
{"infinitive": "servirse", "baseInfinitive": "servir", "english": "to help oneself to (food)"},
|
||||
{"infinitive": "suicidarse", "baseInfinitive": "suicidar", "english": "to commit suicide"},
|
||||
{"infinitive": "tratarse", "baseInfinitive": "tratar", "english": "to be about", "usageHint": "de"},
|
||||
{"infinitive": "vestirse", "baseInfinitive": "vestir", "english": "to get dressed"},
|
||||
{"infinitive": "marearse", "baseInfinitive": "marear", "english": "to get sick, to get dizzy"},
|
||||
{"infinitive": "irse", "baseInfinitive": "ir", "english": "to leave"},
|
||||
{"infinitive": "imaginarse", "baseInfinitive": "imaginar", "english": "to imagine"},
|
||||
{"infinitive": "preguntarse", "baseInfinitive": "preguntar", "english": "to wonder"},
|
||||
{"infinitive": "llamarse", "baseInfinitive": "llamar", "english": "to be called"},
|
||||
{"infinitive": "verse", "baseInfinitive": "ver", "english": "to look or appear"},
|
||||
{"infinitive": "distraerse", "baseInfinitive": "distraer", "english": "to get distracted"},
|
||||
{"infinitive": "concentrarse", "baseInfinitive": "concentrar", "english": "to focus"},
|
||||
{"infinitive": "rendirse", "baseInfinitive": "rendir", "english": "to give up"},
|
||||
{"infinitive": "relajarse", "baseInfinitive": "relajar", "english": "to relax"},
|
||||
{"infinitive": "merecerse", "baseInfinitive": "merecer", "english": "to deserve"},
|
||||
{"infinitive": "suponerse", "baseInfinitive": "suponer", "english": "to suppose"},
|
||||
{"infinitive": "conectarse", "baseInfinitive": "conectar", "english": "to connect"},
|
||||
{"infinitive": "destacarse", "baseInfinitive": "destacar", "english": "to stand out"},
|
||||
{"infinitive": "recibirse", "baseInfinitive": "recibir", "english": "to graduate"},
|
||||
{"infinitive": "graduarse", "baseInfinitive": "graduar", "english": "to graduate"},
|
||||
{"infinitive": "perderse", "baseInfinitive": "perder", "english": "to get lost"},
|
||||
{"infinitive": "cambiarse", "baseInfinitive": "cambiar", "english": "to change (clothing)", "usageHint": "(de ropa)"},
|
||||
{"infinitive": "adaptarse", "baseInfinitive": "adaptar", "english": "to adapt, to adjust", "usageHint": "a"},
|
||||
{"infinitive": "salirse", "baseInfinitive": "salir", "english": "to get away with", "usageHint": "con (la suya)"},
|
||||
{"infinitive": "subirse", "baseInfinitive": "subir", "english": "to get on (the bus, etc.)", "usageHint": "a"},
|
||||
{"infinitive": "tranquilizarse", "baseInfinitive": "tranquilizar", "english": "to relax"},
|
||||
{"infinitive": "equivocarse", "baseInfinitive": "equivocar", "english": "to get something wrong / confused"},
|
||||
{"infinitive": "confundirse", "baseInfinitive": "confundir", "english": "to get something wrong / confused"}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"note": "Curated YouTube videos per guide/grammar item for Issue #21. Each entry: {videoId, title}. Missing entries surface a 'No video yet' label in the app. TheLanguageTutor videos preferred where a matching lesson exists.",
|
||||
"tenseGuides": {
|
||||
"ind_presente": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"ind_preterito": {"videoId": "0PR7G81gKEc", "title": "Past Tense Verbs You Must Learn in Spanish — The Language Tutor Lesson 64"},
|
||||
"ind_imperfecto": {"videoId": "AmnTX30VliE", "title": "Mastering the Imperfect Tense in Spanish — The Language Tutor Lesson 50"},
|
||||
"ind_futuro": {"videoId": "U42loE1zhdw", "title": "Spanish Verbs in Future Tense — The Language Tutor Lesson 51"},
|
||||
"ind_perfecto": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"ind_pluscuamperfecto": {"videoId": "T_M3h88BUUw", "title": "Understanding Past Perfect Indicative Tense in Spanish — The Language Tutor Lesson 57"},
|
||||
"ind_futuro_perfecto": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"},
|
||||
"ind_preterito_anterior": {"videoId": "OCCgeYLlqck", "title": "The Past Anterior Tense in Spanish — The Language Tutor Lesson 65"},
|
||||
"cond_presente": {"videoId": "nRaMf1Y1TCM", "title": "Understanding the Conditional Tense in Spanish — The Language Tutor Lesson 52"},
|
||||
"cond_perfecto": {"videoId": "nlF-8kg-xaM", "title": "Mastering the Conditional Perfect Tense in Spanish — The Language Tutor Lesson 63"},
|
||||
"subj_presente": {"videoId": "CRvXpo45oHw", "title": "The Subjunctive in Spanish — The Language Tutor Lesson 58"},
|
||||
"subj_imperfecto_1": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_imperfecto_2": {"videoId": "5VkCYZGvNlI", "title": "Imperfect Subjunctive in Spanish — The Language Tutor Lesson 59"},
|
||||
"subj_perfecto": {"videoId": "Sm2DDq99Uzw", "title": "Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! — The Language Tutor"},
|
||||
"subj_pluscuamperfecto_1": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_pluscuamperfecto_2": {"videoId": "am0YiYkTQ_E", "title": "Understanding the Past Perfect Subjunctive in Spanish — The Language Tutor Lesson 61"},
|
||||
"subj_futuro": {"videoId": "YPWJsmD3hN4", "title": "Spanish Answers, Episode 10: Future Subjunctive"},
|
||||
"subj_futuro_perfecto": {"videoId": "9vmo2C-0iuQ", "title": "Free Spanish Lessons 151 - Spanish Subjunctive Tense: Future Perfect"},
|
||||
"imp_afirmativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"imp_negativo": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"}
|
||||
},
|
||||
"grammarNotes": {
|
||||
"ser-vs-estar": {"videoId": "tFXGCUi2yls", "title": "Ser vs Estar — BaseLang Spanish Lesson #3"},
|
||||
"por-vs-para": {"videoId": "02Am4LlPGjU", "title": "When to Use Por or Para in Spanish — The Language Tutor Lesson 47"},
|
||||
"preterite-vs-imperfect": {"videoId": "LvhO2-azzig", "title": "Spanish Past Tense: When to Use the Preterite vs Imperfect — BaseLang"},
|
||||
"subjunctive-triggers": {"videoId": "pMAMDgNNPB0", "title": "Spanish Subjunctive Simplified For Beginners — BaseLang"},
|
||||
"reflexive-verbs": {"videoId": "_uH_tosBLyo", "title": "Reflexive Verbs in Spanish — The Language Tutor Lesson 37"},
|
||||
"object-pronouns": {"videoId": "gEe4NW1FXx4", "title": "Intro To Spanish Direct And Indirect Object Pronouns — BaseLang"},
|
||||
"gustar-like-verbs": {"videoId": "SAfXpyZlz-I", "title": "How to Use Gustar in Spanish — The Language Tutor Lesson 121"},
|
||||
"comparatives-superlatives": {"videoId": "U74ClJsbfb0", "title": "Comparatives and Superlatives in Spanish — The Language Tutor Lesson 123"},
|
||||
"conditional-if-clauses": {"videoId": "thvW8qVsqkE", "title": "Si Clauses: The Spanish Hypothetical Explained — BaseLang"},
|
||||
"commands-imperative": {"videoId": "C2UnO5khpi4", "title": "How to Make Commands in Spanish — The Language Tutor Lesson 54"},
|
||||
"saber-vs-conocer": {"videoId": "j87i7MVCvIE", "title": "Saber vs. Conocer: Right (and WRONG) Times to Use These Spanish Verbs"},
|
||||
"double-negatives": {"videoId": "Y887wmI0O_o", "title": "Understanding Negation Words in Spanish — The Language Tutor Lesson 67"},
|
||||
"adjective-placement": {"videoId": "2B_TK_aun8E", "title": "Adjectives and Nouns Working Together in Spanish — The Language Tutor Lesson 66"},
|
||||
"tener-expressions": {"videoId": "189Qg68VCmo", "title": "The Verb Tener — BaseLang Spanish Lesson #17"},
|
||||
"personal-a": {"videoId": "B38CwLxgmOc", "title": "Learn Spanish — Preposition 'A' vs. Personal 'A'"},
|
||||
"relative-pronouns": {"videoId": "fnD1VaLpsCA", "title": "How to Use Relative Pronouns in Spanish — The Language Tutor Lesson 73"},
|
||||
"future-vs-ir-a": {"videoId": "2nxynOxr_h4", "title": "Future Tense in Spanish: 3 Ways To Speak About The Future — BaseLang"},
|
||||
"accent-marks-stress": {"videoId": "mXro8ngx07A", "title": "Spanish Accent Marks: When and How to Use Them — The Language Tutor"},
|
||||
"se-constructions": {"videoId": "ndxsrGD7b-8", "title": "Understanding 'SE' in Spanish: Reflexive, Passive, and Impersonal Constructions"},
|
||||
"estar-gerund-progressive": {"videoId": "s6HeVBv-ctM", "title": "How to Use Gerunds '-ing' in Spanish — The Language Tutor Lesson 113"},
|
||||
"spanish-suffixes": {"videoId": "o88gkstA0ds", "title": "Diminutives and Augmentatives in Spanish — The Language Tutor Lesson 78"},
|
||||
"common-irregular-verbs": {"videoId": "l0rOmomHSxg", "title": "Irregular Verbs in Spanish — The Language Tutor Lesson 21"},
|
||||
"types-of-irregular-verbs": {"videoId": "-XogD_S7pY4", "title": "Master IRREGULAR VERBS in the PRESENT TENSE | Complete Spanish Lesson"},
|
||||
"present-indicative-conjugation": {"videoId": "8HWXJjxvOTE", "title": "Spanish Present Tense: Regular -AR -ER -IR verb conjugation"},
|
||||
"articles-and-gender": {"videoId": "YeTIwDcKwZ4", "title": "Definite and Indefinite Articles in Spanish — The Language Tutor Lesson 11"},
|
||||
"possessive-adjectives": {"videoId": "Y4Aw1jy0ohE", "title": "Possessive Adjectives in Spanish — BaseLang"},
|
||||
"demonstrative-adjectives": {"videoId": "g4UzE8c2wik", "title": "How to Use This, These, That and Those in Spanish — The Language Tutor Lesson 56"},
|
||||
"greetings-farewells": {"videoId": "AqfQQZVmTUw", "title": "Every Spanish Greeting You Need (Formal, Casual & Slang) — The Language Tutor"},
|
||||
"poder-infinitive": {"videoId": "hCUbz5942EY", "title": "Spanish - The Verb 'Poder' Explained In 3 Minutes"},
|
||||
"al-del-contractions": {"videoId": "nWPZZWIwWxg", "title": "Spanish Contractions AL and DEL — The Language Tutor Lesson 15"},
|
||||
"prepositional-pronouns": {"videoId": "quzRXk0oKp8", "title": "Mastering Prepositional Pronouns in Spanish — The Language Tutor Lesson 71"},
|
||||
"irregular-yo-verbs": {"videoId": "bM9NLgaeUvw", "title": "What are YO GO verbs in Spanish? — BaseLang"},
|
||||
"stem-changing-verbs": {"videoId": "WB2ThQauaWo", "title": "Stem Changing Verbs in Spanish: Explained For Beginners — BaseLang"},
|
||||
"stressed-possessives": {"videoId": "OL86D_omkSQ", "title": "Learning Possessive Pronouns in Spanish — The Language Tutor Lesson 68"},
|
||||
"present-perfect-tense": {"videoId": "-uHwV5Lu310", "title": "Present Perfect Indicative Tense in Spanish — The Language Tutor Lesson 55"},
|
||||
"future-perfect-tense": {"videoId": "6cgc5ENNbR4", "title": "Understanding the Future Perfect Indicative in Spanish — The Language Tutor Lesson 62"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Curated YouTube Videos
|
||||
|
||||
Every tense guide and grammar note in the app can be tied to a single curated YouTube video. This file is generated from `Conjuga/youtube_videos.json` by `Scripts/generate_videos_markdown.py` — regenerate when you add or change entries.
|
||||
|
||||
- Total tense-guide entries: **20** of 20
|
||||
- Total grammar-note entries: **36** of 36
|
||||
- Last verified: **2026-04-23** (run `python3 Scripts/generate_videos_markdown.py` to refresh)
|
||||
|
||||
Like counts are often blank because YouTube hides the public count on most videos for signed-out requests. Titles and durations are pulled live from YouTube; unavailable entries mean the video has been taken down, made private, or region-locked. A few entries marked "not available on this app" are a transient yt-dlp extraction limit — the video itself still plays fine when tapping Stream in the app.
|
||||
|
||||
## Tense guides
|
||||
|
||||
Tied to `TenseGuide.tenseId` in the Guide tab.
|
||||
|
||||
| Tense ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ind_presente` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `ind_preterito` | Past Tense Verbs You Must Learn in Spanish \| The Language Tutor *Lesson 64* | The Language Tutor - Spanish | 2020-04-05 | 9:03 | 50,837 | 1,433 | [watch](https://www.youtube.com/watch?v=0PR7G81gKEc) |
|
||||
| `ind_imperfecto` | Mastering the Imperfect Tense in Spanish \| The Language Tutor *Lesson 50* | The Language Tutor - Spanish | 2019-12-22 | 19:43 | 385,678 | 8,420 | [watch](https://www.youtube.com/watch?v=AmnTX30VliE) |
|
||||
| `ind_futuro` | Spanish Verbs in Future Tense \| The Language Tutor *Lesson 51* | The Language Tutor - Spanish | 2019-12-30 | 13:58 | 389,408 | 9,542 | [watch](https://www.youtube.com/watch?v=U42loE1zhdw) |
|
||||
| `ind_perfecto` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `ind_pluscuamperfecto` | Understanding Past Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 57* | The Language Tutor - Spanish | 2020-02-09 | 7:46 | 132,330 | 3,400 | [watch](https://www.youtube.com/watch?v=T_M3h88BUUw) |
|
||||
| `ind_futuro_perfecto` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
| `ind_preterito_anterior` | The Past Anterior Tense in Spanish \| The Language Tutor *Lesson 65* | The Language Tutor - Spanish | 2020-04-12 | 8:37 | 45,389 | 1,124 | [watch](https://www.youtube.com/watch?v=OCCgeYLlqck) |
|
||||
| `cond_presente` | Understanding the Conditional Tense in Spanish \| The Language Tutor *Lesson 52* | The Language Tutor - Spanish | 2020-01-05 | 10:01 | 222,400 | 5,338 | [watch](https://www.youtube.com/watch?v=nRaMf1Y1TCM) |
|
||||
| `cond_perfecto` | Mastering the Conditional Perfect Tense in Spanish \| The Language Tutor *Lesson 63* | The Language Tutor - Spanish | 2020-03-29 | 6:07 | 44,304 | 1,300 | [watch](https://www.youtube.com/watch?v=nlF-8kg-xaM) |
|
||||
| `subj_presente` | The Subjunctive in Spanish \| The Language Tutor *Lesson 58* | The Language Tutor - Spanish | 2020-02-23 | 16:30 | 424,782 | 11,272 | [watch](https://www.youtube.com/watch?v=CRvXpo45oHw) |
|
||||
| `subj_imperfecto_1` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_imperfecto_2` | Imperfect Subjunctive in Spanish \| The Language Tutor *Lesson 59* | The Language Tutor - Spanish | 2020-03-01 | 18:04 | 194,900 | 4,226 | [watch](https://www.youtube.com/watch?v=5VkCYZGvNlI) |
|
||||
| `subj_perfecto` | Unlock Fluent Spanish: Present Perfect Subjunctive Mastery! | The Language Tutor - Spanish | 2020-03-08 | 6:47 | 89,060 | 2,105 | [watch](https://www.youtube.com/watch?v=Sm2DDq99Uzw) |
|
||||
| `subj_pluscuamperfecto_1` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_pluscuamperfecto_2` | Understanding the Past Perfect Subjunctive in Spanish \| The Language Tutor *Lesson 61* | The Language Tutor - Spanish | 2020-03-15 | 8:51 | 75,462 | 1,862 | [watch](https://www.youtube.com/watch?v=am0YiYkTQ_E) |
|
||||
| `subj_futuro` | Spanish Answers, Episode 10: Future Subjunctive | Spanish Answers | 2019-05-14 | 12:52 | 2,580 | 60 | [watch](https://www.youtube.com/watch?v=YPWJsmD3hN4) |
|
||||
| `subj_futuro_perfecto` | Free Spanish Lessons 151-Spanish Subjunctive tense:Future Perfect-Video 1/2 | Aprender Idiomas y Cultura General con Rodrigo | 2014-05-18 | 10:00 | 2,025 | 20 | [watch](https://www.youtube.com/watch?v=9vmo2C-0iuQ) |
|
||||
| `imp_afirmativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `imp_negativo` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
|
||||
## Grammar notes
|
||||
|
||||
Tied to `GrammarNote.id` (hand-authored + generated) in the Guide → Grammar tab.
|
||||
|
||||
| Grammar ID | Title | Channel | Uploaded | Duration | Views | Likes | URL |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `ser-vs-estar` | Ser vs Estar: Spanish Lesson #3 | BaseLang | 2016-04-15 | 4:38 | 129,144 | 785 | [watch](https://www.youtube.com/watch?v=tFXGCUi2yls) |
|
||||
| `por-vs-para` | When to Use Por or Para in Spanish \| The Language Tutor *Lesson 47* | The Language Tutor - Spanish | 2019-12-01 | 14:01 | 261,212 | 7,552 | [watch](https://www.youtube.com/watch?v=02Am4LlPGjU) |
|
||||
| `preterite-vs-imperfect` | Spanish Past Tense: When to Use the Preterite vs Imperfect | BaseLang | 2025-01-14 | 4:20 | 37,948 | — | [watch](https://www.youtube.com/watch?v=LvhO2-azzig) |
|
||||
| `subjunctive-triggers` | Spanish Subjunctive Simplified For Beginners | BaseLang | 2022-01-14 | 9:58 | 72,998 | — | [watch](https://www.youtube.com/watch?v=pMAMDgNNPB0) |
|
||||
| `reflexive-verbs` | Reflexive Verbs in Spanish \| The Language Tutor *Lesson 37* | The Language Tutor - Spanish | 2019-09-22 | 10:37 | 324,262 | 7,345 | [watch](https://www.youtube.com/watch?v=_uH_tosBLyo) |
|
||||
| `object-pronouns` | Intro To Spanish Direct And Indirect Object Pronouns | BaseLang | 2022-01-12 | 9:44 | 169,076 | — | [watch](https://www.youtube.com/watch?v=gEe4NW1FXx4) |
|
||||
| `gustar-like-verbs` | How to Use Gustar in Spanish \| The Language Tutor *Lesson 121* | The Language Tutor - Spanish | 2021-12-12 | 14:06 | 173,299 | — | [watch](https://www.youtube.com/watch?v=SAfXpyZlz-I) |
|
||||
| `comparatives-superlatives` | Comparatives and Superlatives in Spanish \| The Language Tutor *Lesson 123* | The Language Tutor - Spanish | 2022-05-10 | 15:57 | 90,246 | 2,085 | [watch](https://www.youtube.com/watch?v=U74ClJsbfb0) |
|
||||
| `conditional-if-clauses` | Si Clauses: The Spanish Hypothetical Explained | BaseLang | 2022-05-13 | 8:59 | 16,328 | — | [watch](https://www.youtube.com/watch?v=thvW8qVsqkE) |
|
||||
| `commands-imperative` | How to Make Commands in Spanish \| The Language Tutor *Lesson 54* | The Language Tutor - Spanish | 2020-01-19 | 16:50 | 307,013 | 7,142 | [watch](https://www.youtube.com/watch?v=C2UnO5khpi4) |
|
||||
| `saber-vs-conocer` | _(unavailable — Private video)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=j87i7MVCvIE) |
|
||||
| `double-negatives` | Understanding Negation Words in Spanish \| The Language Tutor *Lesson 67* | The Language Tutor - Spanish | 2020-04-26 | 21:36 | 150,024 | 3,888 | [watch](https://www.youtube.com/watch?v=Y887wmI0O_o) |
|
||||
| `adjective-placement` | Adjectives and Nouns Working Together in Spanish \| The Language Tutor *Lesson 66* | The Language Tutor - Spanish | 2020-04-19 | 17:17 | 70,340 | 1,980 | [watch](https://www.youtube.com/watch?v=2B_TK_aun8E) |
|
||||
| `tener-expressions` | The Verb Tener: Spanish Lesson #17 | BaseLang | 2016-05-03 | 4:16 | 7,948 | 76 | [watch](https://www.youtube.com/watch?v=189Qg68VCmo) |
|
||||
| `personal-a` | Learn Spanish-Preposition "A" vs. Personal “A” | castellano4U | 2017-04-04 | 11:45 | 1,976 | — | [watch](https://www.youtube.com/watch?v=B38CwLxgmOc) |
|
||||
| `relative-pronouns` | How to Use Relative Pronouns in Spanish \| The Language Tutor *Lesson 73* | The Language Tutor - Spanish | 2020-06-07 | 11:02 | 57,363 | — | [watch](https://www.youtube.com/watch?v=fnD1VaLpsCA) |
|
||||
| `future-vs-ir-a` | Future Tense in Spanish: 3 Ways To Speak About The Future | BaseLang | 2022-01-05 | 9:57 | 44,300 | — | [watch](https://www.youtube.com/watch?v=2nxynOxr_h4) |
|
||||
| `accent-marks-stress` | Spanish Accent Marks: When and How to Use Them | The Language Tutor - Spanish | 2020-06-14 | 14:09 | 98,134 | 3,242 | [watch](https://www.youtube.com/watch?v=mXro8ngx07A) |
|
||||
| `se-constructions` | Understanding "SE" in Spanish: Reflexive, Passive, and Impersonal Constructions EXPLAINED | Learn Spanish with Spanish55 | 2024-08-16 | 3:57 | 1,271 | 61 | [watch](https://www.youtube.com/watch?v=ndxsrGD7b-8) |
|
||||
| `estar-gerund-progressive` | How to Use Gerunds "-ing" in Spanish \| The Language Tutor *Lesson 113* | The Language Tutor - Spanish | 2021-06-20 | 10:29 | 54,552 | 2,091 | [watch](https://www.youtube.com/watch?v=s6HeVBv-ctM) |
|
||||
| `spanish-suffixes` | Diminutives and Augmentatives in Spanish \| The Language Tutor *Lesson 78* | The Language Tutor - Spanish | 2020-07-12 | 17:03 | 42,033 | — | [watch](https://www.youtube.com/watch?v=o88gkstA0ds) |
|
||||
| `common-irregular-verbs` | Irregular Verbs in Spanish \| The Language Tutor *Lesson 21* | The Language Tutor - Spanish | 2019-06-09 | 12:21 | 301,865 | 6,338 | [watch](https://www.youtube.com/watch?v=l0rOmomHSxg) |
|
||||
| `types-of-irregular-verbs` | _(unavailable — The following content is not available on this app)_ | — | — | — | — | — | [watch](https://www.youtube.com/watch?v=-XogD_S7pY4) |
|
||||
| `present-indicative-conjugation` | Spanish Present Tense: Regular -AR -ER -IR verb conjugation | Learn Spanish with Spanish55 | 2019-10-18 | 7:14 | 51,288 | 233 | [watch](https://www.youtube.com/watch?v=8HWXJjxvOTE) |
|
||||
| `articles-and-gender` | Definite and Indefinite Articles in Spanish \| The Language Tutor *Lesson 11* | The Language Tutor - Spanish | 2019-03-31 | 5:37 | 355,893 | 7,396 | [watch](https://www.youtube.com/watch?v=YeTIwDcKwZ4) |
|
||||
| `possessive-adjectives` | Possessive Adjectives in Spanish | BaseLang | 2022-06-27 | 13:40 | 15,925 | — | [watch](https://www.youtube.com/watch?v=Y4Aw1jy0ohE) |
|
||||
| `demonstrative-adjectives` | How to Use This, These, That and Those in Spanish \| The Language Tutor *Lesson 56* | The Language Tutor - Spanish | 2020-02-02 | 13:34 | 185,671 | 6,689 | [watch](https://www.youtube.com/watch?v=g4UzE8c2wik) |
|
||||
| `greetings-farewells` | Every Spanish Greeting You Need (Formal, Casual & Slang) | The Language Tutor - Spanish | 2019-03-10 | 9:54 | 673,751 | 17,161 | [watch](https://www.youtube.com/watch?v=AqfQQZVmTUw) |
|
||||
| `poder-infinitive` | Spanish - The Verb “Poder“ Explained In 3 Minutes | TheLanguageBro | 2023-07-01 | 3:16 | 4,913 | 106 | [watch](https://www.youtube.com/watch?v=hCUbz5942EY) |
|
||||
| `al-del-contractions` | Spanish Contractions AL and DEL \| The Language Tutor * Lesson 15 * | The Language Tutor - Spanish | 2019-04-28 | 3:34 | 211,085 | 5,457 | [watch](https://www.youtube.com/watch?v=nWPZZWIwWxg) |
|
||||
| `prepositional-pronouns` | Mastering Prepositional Pronouns in Spanish \| The Language Tutor *Lesson 71* | The Language Tutor - Spanish | 2020-05-24 | 13:34 | 93,929 | — | [watch](https://www.youtube.com/watch?v=quzRXk0oKp8) |
|
||||
| `irregular-yo-verbs` | What are YO GO verbs in Spanish? | BaseLang | 2022-04-21 | 5:08 | 17,894 | — | [watch](https://www.youtube.com/watch?v=bM9NLgaeUvw) |
|
||||
| `stem-changing-verbs` | Stem Changing Verbs in Spanish: Explained For Beginners | BaseLang | 2022-05-17 | 7:31 | 59,959 | — | [watch](https://www.youtube.com/watch?v=WB2ThQauaWo) |
|
||||
| `stressed-possessives` | Learning Possessive Pronouns in Spanish \| The Language Tutor *Lesson 68* | The Language Tutor - Spanish | 2020-05-03 | 10:01 | 129,223 | 3,671 | [watch](https://www.youtube.com/watch?v=OL86D_omkSQ) |
|
||||
| `present-perfect-tense` | Present Perfect Indicative Tense in Spanish \| The Language Tutor *Lesson 55* | The Language Tutor - Spanish | 2020-01-29 | 18:09 | 273,176 | 6,676 | [watch](https://www.youtube.com/watch?v=-uHwV5Lu310) |
|
||||
| `future-perfect-tense` | Understanding the Future Perfect Indicative in Spanish \| The Language Tutor *Lesson 62* | The Language Tutor - Spanish | 2020-03-22 | 7:59 | 58,757 | 1,547 | [watch](https://www.youtube.com/watch?v=6cgc5ENNbR4) |
|
||||
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
/// Screenshot every chapter of the textbook — one top + one bottom frame each —
|
||||
/// so you can visually audit parsing / rendering issues across all 30 chapters.
|
||||
final class AllChaptersScreenshotTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = true
|
||||
}
|
||||
|
||||
func testScreenshotEveryChapter() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
let courseTab = app.tabBars.buttons["Course"]
|
||||
XCTAssertTrue(courseTab.waitForExistence(timeout: 5))
|
||||
courseTab.tap()
|
||||
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
|
||||
textbookRow.tap()
|
||||
|
||||
// NOTE: SwiftUI List preserves scroll position across navigation pushes,
|
||||
// so visiting chapters in-order means the next one is already visible
|
||||
// after we return from the previous one. No need to reset.
|
||||
attach(app, name: "00-chapter-list-top")
|
||||
|
||||
for chapter in 1...30 {
|
||||
guard let row = findChapterRow(app: app, chapter: chapter) else {
|
||||
XCTFail("Chapter \(chapter) row not reachable")
|
||||
continue
|
||||
}
|
||||
row.tap()
|
||||
|
||||
// Chapter body — wait until the chapter's title appears as a nav bar label
|
||||
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 3)
|
||||
|
||||
attach(app, name: String(format: "ch%02d-top", chapter))
|
||||
// One big scroll to sample the bottom of the chapter
|
||||
dragFullScreen(app, direction: .up)
|
||||
dragFullScreen(app, direction: .up)
|
||||
attach(app, name: String(format: "ch%02d-bottom", chapter))
|
||||
|
||||
tapNavBack(app)
|
||||
// Small settle wait
|
||||
_ = app.navigationBars.firstMatch.waitForExistence(timeout: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private enum DragDirection { case up, down }
|
||||
|
||||
private func dragFullScreen(_ app: XCUIApplication, direction: DragDirection) {
|
||||
let top = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12))
|
||||
let bot = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.88))
|
||||
switch direction {
|
||||
case .up: bot.press(forDuration: 0.1, thenDragTo: top)
|
||||
case .down: top.press(forDuration: 0.1, thenDragTo: bot)
|
||||
}
|
||||
}
|
||||
|
||||
private func findChapterRow(app: XCUIApplication, chapter: Int) -> XCUIElement? {
|
||||
// Chapter row accessibility label: "<n>, <title>, ..." (SwiftUI composes
|
||||
// label from inner Texts). Match by starting number.
|
||||
let predicate = NSPredicate(format: "label BEGINSWITH %@", "\(chapter),")
|
||||
let row = app.buttons.matching(predicate).firstMatch
|
||||
|
||||
if row.exists && row.isHittable { return row }
|
||||
|
||||
// Scroll down up to 8 times searching for the row — chapters visited
|
||||
// in order, so usually 0–2 swipes suffice.
|
||||
for _ in 0..<8 {
|
||||
if row.exists && row.isHittable { return row }
|
||||
dragFullScreen(app, direction: .up)
|
||||
}
|
||||
return row.exists ? row : nil
|
||||
}
|
||||
|
||||
private func tapNavBack(_ app: XCUIApplication) {
|
||||
let back = app.navigationBars.buttons.firstMatch
|
||||
if back.exists && back.isHittable { back.tap() }
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import XCTest
|
||||
|
||||
final class StemChangeToggleTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testStemChangeConjugationToggle() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
// Course → LanGo Beginner I → Week 4 → E-IE stem-changing verbs
|
||||
app.tabBars.buttons["Course"].tap()
|
||||
|
||||
// Locate the E-IE deck row. Deck titles appear as static text / button.
|
||||
// Scroll until visible, then tap.
|
||||
let deckPredicate = NSPredicate(format: "label CONTAINS[c] 'E-IE stem changing verbs' AND NOT label CONTAINS[c] 'REVÉS'")
|
||||
let deckRow = app.buttons.matching(deckPredicate).firstMatch
|
||||
|
||||
let listRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
let topRef = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.10))
|
||||
for _ in 0..<12 {
|
||||
if deckRow.exists && deckRow.isHittable { break }
|
||||
listRef.press(forDuration: 0.1, thenDragTo: topRef)
|
||||
}
|
||||
XCTAssertTrue(deckRow.waitForExistence(timeout: 3), "E-IE deck row missing")
|
||||
deckRow.tap()
|
||||
|
||||
attach(app, name: "01-deck-top")
|
||||
|
||||
// Tap "Show conjugation" on the first card
|
||||
let showBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Show conjugation'")).firstMatch
|
||||
XCTAssertTrue(showBtn.waitForExistence(timeout: 3), "Show conjugation button missing")
|
||||
showBtn.tap()
|
||||
|
||||
// Wait for the conjugation rows + animation to settle.
|
||||
let yoLabel = app.staticTexts["yo"].firstMatch
|
||||
XCTAssertTrue(yoLabel.waitForExistence(timeout: 3), "yo row not rendered")
|
||||
// Give the transition time to complete before snapshotting.
|
||||
Thread.sleep(forTimeInterval: 0.6)
|
||||
attach(app, name: "02-conjugation-open")
|
||||
|
||||
// Also confirm all expected person labels are rendered.
|
||||
for person in ["yo", "tú", "nosotros"] {
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[person].firstMatch.exists,
|
||||
"missing conjugation row for \(person)"
|
||||
)
|
||||
}
|
||||
|
||||
// Tap again to hide
|
||||
let hideBtn = app.buttons.matching(NSPredicate(format: "label BEGINSWITH 'Hide conjugation'")).firstMatch
|
||||
XCTAssertTrue(hideBtn.waitForExistence(timeout: 2))
|
||||
hideBtn.tap()
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let s = app.screenshot()
|
||||
let a = XCTAttachment(screenshot: s)
|
||||
a.name = name
|
||||
a.lifetime = .keepAlways
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
|
||||
final class TextbookFlowUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testTextbookFlow() throws {
|
||||
let app = XCUIApplication()
|
||||
// Skip onboarding via defaults (already set by run script, but harmless to override)
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
// Dashboard should be default tab. Switch to Course.
|
||||
let courseTab = app.tabBars.buttons["Course"]
|
||||
XCTAssertTrue(courseTab.waitForExistence(timeout: 5), "Course tab missing")
|
||||
courseTab.tap()
|
||||
|
||||
// Attach a screenshot of the Course list
|
||||
attach(app, name: "01-course-list")
|
||||
|
||||
// Tap the Textbook entry
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5), "Textbook row missing in Course")
|
||||
textbookRow.tap()
|
||||
|
||||
attach(app, name: "02-textbook-chapter-list")
|
||||
|
||||
// Tap chapter 1 — should navigate to reader
|
||||
let chapterOneRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Nouns, Articles'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(chapterOneRow.waitForExistence(timeout: 5), "Chapter 1 row missing")
|
||||
chapterOneRow.tap()
|
||||
|
||||
attach(app, name: "03-chapter-body")
|
||||
|
||||
// Find the first exercise link ("Exercise 1.1")
|
||||
let exerciseRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Exercise 1.1'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(exerciseRow.waitForExistence(timeout: 5), "Exercise 1.1 link missing")
|
||||
exerciseRow.tap()
|
||||
|
||||
attach(app, name: "04-exercise-view")
|
||||
|
||||
// Check presence of input fields: at least a few numbered prompts
|
||||
// Text fields use SwiftUI placeholder "Your answer"
|
||||
let firstField = app.textFields["Your answer"].firstMatch
|
||||
XCTAssertTrue(firstField.waitForExistence(timeout: 5), "No input fields rendered for exercise")
|
||||
firstField.tap()
|
||||
firstField.typeText("el")
|
||||
|
||||
attach(app, name: "05-exercise-typed-el")
|
||||
|
||||
// Tap Check answers
|
||||
let checkButton = app.buttons["Check answers"]
|
||||
XCTAssertTrue(checkButton.waitForExistence(timeout: 3), "Check answers button missing")
|
||||
checkButton.tap()
|
||||
|
||||
attach(app, name: "06-exercise-graded")
|
||||
|
||||
// The first answer to Exercise 1.1 is "el" — we should see the first prompt
|
||||
// graded correct. Iterating too deeply is fragile; just take a screenshot
|
||||
// and check for presence of either a checkmark-like label or "Try again".
|
||||
let tryAgain = app.buttons["Try again"]
|
||||
XCTAssertTrue(tryAgain.waitForExistence(timeout: 3), "Grading did not complete")
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let screenshot = app.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import XCTest
|
||||
|
||||
final class VocabGridTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
/// Verifies the chapter reader renders vocab tables as a paired Spanish↔English grid.
|
||||
func testChapter4VocabGrid() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-onboardingComplete", "YES"]
|
||||
app.launch()
|
||||
|
||||
app.tabBars.buttons["Course"].tap()
|
||||
|
||||
let textbookRow = app.buttons.containing(NSPredicate(
|
||||
format: "label CONTAINS[c] 'Complete Spanish'"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(textbookRow.waitForExistence(timeout: 5))
|
||||
textbookRow.tap()
|
||||
|
||||
let ch4 = app.buttons["textbook-chapter-row-4"]
|
||||
XCTAssertTrue(ch4.waitForExistence(timeout: 3))
|
||||
ch4.tap()
|
||||
|
||||
attach(app, name: "01-ch4-top")
|
||||
|
||||
// Tap the first vocab disclosure — "Vocabulary (N items)"
|
||||
let vocabButton = app.buttons.matching(NSPredicate(
|
||||
format: "label BEGINSWITH 'Vocabulary ('"
|
||||
)).firstMatch
|
||||
XCTAssertTrue(vocabButton.waitForExistence(timeout: 3))
|
||||
vocabButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.4)
|
||||
|
||||
attach(app, name: "02-ch4-vocab-open")
|
||||
|
||||
// Scroll a little and screenshot a deeper vocab — numbers table is
|
||||
// typically a few screens down in chapter 4.
|
||||
app.swipeUp(velocity: .fast)
|
||||
app.swipeUp(velocity: .fast)
|
||||
attach(app, name: "03-ch4-deeper")
|
||||
}
|
||||
|
||||
private func attach(_ app: XCUIApplication, name: String) {
|
||||
let s = app.screenshot()
|
||||
let a = XCTAttachment(screenshot: s)
|
||||
a.name = name
|
||||
a.lifetime = .keepAlways
|
||||
add(a)
|
||||
}
|
||||
}
|
||||
@@ -41,21 +41,19 @@ struct CombinedProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 6 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||
// on open, dropping the entities not listed here.
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
@@ -32,21 +32,19 @@ struct WordOfDayProvider: TimelineProvider {
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 6 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||
// on open, dropping the entities not listed here.
|
||||
// Open the store with the SAME schema as the main app. A subset schema
|
||||
// would make SwiftData destructively migrate the store on open and drop
|
||||
// every unlisted table (this is how widget refreshes kept wiping the
|
||||
// bundled Book rows, and TextbookChapter before them).
|
||||
let schema = Schema(SharedStore.localSchemaModels)
|
||||
let config = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
schema: schema,
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
guard let container = try? ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
for: schema,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user