Compare commits
42 Commits
3b8a8a7f1a
...
issue/19-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b69f3b630 | ||
|
|
ff4f906128 | ||
| 23ff9d66de | |||
|
|
b48e935231 | ||
| 924090190f | |||
|
|
945b2ff1f3 | ||
| 77932f802a | |||
|
|
5944f263cd | ||
|
|
a3318adf5e | ||
|
|
a3807faf2d | ||
| 93ab7b3e16 | |||
|
|
a663bc03cd | ||
| b13f58ec81 | |||
|
|
451866e988 | ||
| 0848675016 | |||
|
|
79c4c6e290 | ||
| ee8a0c478f | |||
|
|
282cd1b3a3 | ||
| 24cc05389e | |||
|
|
40d436ad9c | ||
| e1b1910c06 | |||
|
|
473eb271cc | ||
|
|
877e699c56 | ||
|
|
d372a5c77f | ||
|
|
a1dc17bf00 | ||
|
|
c58313496e | ||
|
|
636193fae1 | ||
|
|
faef20e5b8 | ||
|
|
5fa1cc3921 | ||
|
|
a51d2abd47 | ||
|
|
2a062cf484 | ||
|
|
02e8d5141a | ||
|
|
cd67f32302 | ||
|
|
79d9b7cb1d | ||
|
|
d666d0991a | ||
|
|
4e575a22c8 | ||
|
|
d538123251 | ||
|
|
54c1b05411 | ||
|
|
99fc3c91f5 | ||
|
|
ca7640b100 | ||
|
|
719134c6c7 | ||
|
|
143e356b75 |
@@ -29,16 +29,20 @@
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
|
||||
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.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 */; };
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
|
||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||
968D626462B0ADEC8D7D56AA /* CheckpointExamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.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 */; };
|
||||
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||
@@ -48,6 +52,7 @@
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.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 */; };
|
||||
@@ -62,6 +67,7 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||
@@ -72,6 +78,22 @@
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -122,19 +144,24 @@
|
||||
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>"; };
|
||||
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = "<group>"; };
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
|
||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
|
||||
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.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>"; };
|
||||
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>"; };
|
||||
@@ -168,6 +195,23 @@
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.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>"; };
|
||||
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 */
|
||||
@@ -201,6 +245,7 @@
|
||||
7E6AF62A3A949630E067DC22 /* Info.plist */,
|
||||
353C5DE41FD410FA82E3AED7 /* Models */,
|
||||
1994867BC8E985795A172854 /* Services */,
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */,
|
||||
3C75490F53C34A37084FF478 /* ViewModels */,
|
||||
A81CA75762B08D35D5B7A44D /* Views */,
|
||||
);
|
||||
@@ -211,6 +256,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BCCC95A95581458E068E0484 /* SettingsView.swift */,
|
||||
12E9DDEFD53C49E0A48EA655 /* FeatureReferenceView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
@@ -231,7 +277,13 @@
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
E10603F454E54341AA4B9931 /* ConversationService.swift */,
|
||||
D535EF6988A24B47B70209A2 /* PronunciationService.swift */,
|
||||
A04370CF6B4E4D38BE3EB0C7 /* DictionaryService.swift */,
|
||||
327659ABFD524514B6D2D505 /* StoryGenerator.swift */,
|
||||
978FB24DF8D7436CB5210ACE /* StudyTimerService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||
@@ -263,7 +315,8 @@
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
);
|
||||
17E5252282F44ECD9BA70DB8 /* GrammarExercise.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -314,9 +367,15 @@
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */,
|
||||
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */,
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
|
||||
D3698CE7ACF148318615293E /* VocabReviewView.swift */,
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
|
||||
10C16AA6022E4742898745CE /* TypingView.swift */,
|
||||
);
|
||||
895E547BEFB5D0FBF676BE33 /* Lyrics */,
|
||||
8A1DED0596E04DDE9536A9A9 /* Stories */,
|
||||
DFD75E32A53845A693D98F48 /* Chat */,
|
||||
02B2179562E54E148C98219D /* ListeningView.swift */,
|
||||
A649B04B8B3C49419AD9219C /* ClozeView.swift */,
|
||||
);
|
||||
path = Practice;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -325,9 +384,40 @@
|
||||
children = (
|
||||
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */,
|
||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */,
|
||||
);
|
||||
1F71CA5CD67342F18319DB9A /* GrammarExerciseView.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;
|
||||
children = (
|
||||
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */,
|
||||
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */,
|
||||
58394296923991E56BAC2B02 /* LyricsReaderView.swift */,
|
||||
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */,
|
||||
);
|
||||
path = Lyrics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A591A3B6F1F13D23D68D7A9D = {
|
||||
isa = PBXGroup;
|
||||
@@ -372,10 +462,18 @@
|
||||
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */,
|
||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */,
|
||||
5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */,
|
||||
EA1F177F7ABF5D2E4E5466CD /* CheckpointExamView.swift */,
|
||||
);
|
||||
path = Course;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BFC1AEBE02CE22E6474FFEA6 /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F605D24E5EA11065FD18AF7E /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -518,6 +616,11 @@
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
|
||||
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
|
||||
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
|
||||
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */,
|
||||
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||
@@ -548,8 +651,25 @@
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
217A29BCEDD9D44B6DD85AF6 /* Sources */ = {
|
||||
|
||||
@@ -10,7 +10,7 @@ private enum CloudPreviewContainer {
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
return try! ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
configurations: configuration
|
||||
)
|
||||
}()
|
||||
@@ -36,8 +36,10 @@ extension EnvironmentValues {
|
||||
struct ConjugaApp: App {
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isReady = false
|
||||
@State private var isReady = true
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
@State private var studyTimer = StudyTimerService()
|
||||
@State private var dictionary = DictionaryService()
|
||||
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
@@ -66,15 +68,16 @@ struct ConjugaApp: App {
|
||||
"cloud",
|
||||
schema: Schema([
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self,
|
||||
TestResult.self, DailyLog.self, SavedSong.self, Story.self, Conversation.self,
|
||||
configurations: cloudConfig
|
||||
)
|
||||
|
||||
} catch {
|
||||
fatalError("Failed to create ModelContainer: \(error)")
|
||||
}
|
||||
@@ -106,15 +109,23 @@ struct ConjugaApp: App {
|
||||
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
||||
.environment(syncMonitor)
|
||||
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||
.environment(studyTimer)
|
||||
.environment(dictionary)
|
||||
.task {
|
||||
if let url = SharedStore.localStoreURL() {
|
||||
StoreInspector.dump(at: url, label: "before-bootstrap")
|
||||
let needsSeed = await DataLoader.needsSeeding(container: localContainer)
|
||||
if needsSeed {
|
||||
isReady = false
|
||||
}
|
||||
|
||||
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
||||
if let url = SharedStore.localStoreURL() {
|
||||
StoreInspector.dump(at: url, label: "after-bootstrap")
|
||||
|
||||
if !isReady {
|
||||
isReady = true
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
dictionary.buildIfNeeded(context: localContainer.mainContext)
|
||||
}
|
||||
isReady = true
|
||||
|
||||
Task { @MainActor in
|
||||
syncMonitor.beginSync()
|
||||
@@ -130,6 +141,15 @@ struct ConjugaApp: App {
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
switch newPhase {
|
||||
case .active:
|
||||
studyTimer.start()
|
||||
case .inactive, .background:
|
||||
studyTimer.stop(context: cloudContainer.mainContext)
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
|
||||
if newPhase == .background {
|
||||
WidgetDataService.update(
|
||||
localContainer: localContainer,
|
||||
@@ -178,7 +198,7 @@ struct ConjugaApp: App {
|
||||
|
||||
deleteStoreFiles(at: url)
|
||||
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
||||
print("Reset corrupted local reference store")
|
||||
print("[ConjugaApp] ⚠️ Reset corrupted local reference store — this triggers full re-seed")
|
||||
|
||||
return try makeLocalContainer(at: url)
|
||||
}
|
||||
@@ -224,7 +244,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 = 2 // bump: widget schema moved to SharedModels
|
||||
let resetVersion = 3 // bump: SavedSong moved to cloud container
|
||||
let key = "localStoreResetVersion"
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
@@ -239,4 +259,5 @@ struct ConjugaApp: App {
|
||||
|
||||
defaults.set(resetVersion, forKey: key)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,7 @@ final class DailyLog {
|
||||
var dateString: String = ""
|
||||
var reviewCount: Int = 0
|
||||
var correctCount: Int = 0
|
||||
var studySeconds: Int = 0
|
||||
|
||||
var accuracy: Double {
|
||||
guard reviewCount > 0 else { return 0 }
|
||||
|
||||
569
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
569
Conjuga/Conjuga/Models/GrammarExercise.swift
Normal file
@@ -0,0 +1,569 @@
|
||||
import Foundation
|
||||
|
||||
struct GrammarExercise: Identifiable, Hashable {
|
||||
let id: String
|
||||
let prompt: String
|
||||
let sentence: String
|
||||
let correctAnswer: String
|
||||
let options: [String]
|
||||
let explanation: String
|
||||
|
||||
static func exercises(for noteId: String) -> [GrammarExercise] {
|
||||
switch noteId {
|
||||
case "ser-vs-estar": return serVsEstarExercises
|
||||
case "por-vs-para": return porVsParaExercises
|
||||
case "preterite-vs-imperfect": return preteriteVsImperfectExercises
|
||||
case "subjunctive-triggers": return subjunctiveTriggerExercises
|
||||
case "personal-a": return personalAExercises
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ser vs Estar (100)
|
||||
|
||||
private static let serVsEstarExercises: [GrammarExercise] = {
|
||||
let data: [(String, String, String, String)] = [
|
||||
// (sentence, correct, wrong, explanation)
|
||||
("Ella _____ doctora.", "es", "está", "Ser for professions."),
|
||||
("El libro _____ en la mesa.", "está", "es", "Estar for location."),
|
||||
("Yo _____ muy cansado hoy.", "estoy", "soy", "Estar for temporary states."),
|
||||
("Nosotros _____ de México.", "somos", "estamos", "Ser for origin."),
|
||||
("La sopa _____ caliente.", "está", "es", "Estar for conditions."),
|
||||
("_____ las tres de la tarde.", "Son", "Están", "Ser for telling time."),
|
||||
("Mi hermano _____ alto.", "es", "está", "Ser for physical descriptions."),
|
||||
("Ella _____ feliz porque aprobó.", "está", "es", "Estar for emotions."),
|
||||
("La casa _____ grande.", "es", "está", "Ser for inherent qualities."),
|
||||
("El café _____ frío.", "está", "es", "Estar for current condition."),
|
||||
("Ellos _____ estudiantes.", "son", "están", "Ser for identity."),
|
||||
("Yo _____ en la oficina.", "estoy", "soy", "Estar for location."),
|
||||
("La fiesta _____ en mi casa.", "es", "está", "Ser for events (location of event)."),
|
||||
("Tú _____ muy inteligente.", "eres", "estás", "Ser for personality traits."),
|
||||
("El agua _____ fría.", "está", "es", "Estar for temperature (current state)."),
|
||||
("María _____ de España.", "es", "está", "Ser for origin."),
|
||||
("Nosotros _____ listos para salir.", "estamos", "somos", "Estar — ready (temporary state)."),
|
||||
("Él _____ un buen amigo.", "es", "está", "Ser for characteristics."),
|
||||
("La puerta _____ abierta.", "está", "es", "Estar for states resulting from actions."),
|
||||
("Hoy _____ lunes.", "es", "está", "Ser for days/dates."),
|
||||
("Yo _____ aburrido en clase.", "estoy", "soy", "Estar — bored (feeling now)."),
|
||||
("Ella _____ aburrida como persona.", "es", "está", "Ser — boring (personality)."),
|
||||
("La manzana _____ verde.", "está", "es", "Estar — unripe (condition)."),
|
||||
("La camisa _____ de algodón.", "es", "está", "Ser for material."),
|
||||
("Él _____ enfermo.", "está", "es", "Estar for health conditions."),
|
||||
("Nosotros _____ contentos.", "estamos", "somos", "Estar for emotions."),
|
||||
("La clase _____ a las ocho.", "es", "está", "Ser for scheduled time."),
|
||||
("Tú _____ muy guapo hoy.", "estás", "eres", "Estar — looking good (today)."),
|
||||
("Ella _____ profesora de español.", "es", "está", "Ser for profession."),
|
||||
("El examen _____ difícil.", "es", "está", "Ser for inherent characteristic."),
|
||||
("Yo _____ nervioso por el examen.", "estoy", "soy", "Estar for temporary feeling."),
|
||||
("Los niños _____ en el parque.", "están", "son", "Estar for location."),
|
||||
("La película _____ interesante.", "es", "está", "Ser for inherent quality."),
|
||||
("El restaurante _____ cerrado.", "está", "es", "Estar for state (closed now)."),
|
||||
("Mi padre _____ alto y moreno.", "es", "está", "Ser for physical description."),
|
||||
("¿Dónde _____ el baño?", "está", "es", "Estar for location."),
|
||||
("Ella _____ lista.", "es", "está", "Ser — clever (trait)."),
|
||||
("¿Cómo _____ tú?", "estás", "eres", "Estar — how are you (state)."),
|
||||
("La comida _____ deliciosa.", "está", "es", "Estar — tastes delicious (now)."),
|
||||
("Él _____ colombiano.", "es", "está", "Ser for nationality."),
|
||||
("Yo _____ preocupado.", "estoy", "soy", "Estar for worry (emotion)."),
|
||||
("La mesa _____ de madera.", "es", "está", "Ser for material."),
|
||||
("Ellos _____ cansados después del viaje.", "están", "son", "Estar for temporary state."),
|
||||
("Mi madre _____ muy joven.", "es", "está", "Ser for age/appearance (inherent)."),
|
||||
("El cielo _____ nublado.", "está", "es", "Estar for weather conditions."),
|
||||
("Nosotros _____ hermanos.", "somos", "estamos", "Ser for relationships."),
|
||||
("La ventana _____ rota.", "está", "es", "Estar for result of action."),
|
||||
("¿Quién _____ tu profesor?", "es", "está", "Ser for identity."),
|
||||
("El bebé _____ dormido.", "está", "es", "Estar for state (sleeping)."),
|
||||
("Ella _____ muy trabajadora.", "es", "está", "Ser for personality."),
|
||||
("Yo _____ listo para el examen.", "estoy", "soy", "Estar — ready."),
|
||||
("La ciudad _____ bonita.", "es", "está", "Ser for inherent beauty."),
|
||||
("Tú _____ sentado en mi silla.", "estás", "eres", "Estar for position/posture."),
|
||||
("El problema _____ complicado.", "es", "está", "Ser for inherent quality."),
|
||||
("La leche _____ en el refrigerador.", "está", "es", "Estar for location."),
|
||||
("Yo _____ mexicano.", "soy", "estoy", "Ser for nationality."),
|
||||
("Ella _____ embarazada.", "está", "es", "Estar for temporary condition."),
|
||||
("La reunión _____ a las diez.", "es", "está", "Ser for scheduled time."),
|
||||
("El perro _____ sucio.", "está", "es", "Estar for current condition."),
|
||||
("Nosotros _____ amigos desde niños.", "somos", "estamos", "Ser for relationships."),
|
||||
("Tú _____ muy callado hoy.", "estás", "eres", "Estar — quiet today (temporary)."),
|
||||
("Ella _____ la directora.", "es", "está", "Ser for identity/role."),
|
||||
("El coche _____ nuevo.", "es", "está", "Ser for characteristic."),
|
||||
("Yo _____ seguro de eso.", "estoy", "soy", "Estar for certainty (state)."),
|
||||
("La silla _____ rota.", "está", "es", "Estar for broken (result of action)."),
|
||||
("Mi casa _____ cerca del parque.", "está", "es", "Estar for relative location."),
|
||||
("Él _____ viejo.", "es", "está", "Ser for age."),
|
||||
("El café _____ listo.", "está", "es", "Estar — ready (state)."),
|
||||
("Nosotros _____ perdidos.", "estamos", "somos", "Estar for being lost."),
|
||||
("La respuesta _____ correcta.", "es", "está", "Ser for fact."),
|
||||
("Tú _____ enojado conmigo.", "estás", "eres", "Estar for emotion."),
|
||||
("Ella _____ rica.", "es", "está", "Ser for wealth (inherent)."),
|
||||
("El museo _____ en el centro.", "está", "es", "Estar for location."),
|
||||
("Yo _____ de acuerdo.", "estoy", "soy", "Estar for agreement (state)."),
|
||||
("La luz _____ encendida.", "está", "es", "Estar for state (on/off)."),
|
||||
("Ellos _____ gemelos.", "son", "están", "Ser for identity."),
|
||||
("El clima _____ agradable.", "está", "es", "Estar for weather now."),
|
||||
("La tarea _____ para mañana.", "es", "está", "Ser for deadline."),
|
||||
("Yo _____ ocupado ahora.", "estoy", "soy", "Estar for temporary state."),
|
||||
("Ella _____ soltera.", "es", "está", "Ser for marital status."),
|
||||
("El pan _____ duro.", "está", "es", "Estar for condition (stale)."),
|
||||
("Mi hermana _____ mayor que yo.", "es", "está", "Ser for comparison."),
|
||||
("Tú _____ mojado por la lluvia.", "estás", "eres", "Estar for condition."),
|
||||
("La cena _____ a las nueve.", "es", "está", "Ser for time."),
|
||||
("El hospital _____ lejos.", "está", "es", "Estar for distance/location."),
|
||||
("Nosotros _____ orgullosos de ti.", "estamos", "somos", "Estar for emotion."),
|
||||
("Ella _____ muy simpática.", "es", "está", "Ser for personality."),
|
||||
("El gato _____ debajo de la cama.", "está", "es", "Estar for location."),
|
||||
("Yo _____ vegetariano.", "soy", "estoy", "Ser for identity."),
|
||||
("La ventana _____ sucia.", "está", "es", "Estar for condition."),
|
||||
("Él _____ contento con su trabajo.", "está", "es", "Estar for satisfaction."),
|
||||
("La prueba _____ fácil.", "es", "está", "Ser for inherent quality."),
|
||||
("Tú _____ de buen humor.", "estás", "eres", "Estar for mood."),
|
||||
("El vuelo _____ a las seis.", "es", "está", "Ser for scheduled time."),
|
||||
("La playa _____ hermosa.", "es", "está", "Ser for inherent beauty."),
|
||||
("Yo _____ emocionado por el viaje.", "estoy", "soy", "Estar for excitement."),
|
||||
("Ellos _____ en casa.", "están", "son", "Estar for location."),
|
||||
("La tienda _____ abierta.", "está", "es", "Estar for state."),
|
||||
("Él _____ el mejor jugador.", "es", "está", "Ser for identity/superlative."),
|
||||
("Nosotros _____ sorprendidos.", "estamos", "somos", "Estar for emotion."),
|
||||
]
|
||||
return data.enumerated().map { i, d in
|
||||
GrammarExercise(id: "se\(i+1)", prompt: "Choose ser or estar:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Por vs Para (100)
|
||||
|
||||
private static let porVsParaExercises: [GrammarExercise] = {
|
||||
let data: [(String, String, String, String)] = [
|
||||
("Este regalo es _____ ti.", "para", "por", "Para for recipient."),
|
||||
("Gracias _____ tu ayuda.", "por", "para", "Por for cause/reason."),
|
||||
("Caminamos _____ el parque.", "por", "para", "Por for movement through."),
|
||||
("Estudio _____ aprender.", "para", "por", "Para for purpose."),
|
||||
("Pagué veinte dólares _____ el libro.", "por", "para", "Por for exchange."),
|
||||
("Salimos _____ Madrid mañana.", "para", "por", "Para for destination."),
|
||||
("Estudié _____ dos horas.", "por", "para", "Por for duration."),
|
||||
("Necesito el informe _____ el lunes.", "para", "por", "Para for deadline."),
|
||||
("Te llamo _____ teléfono.", "por", "para", "Por for means."),
|
||||
("Trabajo _____ una empresa grande.", "para", "por", "Para for employer."),
|
||||
("Pasamos _____ tu casa ayer.", "por", "para", "Por for passing by."),
|
||||
("La carta fue escrita _____ María.", "por", "para", "Por for agent in passive."),
|
||||
("Este medicamento es _____ el dolor.", "para", "por", "Para for purpose."),
|
||||
("Viajamos _____ avión.", "por", "para", "Por for means of transport."),
|
||||
("_____ favor, ayúdame.", "Por", "Para", "Fixed expression: por favor."),
|
||||
("Voy _____ agua.", "por", "para", "Por for going to get something."),
|
||||
("_____ ser estudiante, habla muy bien.", "Para", "Por", "Para for comparison/considering."),
|
||||
("Lo hice _____ ti.", "por", "para", "Por for on behalf of."),
|
||||
("Este libro es _____ niños.", "para", "por", "Para for intended audience."),
|
||||
("_____ supuesto que sí.", "Por", "Para", "Fixed expression: por supuesto."),
|
||||
("Necesito lentes _____ leer.", "para", "por", "Para for purpose (in order to)."),
|
||||
("Luchamos _____ la libertad.", "por", "para", "Por for cause worth fighting for."),
|
||||
("Cambié mi coche _____ uno nuevo.", "por", "para", "Por for exchange."),
|
||||
("Vamos _____ la costa.", "para", "por", "Para for destination."),
|
||||
("_____ ejemplo, esto es fácil.", "Por", "Para", "Fixed expression: por ejemplo."),
|
||||
("Mandé el paquete _____ correo.", "por", "para", "Por for means."),
|
||||
("Compré flores _____ mi madre.", "para", "por", "Para for recipient."),
|
||||
("Corrieron _____ la calle.", "por", "para", "Por for through/along."),
|
||||
("Estudia mucho _____ sacar buenas notas.", "para", "por", "Para for goal."),
|
||||
("_____ eso no vine.", "Por", "Para", "Por for reason (that's why)."),
|
||||
("Ella trabaja _____ ganar dinero.", "para", "por", "Para for purpose."),
|
||||
("Fueron criticados _____ los medios.", "por", "para", "Por for agent in passive."),
|
||||
("Tengo un mensaje _____ usted.", "para", "por", "Para for recipient."),
|
||||
("Votamos _____ el candidato.", "por", "para", "Por for in favor of."),
|
||||
("_____ lo menos, intenta.", "Por", "Para", "Fixed expression: por lo menos."),
|
||||
("La clase es _____ principiantes.", "para", "por", "Para for intended audience."),
|
||||
("Pagamos mucho _____ la cena.", "por", "para", "Por for exchange."),
|
||||
("Salgo _____ el aeropuerto a las cinco.", "para", "por", "Para for destination."),
|
||||
("Esperamos _____ una hora.", "por", "para", "Por for duration."),
|
||||
("_____ fin llegamos.", "Por", "Para", "Fixed expression: por fin."),
|
||||
("¿_____ qué estudias español?", "Por", "Para", "Por qué — asking for reason."),
|
||||
("¿_____ cuándo es el proyecto?", "Para", "Por", "Para for deadline."),
|
||||
("Lo terminé _____ la noche.", "por", "para", "Por for time of day (general)."),
|
||||
("Este dinero es _____ la renta.", "para", "por", "Para for purpose/intended use."),
|
||||
("_____ mí, está bien.", "Para", "Por", "Para for opinion (in my view)."),
|
||||
("Ella habla _____ todos nosotros.", "por", "para", "Por for on behalf of."),
|
||||
("Voy a estar aquí _____ tres semanas.", "por", "para", "Por for duration."),
|
||||
("Estas vitaminas son _____ la salud.", "para", "por", "Para for purpose."),
|
||||
("Navegamos _____ el río.", "por", "para", "Por for along/through."),
|
||||
("La tarea es _____ mañana.", "para", "por", "Para for deadline."),
|
||||
("Fue elegido _____ el pueblo.", "por", "para", "Por for agent."),
|
||||
("Estamos aquí _____ ayudarte.", "para", "por", "Para for purpose."),
|
||||
("Me preocupo _____ mi familia.", "por", "para", "Por for concern about."),
|
||||
("Hay una sorpresa _____ ti.", "para", "por", "Para for recipient."),
|
||||
("_____ siempre te amaré.", "Para", "Por", "Fixed expression: para siempre."),
|
||||
("Vendí el coche _____ cinco mil.", "por", "para", "Por for price/exchange."),
|
||||
("Ella se fue _____ la mañana.", "por", "para", "Por for general time."),
|
||||
("Este regalo es perfecto _____ ella.", "para", "por", "Para for recipient."),
|
||||
("Brindamos _____ tu éxito.", "por", "para", "Por for toasting/in honor of."),
|
||||
("Necesito un traje _____ la boda.", "para", "por", "Para for occasion."),
|
||||
("Caminé _____ la playa al atardecer.", "por", "para", "Por for along."),
|
||||
("_____ nada, fue un placer.", "De", "Para", "Actually 'de nada' — trick question. Skip."),
|
||||
("Me quedé en casa _____ la lluvia.", "por", "para", "Por for cause."),
|
||||
("Ahorro dinero _____ comprar una casa.", "para", "por", "Para for goal."),
|
||||
("El tren pasa _____ aquí.", "por", "para", "Por for through/by here."),
|
||||
("Tengo algo especial _____ ti.", "para", "por", "Para for recipient."),
|
||||
("Lo dejé _____ después.", "para", "por", "Para for later (intended time)."),
|
||||
("Murió _____ su país.", "por", "para", "Por for sacrifice/cause."),
|
||||
("La reunión es _____ las dos.", "para", "por", "Para for deadline/scheduled."),
|
||||
("Pregunté _____ ti en la fiesta.", "por", "para", "Por for asking about someone."),
|
||||
("Estudio español _____ mi trabajo.", "para", "por", "Para for purpose."),
|
||||
("_____ lo general, como a las doce.", "Por", "Para", "Fixed expression: por lo general."),
|
||||
("Hice la comida _____ los invitados.", "para", "por", "Para for recipients."),
|
||||
("Ella está aquí _____ unas semanas.", "por", "para", "Por for duration."),
|
||||
("El avión sale _____ Buenos Aires.", "para", "por", "Para for destination."),
|
||||
("Cambié euros _____ dólares.", "por", "para", "Por for exchange."),
|
||||
("Corro _____ mantenerme en forma.", "para", "por", "Para for purpose."),
|
||||
("Fueron aplaudidos _____ el público.", "por", "para", "Por for agent."),
|
||||
("Ven _____ acá.", "para", "por", "Para for direction toward."),
|
||||
("_____ suerte, no pasó nada.", "Por", "Para", "Fixed expression: por suerte."),
|
||||
("Compré una torta _____ su cumpleaños.", "para", "por", "Para for occasion."),
|
||||
("Viajé _____ toda Europa.", "por", "para", "Por for throughout."),
|
||||
("El informe es _____ el director.", "para", "por", "Para for recipient."),
|
||||
("Llegué tarde _____ el tráfico.", "por", "para", "Por for cause."),
|
||||
("Está listo _____ servir.", "para", "por", "Para for readiness/purpose."),
|
||||
("Doy gracias _____ todo.", "por", "para", "Por for gratitude about."),
|
||||
("Este postre es _____ compartir.", "para", "por", "Para for intended use."),
|
||||
("Fui al mercado _____ frutas.", "por", "para", "Por for going to fetch."),
|
||||
("La canción fue compuesta _____ él.", "por", "para", "Por for agent."),
|
||||
("Traje comida _____ todos.", "para", "por", "Para for recipients."),
|
||||
("Nos fuimos _____ la puerta de atrás.", "por", "para", "Por for through/via."),
|
||||
("Ella cocina _____ su familia.", "para", "por", "Para for beneficiary."),
|
||||
("Dieron su vida _____ la patria.", "por", "para", "Por for sacrifice."),
|
||||
("Tengo una cita _____ el miércoles.", "para", "por", "Para for deadline/date."),
|
||||
("Lo hago _____ amor.", "por", "para", "Por for motivation."),
|
||||
("_____ colmo, empezó a llover.", "Para", "Por", "Fixed expression: para colmo."),
|
||||
("Mandamos invitaciones _____ correo.", "por", "para", "Por for means."),
|
||||
("Vamos a brindar _____ los novios.", "por", "para", "Por for in honor of."),
|
||||
("Reservé una mesa _____ cuatro personas.", "para", "por", "Para for intended use."),
|
||||
]
|
||||
return data.enumerated().map { i, d in
|
||||
GrammarExercise(id: "pp\(i+1)", prompt: "Choose por or para:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Preterite vs Imperfect (100)
|
||||
|
||||
private static let preteriteVsImperfectExercises: [GrammarExercise] = {
|
||||
let data: [(String, String, String, String)] = [
|
||||
("Ayer _____ una pizza. (comer, yo)", "comí", "comía", "Preterite — completed action (ayer)."),
|
||||
("Cuando era niño, _____ en el parque. (jugar, yo)", "jugaba", "jugué", "Imperfect — habitual past action."),
|
||||
("Ella _____ a las ocho. (llegar)", "llegó", "llegaba", "Preterite — single completed event."),
|
||||
("_____ sol y los pájaros cantaban. (hacer)", "Hacía", "Hizo", "Imperfect — background description."),
|
||||
("De repente, _____ el teléfono. (sonar)", "sonó", "sonaba", "Preterite — sudden event (de repente)."),
|
||||
("Siempre _____ juntos los domingos. (comer, nosotros)", "comíamos", "comimos", "Imperfect — habitual (siempre)."),
|
||||
("Ayer _____ al cine. (ir, nosotros)", "fuimos", "íbamos", "Preterite — specific completed action."),
|
||||
("Cuando _____ joven, viajaba mucho. (ser, yo)", "era", "fui", "Imperfect — ongoing past state."),
|
||||
("Anoche _____ una película muy buena. (ver, yo)", "vi", "veía", "Preterite — specific time (anoche)."),
|
||||
("Todos los días _____ a la escuela. (caminar, ella)", "caminaba", "caminó", "Imperfect — habitual (todos los días)."),
|
||||
("El año pasado _____ a España. (viajar, ellos)", "viajaron", "viajaban", "Preterite — specific time (el año pasado)."),
|
||||
("Mientras yo _____, ella cocinaba. (estudiar)", "estudiaba", "estudié", "Imperfect — simultaneous background."),
|
||||
("_____ las diez cuando llegamos. (ser)", "Eran", "Fueron", "Imperfect — time description."),
|
||||
("Él _____ la puerta y salió. (abrir)", "abrió", "abría", "Preterite — sequential action."),
|
||||
("De niña, _____ helado cada viernes. (comer, ella)", "comía", "comió", "Imperfect — habitual (de niña)."),
|
||||
("_____ mucho frío ese día. (hacer)", "Hacía", "Hizo", "Imperfect — weather description."),
|
||||
("Una vez, _____ a un famoso. (conocer, yo)", "conocí", "conocía", "Preterite — met for first time."),
|
||||
("Yo _____ a Juan desde niño. (conocer)", "conocía", "conocí", "Imperfect — ongoing familiarity."),
|
||||
("_____ la verdad ayer. (saber, yo)", "Supe", "Sabía", "Preterite — found out (new info)."),
|
||||
("Yo _____ la verdad todo el tiempo. (saber)", "sabía", "supe", "Imperfect — knew (ongoing)."),
|
||||
("Ella _____ un vestido azul. (llevar)", "llevaba", "llevó", "Imperfect — description of what she was wearing."),
|
||||
("Él _____ el vaso y se rompió. (dejar caer)", "dejó caer", "dejaba caer", "Preterite — single event."),
|
||||
("Generalmente _____ a las siete. (despertarse, yo)", "me despertaba", "me desperté", "Imperfect — habitual (generalmente)."),
|
||||
("Esa noche _____ mucho. (llover)", "llovió", "llovía", "Preterite — bounded event (esa noche)."),
|
||||
("_____ lloviendo cuando salí. (estar)", "Estaba", "Estuvo", "Imperfect — ongoing background."),
|
||||
("Yo _____ cuando sonó la alarma. (dormir)", "dormía", "dormí", "Imperfect — interrupted background."),
|
||||
("Ella _____ tres libros el verano pasado. (leer)", "leyó", "leía", "Preterite — counted completed actions."),
|
||||
("Antes, _____ mucho café. (tomar, yo)", "tomaba", "tomé", "Imperfect — habitual (antes)."),
|
||||
("El lunes _____ al médico. (ir, yo)", "fui", "iba", "Preterite — specific day."),
|
||||
("Cada verano _____ a la playa. (ir, nosotros)", "íbamos", "fuimos", "Imperfect — habitual (cada verano)."),
|
||||
("Él me _____ un secreto. (contar)", "contó", "contaba", "Preterite — single event."),
|
||||
("Ella siempre me _____ historias. (contar)", "contaba", "contó", "Imperfect — habitual (siempre)."),
|
||||
("_____ mucha gente en la fiesta. (haber)", "Había", "Hubo", "Imperfect — scene description."),
|
||||
("_____ un accidente en la autopista. (haber)", "Hubo", "Había", "Preterite — single event."),
|
||||
("Cuando _____ al parque, vi a Juan. (llegar, yo)", "llegué", "llegaba", "Preterite — completed action."),
|
||||
("Mientras _____ al parque, vi a Juan. (caminar, yo)", "caminaba", "caminé", "Imperfect — ongoing when interrupted."),
|
||||
("Ella _____ la guitarra de joven. (tocar)", "tocaba", "tocó", "Imperfect — used to (habitual)."),
|
||||
("Ayer ella _____ la guitarra en el concierto. (tocar)", "tocó", "tocaba", "Preterite — specific event."),
|
||||
("Mi abuela _____ muy bien. (cocinar)", "cocinaba", "cocinó", "Imperfect — description of ability."),
|
||||
("Mi abuela _____ una paella ayer. (cocinar)", "cocinó", "cocinaba", "Preterite — specific completed action."),
|
||||
("Yo _____ quince años cuando nos mudamos. (tener)", "tenía", "tuve", "Imperfect — age as background."),
|
||||
("Él _____ un accidente terrible. (tener)", "tuvo", "tenía", "Preterite — single event."),
|
||||
("Nosotros _____ en esa casa por diez años. (vivir)", "vivimos", "vivíamos", "Preterite — bounded duration (completed)."),
|
||||
("Nosotros _____ en esa casa cuando era niño. (vivir)", "vivíamos", "vivimos", "Imperfect — ongoing past setting."),
|
||||
("Ella _____ y se fue. (levantarse)", "se levantó", "se levantaba", "Preterite — sequential."),
|
||||
("Ella _____ temprano cada mañana. (levantarse)", "se levantaba", "se levantó", "Imperfect — habitual."),
|
||||
("¿Qué _____ cuando te llamé? (hacer, tú)", "hacías", "hiciste", "Imperfect — in progress when interrupted."),
|
||||
("¿Qué _____ ayer después de clase? (hacer, tú)", "hiciste", "hacías", "Preterite — completed action."),
|
||||
("El perro _____ todo el día. (ladrar)", "ladró", "ladraba", "Could be both — preterite bounds the whole day."),
|
||||
("El perro _____ cuando llegó el cartero. (ladrar)", "ladraba", "ladró", "Imperfect — background action."),
|
||||
("Yo _____ mucho en esa época. (trabajar)", "trabajaba", "trabajé", "Imperfect — ongoing past period."),
|
||||
("Yo _____ allí por cinco años. (trabajar)", "trabajé", "trabajaba", "Preterite — completed bounded duration."),
|
||||
("La tienda _____ a las nueve. (abrir)", "abrió", "abría", "Preterite — one-time event."),
|
||||
("La tienda _____ a las nueve todos los días. (abrir)", "abría", "abrió", "Imperfect — habitual."),
|
||||
("Él _____ el periódico cada mañana. (leer)", "leía", "leyó", "Imperfect — habitual."),
|
||||
("Él _____ el periódico y luego desayunó. (leer)", "leyó", "leía", "Preterite — sequential."),
|
||||
("_____ una noche oscura y fría. (ser)", "Era", "Fue", "Imperfect — scene setting."),
|
||||
("_____ un día memorable. (ser)", "Fue", "Era", "Preterite — judgment about completed day."),
|
||||
("Yo no _____ nada. (decir)", "dije", "decía", "Preterite — single action."),
|
||||
("Ella siempre _____ la verdad. (decir)", "decía", "dijo", "Imperfect — habitual."),
|
||||
("Los niños _____ en el jardín. (jugar)", "jugaban", "jugaron", "Imperfect — ongoing scene."),
|
||||
("Los niños _____ toda la tarde. (jugar)", "jugaron", "jugaban", "Preterite — bounded duration."),
|
||||
("Cuando _____ niño, mi padre me leía cuentos. (ser, yo)", "era", "fui", "Imperfect — background."),
|
||||
("Él _____ presidente por ocho años. (ser)", "fue", "era", "Preterite — bounded duration."),
|
||||
("_____ las seis de la mañana cuando desperté. (ser)", "Eran", "Fueron", "Imperfect — time."),
|
||||
("_____ un buen año para la empresa. (ser)", "Fue", "Era", "Preterite — completed period judged."),
|
||||
("Ella _____ triste cuando recibió la noticia. (ponerse)", "se puso", "se ponía", "Preterite — became (change of state)."),
|
||||
("Ella _____ triste cada vez que llovía. (ponerse)", "se ponía", "se puso", "Imperfect — habitual reaction."),
|
||||
("Yo _____ poder ir, pero no pude. (querer)", "quería", "quise", "Imperfect — wanted (ongoing desire)."),
|
||||
("Él no _____ hacerlo. (querer)", "quiso", "quería", "Preterite — refused (completed decision)."),
|
||||
("Nosotros _____ a la playa el domingo. (ir)", "fuimos", "íbamos", "Preterite — specific completed trip."),
|
||||
("_____ a la playa cuando empezó a llover. (ir, nosotros)", "Íbamos", "Fuimos", "Imperfect — were going (interrupted)."),
|
||||
("Ella _____ muy contenta en su nuevo trabajo. (estar)", "estaba", "estuvo", "Imperfect — ongoing state."),
|
||||
("Ella _____ enferma toda la semana. (estar)", "estuvo", "estaba", "Preterite — bounded duration."),
|
||||
("Mi abuelo _____ cuentos increíbles. (contar)", "contaba", "contó", "Imperfect — used to tell."),
|
||||
("Esa vez mi abuelo nos _____ una historia de miedo. (contar)", "contó", "contaba", "Preterite — specific occasion."),
|
||||
("Yo _____ en el sofá cuando oí un ruido. (estar)", "estaba", "estuve", "Imperfect — background when interrupted."),
|
||||
("Ella _____ rápidamente y llamó al médico. (vestirse)", "se vistió", "se vestía", "Preterite — sequential."),
|
||||
("A menudo _____ por el bosque. (caminar, nosotros)", "caminábamos", "caminamos", "Imperfect — habitual (a menudo)."),
|
||||
("Esa tarde _____ por el bosque. (caminar, nosotros)", "caminamos", "caminábamos", "Preterite — specific occasion."),
|
||||
("La profesora _____ muy estricta. (ser)", "era", "fue", "Imperfect — description."),
|
||||
("La profesora _____ muy amable con nosotros ese día. (ser)", "fue", "era", "Preterite — specific day."),
|
||||
("¿_____ mucho en tu ciudad natal? (llover)", "Llovía", "Llovió", "Imperfect — general weather pattern."),
|
||||
("¿_____ ayer? (llover)", "Llovió", "Llovía", "Preterite — specific day."),
|
||||
("El niño _____ porque tenía hambre. (llorar)", "lloraba", "lloró", "Imperfect — ongoing due to reason."),
|
||||
("El niño _____ cuando se cayó. (llorar)", "lloró", "lloraba", "Preterite — reaction to event."),
|
||||
("Yo _____ cocinar cuando era joven. (no saber)", "no sabía", "no supe", "Imperfect — ongoing lack."),
|
||||
("Yo _____ cocinar hasta que tomé clases. (no saber)", "no supe", "no sabía", "Preterite — realized/found out."),
|
||||
("Ella _____ la carta y empezó a llorar. (leer)", "leyó", "leía", "Preterite — completed then next action."),
|
||||
("Él _____ cuando entré. (hablar)", "hablaba", "habló", "Imperfect — was speaking (interrupted)."),
|
||||
("Nosotros _____ en ese restaurante muchas veces. (cenar)", "cenábamos", "cenamos", "Imperfect — habitual."),
|
||||
("Nosotros _____ en ese restaurante anoche. (cenar)", "cenamos", "cenábamos", "Preterite — specific night."),
|
||||
("_____ un día perfecto para ir a la playa. (ser)", "Era", "Fue", "Imperfect — description/setting."),
|
||||
("Ella _____ la primera en llegar. (ser)", "fue", "era", "Preterite — completed fact."),
|
||||
("Yo _____ en silencio mientras él hablaba. (escuchar)", "escuchaba", "escuché", "Imperfect — simultaneous."),
|
||||
("Yo _____ todo su discurso. (escuchar)", "escuché", "escuchaba", "Preterite — listened to completion."),
|
||||
("El tren _____ a las tres en punto. (salir)", "salió", "salía", "Preterite — specific departure."),
|
||||
("El tren _____ a las tres todos los días. (salir)", "salía", "salió", "Imperfect — habitual schedule."),
|
||||
("Ella _____ el piano maravillosamente. (tocar)", "tocaba", "tocó", "Imperfect — ability description."),
|
||||
]
|
||||
return data.enumerated().map { i, d in
|
||||
GrammarExercise(id: "pi\(i+1)", prompt: "Choose the correct tense:", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Subjunctive Triggers (100)
|
||||
|
||||
private static let subjunctiveTriggerExercises: [GrammarExercise] = {
|
||||
let data: [(String, String, String, String)] = [
|
||||
("Quiero que _____ a la fiesta. (venir, tú)", "vengas", "vienes", "Subjunctive — querer (wish)."),
|
||||
("Es necesario que _____ más. (estudiar, tú)", "estudies", "estudias", "Subjunctive — impersonal expression."),
|
||||
("Sé que ella _____ aquí. (estar)", "está", "esté", "Indicative — saber (certainty)."),
|
||||
("Me alegra que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (alegrarse)."),
|
||||
("Dudo que _____ la verdad. (decir, él)", "diga", "dice", "Subjunctive — doubt (dudar)."),
|
||||
("Es posible que _____ mañana. (llover)", "llueva", "llueve", "Subjunctive — possibility."),
|
||||
("Espero que _____ bien. (estar, tú)", "estés", "estás", "Subjunctive — hope (esperar)."),
|
||||
("Creo que _____ razón. (tener, tú)", "tienes", "tengas", "Indicative — creer (belief)."),
|
||||
("No creo que _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — negated belief."),
|
||||
("Es importante que _____ puntual. (ser, tú)", "seas", "eres", "Subjunctive — impersonal expression."),
|
||||
("Ojalá que _____ buen tiempo. (hacer)", "haga", "hace", "Subjunctive — ojalá (wish)."),
|
||||
("Te pido que _____ silencio. (guardar)", "guardes", "guardas", "Subjunctive — pedir (request)."),
|
||||
("Es cierto que _____ mucho. (trabajar, ella)", "trabaja", "trabaje", "Indicative — es cierto (certainty)."),
|
||||
("No es cierto que _____ mucho. (trabajar, ella)", "trabaje", "trabaja", "Subjunctive — negated certainty."),
|
||||
("Prefiero que _____ tú. (conducir)", "conduzcas", "conduces", "Subjunctive — preferir (preference)."),
|
||||
("Siento que no _____ venir. (poder, tú)", "puedas", "puedes", "Subjunctive — sentir (emotion)."),
|
||||
("Es obvio que _____ cansado. (estar, él)", "está", "esté", "Indicative — es obvio (certainty)."),
|
||||
("Necesito que me _____ un favor. (hacer, tú)", "hagas", "haces", "Subjunctive — necesitar que."),
|
||||
("Es mejor que _____ temprano. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es mejor que."),
|
||||
("Estoy seguro de que _____ bien. (ir, todo)", "va", "vaya", "Indicative — estar seguro (certainty)."),
|
||||
("Temo que _____ demasiado tarde. (ser)", "sea", "es", "Subjunctive — temer (fear)."),
|
||||
("Sugiero que _____ más agua. (beber, tú)", "bebas", "bebes", "Subjunctive — sugerir (suggestion)."),
|
||||
("Es verdad que _____ difícil. (ser)", "es", "sea", "Indicative — es verdad (truth)."),
|
||||
("No es verdad que _____ difícil. (ser)", "sea", "es", "Subjunctive — negated truth."),
|
||||
("Quiero que _____ la puerta. (cerrar, tú)", "cierres", "cierras", "Subjunctive — querer."),
|
||||
("Deseo que _____ feliz. (ser, tú)", "seas", "eres", "Subjunctive — desear (wish)."),
|
||||
("Es probable que _____ tarde. (llegar, ellos)", "lleguen", "llegan", "Subjunctive — es probable."),
|
||||
("Es improbable que _____ hoy. (nevar)", "nieve", "nieva", "Subjunctive — es improbable."),
|
||||
("Me molesta que _____ tanto ruido. (hacer, ellos)", "hagan", "hacen", "Subjunctive — emotion (molestar)."),
|
||||
("Es evidente que _____ talento. (tener, ella)", "tiene", "tenga", "Indicative — es evidente."),
|
||||
("Recomiendo que _____ este libro. (leer, tú)", "leas", "lees", "Subjunctive — recomendar."),
|
||||
("Exijo que _____ a tiempo. (llegar, todos)", "lleguen", "llegan", "Subjunctive — exigir (demand)."),
|
||||
("Es una lástima que no _____ ir. (poder, tú)", "puedas", "puedes", "Subjunctive — es una lástima."),
|
||||
("Me sorprende que _____ tan joven. (ser, él)", "sea", "es", "Subjunctive — surprise (emotion)."),
|
||||
("Insisto en que _____ la verdad. (decir, tú)", "digas", "dices", "Subjunctive — insistir."),
|
||||
("Es extraño que no _____ aquí. (estar, ella)", "esté", "está", "Subjunctive — es extraño."),
|
||||
("Prohíbo que _____ en clase. (comer, ustedes)", "coman", "comen", "Subjunctive — prohibir."),
|
||||
("Permito que _____ temprano. (salir, tú)", "salgas", "sales", "Subjunctive — permitir."),
|
||||
("Es dudoso que _____ a tiempo. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — es dudoso."),
|
||||
("Pienso que _____ inteligente. (ser, ella)", "es", "sea", "Indicative — pensar (opinion)."),
|
||||
("No pienso que _____ justo. (ser)", "sea", "es", "Subjunctive — negated opinion."),
|
||||
("Me encanta que _____ español. (hablar, tú)", "hables", "hablas", "Subjunctive — emotion (encantar)."),
|
||||
("Es fantástico que _____ aquí. (estar, ustedes)", "estén", "están", "Subjunctive — es fantástico."),
|
||||
("Mando que _____ inmediatamente. (venir, tú)", "vengas", "vienes", "Subjunctive — mandar."),
|
||||
("Es ridículo que _____ eso. (pensar, él)", "piense", "piensa", "Subjunctive — es ridículo."),
|
||||
("Busco a alguien que _____ francés. (hablar)", "hable", "habla", "Subjunctive — nonexistent antecedent."),
|
||||
("Conozco a alguien que _____ francés. (hablar)", "habla", "hable", "Indicative — known antecedent."),
|
||||
("No hay nadie que _____ eso. (saber)", "sepa", "sabe", "Subjunctive — negative antecedent."),
|
||||
("Cuando _____ a casa, llámame. (llegar, tú)", "llegues", "llegas", "Subjunctive — cuando + future."),
|
||||
("Cuando _____ a casa, siempre como. (llegar, yo)", "llego", "llegue", "Indicative — cuando + habitual."),
|
||||
("Antes de que _____, quiero decirte algo. (ir, tú)", "te vayas", "te vas", "Subjunctive — antes de que."),
|
||||
("Después de que _____, descansaremos. (terminar, nosotros)", "terminemos", "terminamos", "Subjunctive — después de que + future."),
|
||||
("Aunque _____ mucho, iré. (llover)", "llueva", "llueve", "Subjunctive — aunque + hypothetical."),
|
||||
("Aunque _____ mucho, siempre voy. (llover)", "llueve", "llueva", "Indicative — aunque + factual."),
|
||||
("Para que _____ bien, debes practicar. (salir, todo)", "salga", "sale", "Subjunctive — para que."),
|
||||
("Sin que nadie lo _____. (saber)", "sepa", "sabe", "Subjunctive — sin que."),
|
||||
("Con tal de que _____ contento. (estar, tú)", "estés", "estás", "Subjunctive — con tal de que."),
|
||||
("A menos que _____ temprano, perderás el tren. (salir, tú)", "salgas", "sales", "Subjunctive — a menos que."),
|
||||
("En caso de que _____, llámame. (necesitar, tú)", "necesites", "necesitas", "Subjunctive — en caso de que."),
|
||||
("Mientras _____ aquí, todo estará bien. (estar, yo)", "esté", "estoy", "Subjunctive — mientras + uncertainty."),
|
||||
("Tan pronto como _____, empezamos. (llegar, él)", "llegue", "llega", "Subjunctive — tan pronto como + future."),
|
||||
("Hasta que no _____, no me voy. (terminar, tú)", "termines", "terminas", "Subjunctive — hasta que + future."),
|
||||
("Es hora de que _____ la verdad. (saber, tú)", "sepas", "sabes", "Subjunctive — es hora de que."),
|
||||
("Espero que _____ un buen día. (tener, tú)", "tengas", "tienes", "Subjunctive — esperar."),
|
||||
("Dile que _____ aquí. (venir)", "venga", "viene", "Subjunctive — indirect command."),
|
||||
("No hay nada que _____ hacer. (poder, yo)", "pueda", "puedo", "Subjunctive — negative existence."),
|
||||
("Es normal que _____ nervioso. (estar, tú)", "estés", "estás", "Subjunctive — es normal que."),
|
||||
("Me da miedo que _____ sola. (ir, ella)", "vaya", "va", "Subjunctive — emotion (dar miedo)."),
|
||||
("Es urgente que _____ al doctor. (ir, tú)", "vayas", "vas", "Subjunctive — es urgente."),
|
||||
("No quiero que _____ tarde. (llegar, tú)", "llegues", "llegas", "Subjunctive — no querer."),
|
||||
("Tal vez _____ razón. (tener, tú)", "tengas", "tienes", "Subjunctive — tal vez."),
|
||||
("Quizás _____ mañana. (venir, ella)", "venga", "viene", "Subjunctive — quizás."),
|
||||
("Es imposible que _____ tan rápido. (terminar, él)", "termine", "termina", "Subjunctive — es imposible."),
|
||||
("Parece que _____ contento. (estar, él)", "está", "esté", "Indicative — parece que (appears)."),
|
||||
("No parece que _____ contento. (estar, él)", "esté", "está", "Subjunctive — negated parece."),
|
||||
("Dice que _____ mañana. (venir)", "viene", "venga", "Indicative — decir reporting fact."),
|
||||
("Dice que _____ mañana. (venir — as command)", "venga", "viene", "Subjunctive — decir as command."),
|
||||
("Me preocupa que no _____ bien. (sentirse, tú)", "te sientas", "te sientes", "Subjunctive — emotion (preocupar)."),
|
||||
("Es raro que _____ tanto calor. (hacer)", "haga", "hace", "Subjunctive — es raro."),
|
||||
("Confío en que _____ bien. (salir, todo)", "salga", "sale", "Subjunctive — confiar en que."),
|
||||
("Es fundamental que _____ la tarea. (hacer, ustedes)", "hagan", "hacen", "Subjunctive — es fundamental."),
|
||||
("Me pone triste que _____ así. (ser, las cosas)", "sean", "son", "Subjunctive — emotion."),
|
||||
("Aconsejo que _____ más temprano. (acostarse, tú)", "te acuestes", "te acuestas", "Subjunctive — aconsejar."),
|
||||
("Es bueno que _____ ejercicio. (hacer, tú)", "hagas", "haces", "Subjunctive — es bueno que."),
|
||||
("Es malo que _____ tanto. (fumar, él)", "fume", "fuma", "Subjunctive — es malo que."),
|
||||
("Me gusta que _____ aquí. (estar, tú)", "estés", "estás", "Subjunctive — emotion (gustar que)."),
|
||||
("No creo que _____ la respuesta. (saber, él)", "sepa", "sabe", "Subjunctive — negated belief."),
|
||||
("Es increíble que _____ tan rápido. (aprender, ella)", "aprenda", "aprende", "Subjunctive — es increíble."),
|
||||
("Ojala _____ más tiempo. (tener, nosotros)", "tengamos", "tenemos", "Subjunctive — ojalá."),
|
||||
("Niego que _____ la verdad. (ser, eso)", "sea", "es", "Subjunctive — negar (deny)."),
|
||||
("Es preciso que _____ ahora. (salir, nosotros)", "salgamos", "salimos", "Subjunctive — es preciso."),
|
||||
("Te aconsejo que _____ paciencia. (tener)", "tengas", "tienes", "Subjunctive — aconsejar."),
|
||||
("Basta que _____ una vez. (decir, tú)", "digas", "dices", "Subjunctive — bastar que."),
|
||||
("Conviene que _____ preparado. (estar, tú)", "estés", "estás", "Subjunctive — convenir que."),
|
||||
("Es natural que _____ preocupado. (estar, él)", "esté", "está", "Subjunctive — es natural."),
|
||||
("Ruego que me _____. (perdonar, tú)", "perdones", "perdonas", "Subjunctive — rogar."),
|
||||
("Es suficiente que _____ una carta. (escribir, tú)", "escribas", "escribes", "Subjunctive — es suficiente que."),
|
||||
("Me fascina que _____ tantos idiomas. (hablar, ella)", "hable", "habla", "Subjunctive — emotion (fascinar)."),
|
||||
("Hace falta que _____ más esfuerzo. (poner, nosotros)", "pongamos", "ponemos", "Subjunctive — hacer falta que."),
|
||||
]
|
||||
return data.enumerated().map { i, d in
|
||||
GrammarExercise(id: "st\(i+1)", prompt: "Subjunctive or indicative?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Personal A (100)
|
||||
|
||||
private static let personalAExercises: [GrammarExercise] = {
|
||||
let data: [(String, String, String, String)] = [
|
||||
("Veo _____ María.", "a", "(nothing)", "Personal a — specific person as direct object."),
|
||||
("Veo _____ la mesa.", "(nothing)", "a", "No personal a — thing, not person."),
|
||||
("Tengo _____ dos hermanos.", "(nothing)", "a", "No personal a after tener."),
|
||||
("Conozco _____ tu profesor.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Busco _____ un doctor.", "(nothing)", "a", "No personal a — non-specific person."),
|
||||
("No veo _____ nadie.", "a", "(nothing)", "Personal a with nadie."),
|
||||
("Llamo _____ mi madre.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Extraño _____ mis amigos.", "a", "(nothing)", "Personal a — specific people."),
|
||||
("Necesito _____ un traductor.", "(nothing)", "a", "No personal a — any translator."),
|
||||
("Necesito _____ mi traductor.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("¿Conoces _____ alguien aquí?", "a", "(nothing)", "Personal a with alguien."),
|
||||
("¿_____ quién llamaste?", "A", "(nothing)", "Personal a with quién."),
|
||||
("Invité _____ Juan a la fiesta.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Compré _____ un libro.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Llevo _____ mi perro al veterinario.", "a", "(nothing)", "Personal a — beloved pet."),
|
||||
("Quiero _____ mi familia.", "a", "(nothing)", "Personal a — loving people."),
|
||||
("Leo _____ un libro.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Escucho _____ mi profesora.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Escucho _____ música.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Busco _____ mi hija.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Busco _____ mis llaves.", "(nothing)", "a", "No personal a — things."),
|
||||
("Vi _____ Carlos en el parque.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Vi _____ una película.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Admiro _____ esa mujer.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Tiene _____ tres hijos.", "(nothing)", "a", "No personal a after tener."),
|
||||
("Ayudo _____ mi vecina.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Encontré _____ Pedro en la tienda.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Encontré _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing/place."),
|
||||
("Esperamos _____ nuestros padres.", "a", "(nothing)", "Personal a — specific people."),
|
||||
("Esperamos _____ el autobús.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Odio _____ la violencia.", "(nothing)", "a", "No personal a — abstract concept."),
|
||||
("Odio _____ ese hombre.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Contrataron _____ un ingeniero.", "(nothing)", "a", "No personal a — non-specific person."),
|
||||
("Contrataron _____ María.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Cuido _____ mis hijos.", "a", "(nothing)", "Personal a — caring for people."),
|
||||
("Cuido _____ mi jardín.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Respeto _____ mis abuelos.", "a", "(nothing)", "Personal a — specific people."),
|
||||
("Respeto _____ las reglas.", "(nothing)", "a", "No personal a — things."),
|
||||
("Visité _____ mi tía.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Visité _____ el museo.", "(nothing)", "a", "No personal a — place."),
|
||||
("Abandonó _____ su familia.", "a", "(nothing)", "Personal a — people."),
|
||||
("Abandonó _____ su coche.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Presenté _____ mi novio.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Traje _____ mi hermano.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Traje _____ comida.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Echamos de menos _____ nuestros amigos.", "a", "(nothing)", "Personal a — missing people."),
|
||||
("Mandé _____ los niños al colegio.", "a", "(nothing)", "Personal a — sending people."),
|
||||
("Mandé _____ una carta.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Saludé _____ la vecina.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Abrí _____ la puerta.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Elegimos _____ un nuevo líder.", "a", "(nothing)", "Personal a — specific person elected."),
|
||||
("Elegimos _____ un buen restaurante.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Acusaron _____ el sospechoso.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Derribaron _____ el edificio.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Recogí _____ los niños del colegio.", "a", "(nothing)", "Personal a — picking up people."),
|
||||
("Recogí _____ mis cosas.", "(nothing)", "a", "No personal a — things."),
|
||||
("Críticaron _____ el presidente.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Critícaron _____ la decisión.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Perdoné _____ mi amigo.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Perdoné _____ su error.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Describió _____ su madre.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Describió _____ la situación.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Abracé _____ mi abuela.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Obedezco _____ mis padres.", "a", "(nothing)", "Personal a — people."),
|
||||
("Obedezco _____ las leyes.", "(nothing)", "a", "No personal a — things."),
|
||||
("Felicité _____ mi compañero.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Cuidamos _____ nuestro gato.", "a", "(nothing)", "Personal a — beloved pet."),
|
||||
("Cuidamos _____ la casa.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Castigaron _____ los culpables.", "a", "(nothing)", "Personal a — specific people."),
|
||||
("Repararon _____ el techo.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Defendí _____ mi hermana.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Defendí _____ mi posición.", "(nothing)", "a", "No personal a — abstract."),
|
||||
("Acompañé _____ mi amiga al aeropuerto.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Ignoré _____ el comentario.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Ignoré _____ esa persona.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Reconocí _____ Juan inmediatamente.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Reconocí _____ la canción.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Salvaron _____ los pasajeros.", "a", "(nothing)", "Personal a — people."),
|
||||
("Salvaron _____ los documentos.", "(nothing)", "a", "No personal a — things."),
|
||||
("Atendemos _____ nuestros clientes.", "a", "(nothing)", "Personal a — people."),
|
||||
("Atendemos _____ los pedidos.", "(nothing)", "a", "No personal a — things."),
|
||||
("Despidieron _____ tres empleados.", "a", "(nothing)", "Personal a — people."),
|
||||
("Pintaron _____ la casa.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Enseño _____ mis estudiantes.", "a", "(nothing)", "Personal a — people."),
|
||||
("Enseño _____ español.", "(nothing)", "a", "No personal a — subject/thing."),
|
||||
("Protegemos _____ los niños.", "a", "(nothing)", "Personal a — people."),
|
||||
("Protegemos _____ el medio ambiente.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Entrevisté _____ la candidata.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Preparé _____ la cena.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Culparon _____ los responsables.", "a", "(nothing)", "Personal a — people."),
|
||||
("Cerraron _____ la tienda.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Seguí _____ el ladrón.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Seguí _____ las instrucciones.", "(nothing)", "a", "No personal a — things."),
|
||||
("Engañaron _____ los clientes.", "a", "(nothing)", "Personal a — people."),
|
||||
("Rompieron _____ la ventana.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Consulté _____ un especialista.", "a", "(nothing)", "Personal a — specific person."),
|
||||
("Consulté _____ un diccionario.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Persiguieron _____ los criminales.", "a", "(nothing)", "Personal a — people."),
|
||||
("Lavé _____ el coche.", "(nothing)", "a", "No personal a — thing."),
|
||||
("Detuvieron _____ los manifestantes.", "a", "(nothing)", "Personal a — people."),
|
||||
]
|
||||
return data.enumerated().map { i, d in
|
||||
GrammarExercise(id: "pa\(i+1)", prompt: "Is the personal 'a' needed?", sentence: d.0, correctAnswer: d.1, options: [d.1, d.2].shuffled(), explanation: d.3)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -28,6 +28,9 @@ struct GrammarNote: Identifiable {
|
||||
accentMarksStress,
|
||||
seConstructions,
|
||||
estarGerundProgressive,
|
||||
spanishSuffixes,
|
||||
commonIrregularVerbs,
|
||||
typesOfIrregularVerbs,
|
||||
]
|
||||
|
||||
// MARK: - 1. Ser vs Estar
|
||||
@@ -887,4 +890,388 @@ struct GrammarNote: Identifiable {
|
||||
**Other verbs with gerunds:** While *estar* is the most common, other verbs can also combine with gerunds: *seguir* (to keep on), *continuar* (to continue), *andar* (to go around), *llevar* (duration). *Sigo estudiando español.* — I keep on studying Spanish. *Llevo tres años viviendo aquí.* — I've been living here for three years.
|
||||
"""
|
||||
)
|
||||
|
||||
// MARK: - 21. Spanish Suffixes
|
||||
|
||||
private static let spanishSuffixes = GrammarNote(
|
||||
id: "spanish-suffixes",
|
||||
title: "Spanish Suffixes",
|
||||
category: "Word Building",
|
||||
body: """
|
||||
Spanish uses suffixes — endings added to root words — to change meaning, size, tone, or part of speech. Learning these patterns lets you decode unfamiliar words and build new ones from roots you already know.
|
||||
|
||||
**Diminutives — smallness and affection**
|
||||
|
||||
Diminutive suffixes make a noun feel smaller, cuter, or more endearing. They are extremely common in everyday speech.
|
||||
|
||||
*-ito / -ita* — the most common diminutive. Expresses smallness or affection.
|
||||
*perro → perrito* — little dog
|
||||
*casa → casita* — little house
|
||||
*momento → momentito* — just a moment
|
||||
*abuela → abuelita* — grandma (affectionate)
|
||||
|
||||
*-cito / -cita* — used after words ending in a consonant or -e.
|
||||
*café → cafecito* — a nice little coffee
|
||||
*pobre → pobrecito* — poor little thing
|
||||
*amor → amorcito* — sweetheart
|
||||
|
||||
*-illo / -illa* — a slightly less affectionate diminutive.
|
||||
*chico → chiquillo* — little kid
|
||||
*guerra → guerrilla* — small war / guerrilla
|
||||
*pan → panecillo* — bread roll
|
||||
|
||||
*-ico / -ica* — regional diminutive popular in Colombia, Cuba, and Costa Rica.
|
||||
*rato → ratico* — a little while
|
||||
*gato → gatico* — little cat
|
||||
|
||||
**Augmentatives — bigness and intensity**
|
||||
|
||||
Augmentative suffixes make nouns feel bigger, more intense, or sometimes clumsier. They often carry a slightly negative or humorous tone.
|
||||
|
||||
*-ón / -ona* — big or intense, sometimes negative.
|
||||
*silla → sillón* — armchair (big chair)
|
||||
*soltar → solterón* — confirmed bachelor
|
||||
*cabeza → cabezón* — big-headed / stubborn
|
||||
|
||||
*-ote / -ota* — big, often clumsy or exaggerated.
|
||||
*grande → grandote* — really big
|
||||
*amigo → amigote* — a big buddy (casual)
|
||||
*palabra → palabrota* — a swear word (a "big word")
|
||||
|
||||
*-azo / -aza* — big; also means a blow or hit with something.
|
||||
*perro → perrazo* — huge dog
|
||||
*puño → puñetazo* — a punch
|
||||
*gol → golazo* — an amazing goal
|
||||
*codo → codazo* — an elbow jab
|
||||
|
||||
*-udo / -uda* — having a lot of something.
|
||||
*pelo → peludo* — hairy
|
||||
*barba → barbudo* — bearded
|
||||
*panza → panzudo* — big-bellied
|
||||
|
||||
**Verb-related endings**
|
||||
|
||||
These endings are built into the verb conjugation system and tell you when and how the action occurs.
|
||||
|
||||
*-ando* — gerund for -ar verbs (happening now, equivalent to English "-ing").
|
||||
*hablar → hablando* — speaking
|
||||
*caminar → caminando* — walking
|
||||
*estudiar → estudiando* — studying
|
||||
|
||||
*-iendo* — gerund for -er and -ir verbs.
|
||||
*comer → comiendo* — eating
|
||||
*vivir → viviendo* — living
|
||||
*leer → leyendo* — reading (note the spelling change)
|
||||
|
||||
*-ado* — past participle for -ar verbs (equivalent to English "-ed").
|
||||
*hablar → hablado* — spoken
|
||||
*cerrar → cerrado* — closed
|
||||
*cansar → cansado* — tired
|
||||
|
||||
*-ido* — past participle for -er and -ir verbs.
|
||||
*comer → comido* — eaten
|
||||
*vivir → vivido* — lived
|
||||
*dormir → dormido* — slept
|
||||
|
||||
**Noun-forming suffixes**
|
||||
|
||||
These suffixes turn verbs or adjectives into nouns, creating words for actions, results, places, and people.
|
||||
|
||||
*-ción / -sión* — action or result, equivalent to English "-tion."
|
||||
*actuar → acción* — action
|
||||
*decidir → decisión* — decision
|
||||
*comunicar → comunicación* — communication
|
||||
*expresar → expresión* — expression
|
||||
|
||||
*-miento* — action or result, similar to -ción.
|
||||
*conocer → conocimiento* — knowledge
|
||||
*sentir → sentimiento* — feeling
|
||||
*mover → movimiento* — movement
|
||||
|
||||
*-ería* — a shop, business, or place associated with something.
|
||||
*pan → panadería* — bakery
|
||||
*libro → librería* — bookstore
|
||||
*zapato → zapatería* — shoe store
|
||||
*carne → carnicería* — butcher shop
|
||||
|
||||
*-ero / -era* — a person who does or makes something.
|
||||
*pan → panadero* — baker
|
||||
*cocina → cocinero* — cook
|
||||
*carta → cartero* — mail carrier
|
||||
*enfermo → enfermera* — nurse
|
||||
|
||||
*-dor / -dora* — a person or thing that does something.
|
||||
*jugar → jugador* — player
|
||||
*trabajar → trabajador* — worker
|
||||
*computar → computadora* — computer
|
||||
*lavar → lavadora* — washing machine
|
||||
|
||||
*-ista* — a person devoted to something (gender-neutral form).
|
||||
*arte → artista* — artist
|
||||
*diente → dentista* — dentist
|
||||
*fútbol → futbolista* — soccer player
|
||||
*piano → pianista* — pianist
|
||||
|
||||
*-ura* — a quality or result.
|
||||
*dulce → dulzura* — sweetness
|
||||
*loco → locura* — madness
|
||||
*abrir → abertura* — opening
|
||||
*pintar → pintura* — painting / paint
|
||||
|
||||
*-eza* — an abstract quality.
|
||||
*bello → belleza* — beauty
|
||||
*triste → tristeza* — sadness
|
||||
*puro → pureza* — purity
|
||||
*natural → naturaleza* — nature
|
||||
|
||||
*-dad / -tad* — a quality, equivalent to English "-ty."
|
||||
*libre → libertad* — liberty
|
||||
*real → realidad* — reality
|
||||
*feliz → felicidad* — happiness
|
||||
*amigo → amistad* — friendship
|
||||
|
||||
*-anza* — action or result.
|
||||
*esperar → esperanza* — hope
|
||||
*enseñar → enseñanza* — teaching
|
||||
*confiar → confianza* — trust / confidence
|
||||
|
||||
**Adjective-forming suffixes**
|
||||
|
||||
These turn nouns or verbs into adjectives that describe qualities, origins, or tendencies.
|
||||
|
||||
*-oso / -osa* — full of something, equivalent to English "-ous."
|
||||
*peligro → peligroso* — dangerous
|
||||
*hermosura → hermoso* — beautiful
|
||||
*cariño → cariñoso* — affectionate
|
||||
*éxito → exitoso* — successful
|
||||
|
||||
*-ano / -ana* — relating to a place or group.
|
||||
*México → mexicano* — Mexican
|
||||
*América → americano* — American
|
||||
*cristiano* — Christian
|
||||
*humano* — human
|
||||
|
||||
*-eño / -eña* — from a place.
|
||||
*Panamá → panameño* — Panamanian
|
||||
*Brasil → brasileño* — Brazilian
|
||||
*isla → isleño* — islander
|
||||
|
||||
*-ense* — from a place (often countries or cities).
|
||||
*Costa Rica → costarricense* — Costa Rican
|
||||
*Estados Unidos → estadounidense* — American (USA)
|
||||
*Canadá → canadiense* — Canadian
|
||||
|
||||
*-ble* — able to be, equivalent to English "-ble."
|
||||
*posible* — possible
|
||||
*increíble* — incredible
|
||||
*responsable* — responsible
|
||||
*agradable* — pleasant
|
||||
|
||||
*-ivo / -iva* — tending toward, equivalent to English "-ive."
|
||||
*actuar → activo* — active
|
||||
*crear → creativo* — creative
|
||||
*producir → productivo* — productive
|
||||
|
||||
**Adverb and intensity suffixes**
|
||||
|
||||
*-mente* — turns adjectives into adverbs, equivalent to English "-ly." Attach it to the feminine form of the adjective.
|
||||
*rápida → rápidamente* — quickly
|
||||
*fácil → fácilmente* — easily
|
||||
*tranquila → tranquilamente* — calmly
|
||||
*completa → completamente* — completely
|
||||
|
||||
*-ísimo / -ísima* — absolute superlative, meaning "extremely" or "super."
|
||||
*rápido → rapidísimo* — super fast
|
||||
*bella → bellísima* — extremely beautiful
|
||||
*mucho → muchísimo* — very very much
|
||||
*grande → grandísimo* — enormous
|
||||
|
||||
**Pejorative suffixes — negative or ugly tone**
|
||||
|
||||
These suffixes add a negative, derogatory, or ugly connotation.
|
||||
|
||||
*-ucho / -ucha* — ugly, run-down.
|
||||
*casa → casucha* — a shack, a dump
|
||||
*delgado → delgaducho* — scrawny
|
||||
|
||||
*-aco / -aca* — ugly, derogatory.
|
||||
*pájaro → pajarraco* — ugly bird
|
||||
*libro → libraco* — a terrible book
|
||||
|
||||
**Key insight:** Once you recognize root + suffix patterns, you can decode unfamiliar words. For example, *panadería* = *pan* (bread) + *-ero* (person) + *-ía* (place) = "place where the bread-person works" = bakery. The suffix system makes Spanish vocabulary highly predictable and composable.
|
||||
"""
|
||||
)
|
||||
|
||||
// MARK: - 22. Most Common Irregular Verbs
|
||||
|
||||
private static let commonIrregularVerbs = GrammarNote(
|
||||
id: "common-irregular-verbs",
|
||||
title: "Most Common Irregular Verbs",
|
||||
category: "Irregular Verbs",
|
||||
body: """
|
||||
These 15 verbs are among the most used in Spanish — and they are all irregular. Mastering their forms is essential because you will encounter them in nearly every conversation.
|
||||
|
||||
**ser** — to be (permanent)
|
||||
|
||||
*yo soy, tú eres, él es* — Present
|
||||
*yo fui, tú fuiste, él fue* — Preterite
|
||||
*Soy estudiante.* — I am a student.
|
||||
|
||||
**estar** — to be (temporary/location)
|
||||
|
||||
*yo estoy, tú estás, él está* — Present
|
||||
*yo estuve, tú estuviste, él estuvo* — Preterite
|
||||
*Estoy cansado.* — I am tired.
|
||||
|
||||
**ir** — to go
|
||||
|
||||
*yo voy, tú vas, él va* — Present
|
||||
*yo fui, tú fuiste, él fue* — Preterite (same as ser!)
|
||||
*Voy al supermercado.* — I'm going to the supermarket.
|
||||
|
||||
**haber** — to have (auxiliary)
|
||||
|
||||
*yo he, tú has, él ha* — Present
|
||||
*yo hube, tú hubiste, él hubo* — Preterite
|
||||
*He comido ya.* — I have already eaten.
|
||||
|
||||
**tener** — to have (possession)
|
||||
|
||||
*yo tengo, tú tienes, él tiene* — Present
|
||||
*yo tuve, tú tuviste, él tuvo* — Preterite
|
||||
*Tengo dos hermanos.* — I have two siblings.
|
||||
|
||||
**hacer** — to do / to make
|
||||
|
||||
*yo hago, tú haces, él hace* — Present
|
||||
*yo hice, tú hiciste, él hizo* — Preterite
|
||||
*¿Qué haces?* — What are you doing?
|
||||
|
||||
**poder** — to be able to / can
|
||||
|
||||
*yo puedo, tú puedes, él puede* — Present
|
||||
*yo pude, tú pudiste, él pudo* — Preterite
|
||||
*No puedo dormir.* — I can't sleep.
|
||||
|
||||
**querer** — to want / to love
|
||||
|
||||
*yo quiero, tú quieres, él quiere* — Present
|
||||
*yo quise, tú quisiste, él quiso* — Preterite
|
||||
*Quiero agua, por favor.* — I want water, please.
|
||||
|
||||
**decir** — to say / to tell
|
||||
|
||||
*yo digo, tú dices, él dice* — Present
|
||||
*yo dije, tú dijiste, él dijo* — Preterite
|
||||
*¿Qué dices?* — What are you saying?
|
||||
|
||||
**venir** — to come
|
||||
|
||||
*yo vengo, tú vienes, él viene* — Present
|
||||
*yo vine, tú viniste, él vino* — Preterite
|
||||
*Vengo de la tienda.* — I'm coming from the store.
|
||||
|
||||
**saber** — to know (facts)
|
||||
|
||||
*yo sé, tú sabes, él sabe* — Present
|
||||
*yo supe, tú supiste, él supo* — Preterite
|
||||
*No sé la respuesta.* — I don't know the answer.
|
||||
|
||||
**poner** — to put / to place
|
||||
|
||||
*yo pongo, tú pones, él pone* — Present
|
||||
*yo puse, tú pusiste, él puso* — Preterite
|
||||
*Pon el libro en la mesa.* — Put the book on the table.
|
||||
|
||||
**salir** — to leave / to go out
|
||||
|
||||
*yo salgo, tú sales, él sale* — Present
|
||||
*yo salí, tú saliste, él salió* — Preterite
|
||||
*Salgo a las ocho.* — I leave at eight.
|
||||
|
||||
**dar** — to give
|
||||
|
||||
*yo doy, tú das, él da* — Present
|
||||
*yo di, tú diste, él dio* — Preterite
|
||||
*Dame un momento.* — Give me a moment.
|
||||
|
||||
**ver** — to see
|
||||
|
||||
*yo veo, tú ves, él ve* — Present
|
||||
*yo vi, tú viste, él vio* — Preterite
|
||||
*¿Ves eso?* — Do you see that?
|
||||
|
||||
**Tip:** These verbs appear so frequently that you will naturally memorize them through practice. Focus on the present and preterite forms first — those are the ones you need every day.
|
||||
"""
|
||||
)
|
||||
|
||||
// MARK: - 23. Types of Irregular Verbs
|
||||
|
||||
private static let typesOfIrregularVerbs = GrammarNote(
|
||||
id: "types-of-irregular-verbs",
|
||||
title: "Types of Irregular Verbs",
|
||||
category: "Irregular Verbs",
|
||||
body: """
|
||||
Spanish irregular verbs follow predictable patterns. Once you learn the pattern, you can conjugate dozens of verbs correctly. There are three main types of irregularity.
|
||||
|
||||
**Spelling Changes**
|
||||
|
||||
These verbs change their spelling to preserve the correct pronunciation. The sound stays the same — only the letters change. This happens because Spanish spelling rules require certain letter combinations before specific vowels.
|
||||
|
||||
*c → qu* (before e): buscar → busqué, tocar → toqué, sacar → saqué
|
||||
*g → gu* (before e): pagar → pagué, llegar → llegué, jugar → jugué
|
||||
*z → c* (before e): empezar → empecé, almorzar → almorcé, cruzar → crucé
|
||||
*g → j* (before a/o): coger → cojo, elegir → elijo, proteger → protejo
|
||||
*gu → gü* (before e): averiguar → averigüé
|
||||
*i → y* (between vowels): leer → leyó/leyeron, oír → oyó/oyeron
|
||||
|
||||
*Busqué mis llaves por toda la casa.* — I looked for my keys all over the house.
|
||||
*Llegué tarde al trabajo.* — I arrived late to work.
|
||||
*Empecé a estudiar español el año pasado.* — I started studying Spanish last year.
|
||||
|
||||
**Stem Changes**
|
||||
|
||||
These verbs change a vowel in the stem when it is stressed. The change only happens in certain persons (usually all except nosotros and vosotros in the present tense). Common patterns:
|
||||
|
||||
*e → ie:* pensar → pienso, querer → quiero, preferir → prefiero, cerrar → cierro, entender → entiendo
|
||||
|
||||
*Quiero ir al cine.* — I want to go to the movies.
|
||||
*Prefiero quedarme en casa.* — I prefer to stay at home.
|
||||
|
||||
*o → ue:* poder → puedo, dormir → duermo, volver → vuelvo, encontrar → encuentro, recordar → recuerdo
|
||||
|
||||
*No puedo encontrar mi teléfono.* — I can't find my phone.
|
||||
*Duermo ocho horas cada noche.* — I sleep eight hours every night.
|
||||
|
||||
*e → i* (only -ir verbs): pedir → pido, servir → sirvo, repetir → repito, seguir → sigo, vestirse → me visto
|
||||
|
||||
*Pido una cerveza, por favor.* — I'll have a beer, please.
|
||||
*Sigo estudiando todos los días.* — I keep studying every day.
|
||||
|
||||
*u → ue:* jugar → juego (the only verb with this pattern!)
|
||||
|
||||
*Juego al fútbol los sábados.* — I play soccer on Saturdays.
|
||||
|
||||
**Unique Irregulars**
|
||||
|
||||
These verbs have forms that don't follow any predictable pattern — you simply have to memorize them. The good news is that the most common ones (ser, ir, haber, etc.) are used so frequently that you learn them quickly through exposure.
|
||||
|
||||
*ser:* soy, eres, es, somos, sois, son (present) — fui, fuiste, fue... (preterite)
|
||||
*ir:* voy, vas, va, vamos, vais, van (present) — fui, fuiste, fue... (preterite, same as ser!)
|
||||
*haber:* he, has, ha, hemos, habéis, han (present auxiliary)
|
||||
*hacer:* hago (yo present), hice/hizo (preterite)
|
||||
*decir:* digo (yo present), dije/dijo (preterite)
|
||||
*tener:* tengo (yo present), tuve (preterite)
|
||||
*venir:* vengo (yo present), vine (preterite)
|
||||
|
||||
**The "go" verbs — yo forms ending in -go:** Many common verbs have an irregular *yo* form in the present tense that ends in *-go*, while all other persons are regular or follow a stem change.
|
||||
|
||||
*tener → tengo, poner → pongo, salir → salgo, venir → vengo, hacer → hago, decir → digo, traer → traigo, oír → oigo, caer → caigo*
|
||||
|
||||
*Tengo que salir ahora.* — I have to leave now.
|
||||
*Pongo la mesa antes de cenar.* — I set the table before dinner.
|
||||
|
||||
**Tip:** Use the Irregularity Drills in the Practice tab to focus on each type separately — spelling changes, stem changes, or unique irregulars.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,16 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case typingEsToEn = "typing_es_to_en"
|
||||
case handwritingEnToEs = "hw_en_to_es"
|
||||
case handwritingEsToEn = "hw_es_to_en"
|
||||
case completeSentenceES = "complete_sentence_es"
|
||||
case checkpoint = "checkpoint"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Quiz types shown in the weekly test picker (excludes checkpoint).
|
||||
static var weeklyQuizTypes: [QuizType] {
|
||||
allCases.filter { $0 != .checkpoint }
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mcEnToEs: "Multiple Choice: EN → ES"
|
||||
@@ -19,6 +26,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .typingEsToEn: "Fill in the Blank: ES → EN"
|
||||
case .handwritingEnToEs: "Handwriting: EN → ES"
|
||||
case .handwritingEsToEn: "Handwriting: ES → EN"
|
||||
case .completeSentenceES: "Complete the Sentence"
|
||||
case .checkpoint: "Checkpoint Exam"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +36,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .mcEnToEs, .mcEsToEn: "list.bullet"
|
||||
case .typingEnToEs, .typingEsToEn: "keyboard"
|
||||
case .handwritingEnToEs, .handwritingEsToEn: "pencil.and.outline"
|
||||
case .completeSentenceES: "text.badge.checkmark"
|
||||
case .checkpoint: "checkmark.seal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +49,8 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
case .typingEsToEn: "See Spanish, type the English word"
|
||||
case .handwritingEnToEs: "See English, handwrite the Spanish word"
|
||||
case .handwritingEsToEn: "See Spanish, handwrite the English word"
|
||||
case .completeSentenceES: "Read a Spanish sentence and pick the missing word"
|
||||
case .checkpoint: "Cumulative review of all weeks so far"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,20 +58,20 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
var promptLanguage: String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "English"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "Spanish"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: "Spanish"
|
||||
}
|
||||
}
|
||||
|
||||
var answerLanguage: String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: "Spanish"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: "English"
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: "Spanish"
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: "English"
|
||||
}
|
||||
}
|
||||
|
||||
var isMultipleChoice: Bool {
|
||||
switch self {
|
||||
case .mcEnToEs, .mcEsToEn: true
|
||||
case .mcEnToEs, .mcEsToEn, .completeSentenceES, .checkpoint: true
|
||||
case .typingEnToEs, .typingEsToEn, .handwritingEnToEs, .handwritingEsToEn: false
|
||||
}
|
||||
}
|
||||
@@ -70,17 +83,21 @@ enum QuizType: String, CaseIterable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
var isCompleteSentence: Bool {
|
||||
self == .completeSentenceES
|
||||
}
|
||||
|
||||
func prompt(for card: VocabCard) -> String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.back
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.front
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .completeSentenceES, .checkpoint: card.front
|
||||
}
|
||||
}
|
||||
|
||||
func answer(for card: VocabCard) -> String {
|
||||
switch self {
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs: card.front
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn: card.back
|
||||
case .mcEnToEs, .typingEnToEs, .handwritingEnToEs, .completeSentenceES: card.front
|
||||
case .mcEsToEn, .typingEsToEn, .handwritingEsToEn, .checkpoint: card.back
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ enum TenseID: String, CaseIterable, Codable, Sendable, Hashable {
|
||||
.ind_futuro,
|
||||
]
|
||||
|
||||
/// The 6 most essential tenses every learner should master.
|
||||
static let coreTenses: Set<TenseID> = [
|
||||
.ind_presente,
|
||||
.ind_preterito,
|
||||
.ind_imperfecto,
|
||||
.ind_futuro,
|
||||
.subj_presente,
|
||||
.imp_afirmativo,
|
||||
]
|
||||
|
||||
static let coreTenseIDs = coreTenses.map(\.rawValue)
|
||||
|
||||
static let defaultPracticeIDs = defaultPractice.map(\.rawValue)
|
||||
}
|
||||
|
||||
@@ -39,6 +51,10 @@ struct TenseInfo: Identifiable, Hashable, Sendable {
|
||||
let mood: String
|
||||
let order: Int
|
||||
|
||||
var isCore: Bool {
|
||||
TenseID(rawValue: id).map { TenseID.coreTenses.contains($0) } ?? false
|
||||
}
|
||||
|
||||
static let all: [TenseInfo] = [
|
||||
TenseInfo(id: TenseID.ind_presente.rawValue, spanish: "Indicativo Presente", english: "Present", mood: "Indicative", order: 0),
|
||||
TenseInfo(id: TenseID.ind_preterito.rawValue, spanish: "Indicativo Pretérito", english: "Preterite", mood: "Indicative", order: 1),
|
||||
|
||||
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal file
78
Conjuga/Conjuga/Services/ConversationService.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConversationService {
|
||||
var isResponding = false
|
||||
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
static let scenarios = [
|
||||
"Ordering at a restaurant",
|
||||
"Asking for directions",
|
||||
"Shopping at a market",
|
||||
"Checking into a hotel",
|
||||
"Making plans with a friend",
|
||||
"At the doctor's office",
|
||||
"Job interview",
|
||||
"Renting an apartment",
|
||||
"At the airport",
|
||||
"Meeting someone new",
|
||||
]
|
||||
|
||||
func startConversation(scenario: String, level: String) -> String {
|
||||
session = LanguageModelSession(instructions: """
|
||||
You are a friendly Spanish conversation partner. The scenario is: \(scenario).
|
||||
The student's level is: \(level).
|
||||
|
||||
Rules:
|
||||
- Respond ONLY in Spanish appropriate for the student's level.
|
||||
- Keep responses to 1-3 sentences.
|
||||
- If the student makes a grammar mistake, gently correct it in parentheses \
|
||||
at the end of your response, like: (Pequeña corrección: "fuiste" en vez de "fue")
|
||||
- Stay in character for the scenario.
|
||||
- Be encouraging and natural.
|
||||
""")
|
||||
|
||||
// Return the opening message based on scenario
|
||||
switch scenario {
|
||||
case "Ordering at a restaurant":
|
||||
return "¡Bienvenido! Soy su mesero. ¿Ya sabe qué le gustaría ordenar?"
|
||||
case "Asking for directions":
|
||||
return "¡Hola! ¿En qué le puedo ayudar? ¿Está buscando algún lugar?"
|
||||
case "Shopping at a market":
|
||||
return "¡Buenos días! Tenemos frutas muy frescas hoy. ¿Qué le gustaría comprar?"
|
||||
case "Checking into a hotel":
|
||||
return "Buenas tardes, bienvenido al Hotel Sol. ¿Tiene una reservación?"
|
||||
case "Making plans with a friend":
|
||||
return "¡Oye! ¿Qué quieres hacer este fin de semana? Estoy libre el sábado."
|
||||
case "At the doctor's office":
|
||||
return "Buenos días. Soy el doctor García. ¿Cómo se siente hoy? ¿Qué le pasa?"
|
||||
case "Job interview":
|
||||
return "Buenos días, gracias por venir. Cuénteme un poco sobre usted."
|
||||
case "Renting an apartment":
|
||||
return "¡Hola! Gracias por su interés en el apartamento. ¿Qué preguntas tiene?"
|
||||
case "At the airport":
|
||||
return "Buenas tardes, pasajero. ¿Me puede mostrar su pasaporte y su boleto?"
|
||||
case "Meeting someone new":
|
||||
return "¡Hola! Me llamo Carlos. ¿Cómo te llamas? ¿De dónde eres?"
|
||||
default:
|
||||
return "¡Hola! ¿Cómo estás? Vamos a practicar español juntos."
|
||||
}
|
||||
}
|
||||
|
||||
func respond(to userMessage: String) async throws -> String {
|
||||
guard let session else { return "Error: no session" }
|
||||
isResponding = true
|
||||
defer { isResponding = false }
|
||||
|
||||
let response = try await session.respond(to: userMessage)
|
||||
return response.content
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,21 @@ import SharedModels
|
||||
import Foundation
|
||||
|
||||
actor DataLoader {
|
||||
static let courseDataVersion = 6
|
||||
static let courseDataKey = "courseDataVersion"
|
||||
|
||||
/// Quick check: does the DB need seeding or course data refresh?
|
||||
static func needsSeeding(container: ModelContainer) async -> Bool {
|
||||
let context = ModelContext(container)
|
||||
let verbCount = (try? context.fetchCount(FetchDescriptor<Verb>())) ?? 0
|
||||
if verbCount == 0 { return true }
|
||||
|
||||
let storedVersion = UserDefaults.standard.integer(forKey: courseDataKey)
|
||||
if storedVersion < courseDataVersion { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func seedIfNeeded(container: ModelContainer) async {
|
||||
let context = ModelContext(container)
|
||||
|
||||
@@ -123,11 +138,9 @@ actor DataLoader {
|
||||
/// Re-seed course data if the version has changed (e.g. examples were added).
|
||||
/// Call this on every launch — it checks a version key and only re-seeds when needed.
|
||||
static func refreshCourseDataIfNeeded(container: ModelContainer) async {
|
||||
let currentVersion = 3 // Bump this whenever course_data.json changes
|
||||
let key = "courseDataVersion"
|
||||
let shared = UserDefaults.standard
|
||||
|
||||
if shared.integer(forKey: key) >= currentVersion { return }
|
||||
if shared.integer(forKey: courseDataKey) >= courseDataVersion { return }
|
||||
|
||||
print("Course data version outdated — re-seeding...")
|
||||
let context = ModelContext(container)
|
||||
@@ -140,8 +153,8 @@ actor DataLoader {
|
||||
// Re-seed
|
||||
seedCourseData(context: context)
|
||||
|
||||
shared.set(currentVersion, forKey: key)
|
||||
print("Course data re-seeded to version \(currentVersion)")
|
||||
shared.set(courseDataVersion, forKey: courseDataKey)
|
||||
print("Course data re-seeded to version \(courseDataVersion)")
|
||||
}
|
||||
|
||||
static func migrateCourseProgressIfNeeded(
|
||||
@@ -255,14 +268,18 @@ actor DataLoader {
|
||||
// Parse example sentences
|
||||
var exES: [String] = []
|
||||
var exEN: [String] = []
|
||||
var exBlanks: [String] = []
|
||||
if let examples = cardDict["examples"] as? [[String: String]] {
|
||||
for ex in examples {
|
||||
if let es = ex["es"] { exES.append(es) }
|
||||
if let en = ex["en"] { exEN.append(en) }
|
||||
if let es = ex["es"] {
|
||||
exES.append(es)
|
||||
exEN.append(ex["en"] ?? "")
|
||||
exBlanks.append(ex["blank"] ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN)
|
||||
let card = VocabCard(front: front, back: back, deckId: deckId, examplesES: exES, examplesEN: exEN, examplesBlanks: exBlanks)
|
||||
card.deck = deck
|
||||
context.insert(card)
|
||||
cardCount += 1
|
||||
|
||||
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal file
268
Conjuga/Conjuga/Services/DictionaryService.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DictionaryService {
|
||||
|
||||
struct Entry {
|
||||
let word: String
|
||||
let baseForm: String
|
||||
let english: String
|
||||
let partOfSpeech: String
|
||||
let tenseId: String?
|
||||
let person: String?
|
||||
}
|
||||
|
||||
private var verbIndex: [String: Entry] = [:]
|
||||
private var nonVerbIndex: [String: Entry] = [:]
|
||||
private var isBuilt = false
|
||||
|
||||
/// Build the reverse index from existing verb data + bundled non-verb dictionary.
|
||||
/// Loads from disk cache if available, otherwise builds from DB and caches.
|
||||
func buildIfNeeded(context: ModelContext) {
|
||||
guard !isBuilt else { return }
|
||||
|
||||
loadNonVerbDictionary()
|
||||
|
||||
if loadCachedIndex() {
|
||||
isBuilt = true
|
||||
return
|
||||
}
|
||||
|
||||
// No cache — build from DB
|
||||
let verbDescriptor = FetchDescriptor<Verb>()
|
||||
let verbs = (try? context.fetch(verbDescriptor)) ?? []
|
||||
let verbMap = Dictionary(uniqueKeysWithValues: verbs.map { ($0.id, $0) })
|
||||
|
||||
let formDescriptor = FetchDescriptor<VerbForm>()
|
||||
let forms = (try? context.fetch(formDescriptor)) ?? []
|
||||
|
||||
let persons = TenseInfo.persons
|
||||
for form in forms {
|
||||
guard let verb = verbMap[form.verbId] else { continue }
|
||||
let key = form.form.lowercased()
|
||||
if verbIndex[key] != nil { continue }
|
||||
|
||||
let person = form.personIndex < persons.count ? persons[form.personIndex] : nil
|
||||
verbIndex[key] = Entry(
|
||||
word: form.form,
|
||||
baseForm: verb.infinitive,
|
||||
english: verb.english,
|
||||
partOfSpeech: "verb",
|
||||
tenseId: form.tenseId,
|
||||
person: person
|
||||
)
|
||||
}
|
||||
|
||||
for verb in verbs {
|
||||
let key = verb.infinitive.lowercased()
|
||||
if verbIndex[key] == nil {
|
||||
verbIndex[key] = Entry(
|
||||
word: verb.infinitive,
|
||||
baseForm: verb.infinitive,
|
||||
english: verb.english,
|
||||
partOfSpeech: "verb",
|
||||
tenseId: nil,
|
||||
person: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
isBuilt = true
|
||||
saveCachedIndex()
|
||||
print("[Dictionary] Built index from DB: \(verbIndex.count) verb forms")
|
||||
}
|
||||
|
||||
// MARK: - Disk Cache
|
||||
|
||||
private static var cacheURL: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("dictionary_index.json")
|
||||
}
|
||||
|
||||
private struct CachedEntry: Codable {
|
||||
let word: String
|
||||
let baseForm: String
|
||||
let english: String
|
||||
let partOfSpeech: String
|
||||
let tenseId: String?
|
||||
let person: String?
|
||||
}
|
||||
|
||||
private func saveCachedIndex() {
|
||||
let entries = verbIndex.map { (key: $0.key, value: CachedEntry(
|
||||
word: $0.value.word, baseForm: $0.value.baseForm,
|
||||
english: $0.value.english, partOfSpeech: $0.value.partOfSpeech,
|
||||
tenseId: $0.value.tenseId, person: $0.value.person
|
||||
))}
|
||||
let dict = Dictionary(uniqueKeysWithValues: entries)
|
||||
if let data = try? JSONEncoder().encode(dict) {
|
||||
try? data.write(to: Self.cacheURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCachedIndex() -> Bool {
|
||||
guard let data = try? Data(contentsOf: Self.cacheURL),
|
||||
let dict = try? JSONDecoder().decode([String: CachedEntry].self, from: data) else {
|
||||
return false
|
||||
}
|
||||
verbIndex = dict.mapValues { Entry(
|
||||
word: $0.word, baseForm: $0.baseForm,
|
||||
english: $0.english, partOfSpeech: $0.partOfSpeech,
|
||||
tenseId: $0.tenseId, person: $0.person
|
||||
)}
|
||||
print("[Dictionary] Loaded cached index: \(verbIndex.count) verb forms")
|
||||
return true
|
||||
}
|
||||
|
||||
func lookup(_ word: String) -> Entry? {
|
||||
let cleaned = word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
return verbIndex[cleaned] ?? nonVerbIndex[cleaned]
|
||||
}
|
||||
|
||||
private func loadNonVerbDictionary() {
|
||||
// Common non-verb Spanish words — articles, prepositions, pronouns, adjectives, nouns, adverbs, conjunctions
|
||||
let words: [(String, String, String)] = [
|
||||
// Articles
|
||||
("el", "the (masc.)", "article"), ("la", "the (fem.)", "article"),
|
||||
("los", "the (masc. pl.)", "article"), ("las", "the (fem. pl.)", "article"),
|
||||
("un", "a, an (masc.)", "article"), ("una", "a, an (fem.)", "article"),
|
||||
("unos", "some (masc.)", "article"), ("unas", "some (fem.)", "article"),
|
||||
|
||||
// Pronouns
|
||||
("yo", "I", "pronoun"), ("tú", "you (informal)", "pronoun"),
|
||||
("él", "he", "pronoun"), ("ella", "she", "pronoun"),
|
||||
("nosotros", "we (masc.)", "pronoun"), ("nosotras", "we (fem.)", "pronoun"),
|
||||
("ellos", "they (masc.)", "pronoun"), ("ellas", "they (fem.)", "pronoun"),
|
||||
("usted", "you (formal)", "pronoun"), ("ustedes", "you all (formal)", "pronoun"),
|
||||
("me", "me", "pronoun"), ("te", "you (obj.)", "pronoun"),
|
||||
("nos", "us", "pronoun"), ("le", "him/her/you (obj.)", "pronoun"),
|
||||
("les", "them/you all (obj.)", "pronoun"), ("lo", "it/him (obj.)", "pronoun"),
|
||||
("se", "self/each other", "pronoun"), ("mi", "my", "pronoun"),
|
||||
("tu", "your (informal)", "pronoun"), ("su", "his/her/your/their", "pronoun"),
|
||||
("nuestro", "our (masc.)", "pronoun"), ("nuestra", "our (fem.)", "pronoun"),
|
||||
("esto", "this", "pronoun"), ("eso", "that", "pronoun"),
|
||||
("algo", "something", "pronoun"), ("nada", "nothing", "pronoun"),
|
||||
("alguien", "someone", "pronoun"), ("nadie", "nobody", "pronoun"),
|
||||
("todo", "everything, all", "pronoun"), ("cada", "each", "pronoun"),
|
||||
("otro", "other, another", "pronoun"), ("otra", "other, another (fem.)", "pronoun"),
|
||||
("mismo", "same, self", "pronoun"), ("misma", "same, self (fem.)", "pronoun"),
|
||||
|
||||
// Prepositions
|
||||
("a", "to, at", "preposition"), ("de", "of, from", "preposition"),
|
||||
("en", "in, on, at", "preposition"), ("con", "with", "preposition"),
|
||||
("por", "for, by, through", "preposition"), ("para", "for, in order to", "preposition"),
|
||||
("sin", "without", "preposition"), ("sobre", "on, about", "preposition"),
|
||||
("entre", "between, among", "preposition"), ("hasta", "until, up to", "preposition"),
|
||||
("desde", "from, since", "preposition"), ("hacia", "toward", "preposition"),
|
||||
("durante", "during", "preposition"), ("según", "according to", "preposition"),
|
||||
("tras", "after, behind", "preposition"), ("contra", "against", "preposition"),
|
||||
|
||||
// Conjunctions
|
||||
("y", "and", "conjunction"), ("e", "and (before i/hi)", "conjunction"),
|
||||
("o", "or", "conjunction"), ("u", "or (before o/ho)", "conjunction"),
|
||||
("pero", "but", "conjunction"), ("sino", "but rather", "conjunction"),
|
||||
("porque", "because", "conjunction"), ("que", "that, which", "conjunction"),
|
||||
("si", "if", "conjunction"), ("cuando", "when", "conjunction"),
|
||||
("como", "as, like, how", "conjunction"), ("donde", "where", "conjunction"),
|
||||
("aunque", "although", "conjunction"), ("mientras", "while", "conjunction"),
|
||||
("ni", "neither, nor", "conjunction"), ("pues", "well, since", "conjunction"),
|
||||
|
||||
// Common adverbs
|
||||
("no", "no, not", "adverb"), ("sí", "yes", "adverb"),
|
||||
("muy", "very", "adverb"), ("más", "more, most", "adverb"),
|
||||
("menos", "less, fewer", "adverb"), ("bien", "well", "adverb"),
|
||||
("mal", "badly", "adverb"), ("ya", "already, now", "adverb"),
|
||||
("también", "also, too", "adverb"), ("tampoco", "neither, either", "adverb"),
|
||||
("aquí", "here", "adverb"), ("ahí", "there", "adverb"),
|
||||
("allí", "over there", "adverb"), ("siempre", "always", "adverb"),
|
||||
("nunca", "never", "adverb"), ("hoy", "today", "adverb"),
|
||||
("ayer", "yesterday", "adverb"), ("mañana", "tomorrow", "adverb"),
|
||||
("ahora", "now", "adverb"), ("después", "after, later", "adverb"),
|
||||
("antes", "before", "adverb"), ("luego", "then, later", "adverb"),
|
||||
("todavía", "still, yet", "adverb"), ("casi", "almost", "adverb"),
|
||||
("solo", "only, alone", "adverb"), ("tan", "so, as", "adverb"),
|
||||
("mucho", "a lot, much", "adverb"), ("poco", "little, few", "adverb"),
|
||||
("bastante", "quite, enough", "adverb"), ("demasiado", "too much", "adverb"),
|
||||
|
||||
// Question words
|
||||
("qué", "what", "interrogative"), ("quién", "who", "interrogative"),
|
||||
("cómo", "how", "interrogative"), ("dónde", "where", "interrogative"),
|
||||
("cuándo", "when", "interrogative"), ("cuánto", "how much", "interrogative"),
|
||||
("cuál", "which", "interrogative"), ("por qué", "why", "interrogative"),
|
||||
|
||||
// Common nouns
|
||||
("casa", "house", "noun"), ("hombre", "man", "noun"),
|
||||
("mujer", "woman", "noun"), ("niño", "boy, child", "noun"),
|
||||
("niña", "girl", "noun"), ("familia", "family", "noun"),
|
||||
("amigo", "friend (masc.)", "noun"), ("amiga", "friend (fem.)", "noun"),
|
||||
("tiempo", "time, weather", "noun"), ("día", "day", "noun"),
|
||||
("noche", "night", "noun"), ("año", "year", "noun"),
|
||||
("vida", "life", "noun"), ("mundo", "world", "noun"),
|
||||
("país", "country", "noun"), ("ciudad", "city", "noun"),
|
||||
("agua", "water", "noun"), ("comida", "food", "noun"),
|
||||
("trabajo", "work, job", "noun"), ("escuela", "school", "noun"),
|
||||
("libro", "book", "noun"), ("calle", "street", "noun"),
|
||||
("dinero", "money", "noun"), ("mano", "hand", "noun"),
|
||||
("padre", "father", "noun"), ("madre", "mother", "noun"),
|
||||
("hijo", "son", "noun"), ("hija", "daughter", "noun"),
|
||||
("hermano", "brother", "noun"), ("hermana", "sister", "noun"),
|
||||
("persona", "person", "noun"), ("gente", "people", "noun"),
|
||||
("cosa", "thing", "noun"), ("lugar", "place", "noun"),
|
||||
("parte", "part", "noun"), ("nombre", "name", "noun"),
|
||||
("momento", "moment", "noun"), ("problema", "problem", "noun"),
|
||||
("mesa", "table", "noun"), ("puerta", "door", "noun"),
|
||||
("coche", "car", "noun"), ("perro", "dog", "noun"),
|
||||
("gato", "cat", "noun"), ("sol", "sun", "noun"),
|
||||
("mar", "sea", "noun"), ("playa", "beach", "noun"),
|
||||
("montaña", "mountain", "noun"), ("tienda", "store", "noun"),
|
||||
("restaurante", "restaurant", "noun"), ("hotel", "hotel", "noun"),
|
||||
("cuerpo", "body", "noun"), ("cabeza", "head", "noun"),
|
||||
("corazón", "heart", "noun"), ("ojo", "eye", "noun"),
|
||||
|
||||
// Common adjectives
|
||||
("bueno", "good", "adjective"), ("buena", "good (fem.)", "adjective"),
|
||||
("malo", "bad", "adjective"), ("mala", "bad (fem.)", "adjective"),
|
||||
("grande", "big, great", "adjective"), ("pequeño", "small", "adjective"),
|
||||
("nuevo", "new", "adjective"), ("viejo", "old", "adjective"),
|
||||
("joven", "young", "adjective"), ("largo", "long", "adjective"),
|
||||
("corto", "short", "adjective"), ("alto", "tall, high", "adjective"),
|
||||
("bajo", "short, low", "adjective"), ("bonito", "pretty", "adjective"),
|
||||
("hermoso", "beautiful", "adjective"), ("feo", "ugly", "adjective"),
|
||||
("feliz", "happy", "adjective"), ("triste", "sad", "adjective"),
|
||||
("fácil", "easy", "adjective"), ("difícil", "difficult", "adjective"),
|
||||
("importante", "important", "adjective"), ("posible", "possible", "adjective"),
|
||||
("mejor", "better, best", "adjective"), ("peor", "worse, worst", "adjective"),
|
||||
("primero", "first", "adjective"), ("último", "last", "adjective"),
|
||||
("mismo", "same", "adjective"), ("otro", "other", "adjective"),
|
||||
("cada", "each, every", "adjective"), ("todo", "all, every", "adjective"),
|
||||
("mucho", "much, many", "adjective"), ("poco", "little, few", "adjective"),
|
||||
|
||||
// Numbers
|
||||
("uno", "one", "number"), ("dos", "two", "number"),
|
||||
("tres", "three", "number"), ("cuatro", "four", "number"),
|
||||
("cinco", "five", "number"), ("seis", "six", "number"),
|
||||
("siete", "seven", "number"), ("ocho", "eight", "number"),
|
||||
("nueve", "nine", "number"), ("diez", "ten", "number"),
|
||||
|
||||
// Misc
|
||||
("del", "of the (de + el)", "contraction"), ("al", "to the (a + el)", "contraction"),
|
||||
]
|
||||
|
||||
for (word, english, pos) in words {
|
||||
nonVerbIndex[word.lowercased()] = Entry(
|
||||
word: word,
|
||||
baseForm: word,
|
||||
english: english,
|
||||
partOfSpeech: pos,
|
||||
tenseId: nil,
|
||||
person: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
121
Conjuga/Conjuga/Services/LyricsSearchService.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
|
||||
struct LyricsSearchResult: Sendable {
|
||||
let title: String
|
||||
let artist: String
|
||||
let lyricsES: String
|
||||
let albumArtURL: String?
|
||||
let appleMusicURL: String?
|
||||
}
|
||||
|
||||
actor LyricsSearchService {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
|
||||
async let lrcResults = searchLRCLIB(artist: artist, title: title)
|
||||
async let itunesResults = searchITunes(artist: artist, title: title)
|
||||
|
||||
let lyrics = try await lrcResults
|
||||
let metadata = try? await itunesResults
|
||||
|
||||
return lyrics.map { lrc in
|
||||
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
|
||||
return LyricsSearchResult(
|
||||
title: lrc.trackName,
|
||||
artist: lrc.artistName,
|
||||
lyricsES: lrc.plainLyrics,
|
||||
albumArtURL: match?.artworkURL600,
|
||||
appleMusicURL: match?.trackViewURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LRCLIB
|
||||
|
||||
private struct LRCLIBResult: Decodable, Sendable {
|
||||
let trackName: String
|
||||
let artistName: String
|
||||
let plainLyrics: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case trackName, artistName, plainLyrics
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
trackName = try c.decode(String.self, forKey: .trackName)
|
||||
artistName = try c.decode(String.self, forKey: .artistName)
|
||||
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
|
||||
var components = URLComponents(string: "https://lrclib.net/api/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "track_name", value: title),
|
||||
URLQueryItem(name: "artist_name", value: artist),
|
||||
]
|
||||
guard let url = components.url else { return [] }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
|
||||
|
||||
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
|
||||
return results.filter { !$0.plainLyrics.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - iTunes Search
|
||||
|
||||
private struct ITunesResponse: Decodable {
|
||||
let results: [ITunesTrack]
|
||||
}
|
||||
|
||||
private struct ITunesTrack: Decodable {
|
||||
let trackName: String?
|
||||
let artistName: String?
|
||||
let artworkUrl100: String?
|
||||
let trackViewUrl: String?
|
||||
|
||||
var artworkURL600: String? {
|
||||
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
|
||||
}
|
||||
|
||||
var trackViewURL: String? { trackViewUrl }
|
||||
}
|
||||
|
||||
private struct ITunesMetadata: Sendable {
|
||||
let tracks: [ITunesTrack]
|
||||
|
||||
func bestMatch(artist: String, title: String) -> ITunesTrack? {
|
||||
let normalizedArtist = artist.lowercased()
|
||||
let normalizedTitle = title.lowercased()
|
||||
|
||||
// Prefer exact title+artist match, then just title
|
||||
return tracks.first {
|
||||
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
|
||||
($0.artistName ?? "").lowercased().contains(normalizedArtist)
|
||||
} ?? tracks.first {
|
||||
($0.trackName ?? "").lowercased().contains(normalizedTitle)
|
||||
} ?? tracks.first
|
||||
}
|
||||
}
|
||||
|
||||
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
|
||||
let query = "\(artist) \(title)"
|
||||
var components = URLComponents(string: "https://itunes.apple.com/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "term", value: query),
|
||||
URLQueryItem(name: "media", value: "music"),
|
||||
URLQueryItem(name: "limit", value: "5"),
|
||||
]
|
||||
guard let url = components.url else { return ITunesMetadata(tracks: []) }
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
|
||||
return ITunesMetadata(tracks: response.results)
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ struct PracticeSessionService {
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||
}
|
||||
|
||||
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
|
||||
func nextCard(for focusMode: FocusMode, excludingVerbId lastVerbId: Int? = nil) -> PracticeCardLoad? {
|
||||
switch focusMode {
|
||||
case .weakVerbs:
|
||||
if let form = pickWeakForm() {
|
||||
@@ -58,11 +58,15 @@ struct PracticeSessionService {
|
||||
if let form = pickIrregularForm(filter: filter) {
|
||||
return loadCard(from: form)
|
||||
}
|
||||
case .commonTenses:
|
||||
if let form = pickCommonTenseForm() {
|
||||
return loadCard(from: form)
|
||||
}
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let dueCard = fetchDueCard() {
|
||||
if let dueCard = fetchDueCard(excluding: lastVerbId) {
|
||||
return loadCard(from: dueCard)
|
||||
}
|
||||
|
||||
@@ -146,7 +150,7 @@ struct PracticeSessionService {
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchDueCard() -> ReviewCard? {
|
||||
private func fetchDueCard(excluding lastVerbId: Int?) -> ReviewCard? {
|
||||
let settings = settings()
|
||||
let allowedVerbIds = referenceStore.allowedVerbIDs(selectedLevel: settings.selectedLevel)
|
||||
let now = Date()
|
||||
@@ -157,11 +161,20 @@ struct PracticeSessionService {
|
||||
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
||||
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
|
||||
return cards.first { card in
|
||||
let eligible = cards.filter { card in
|
||||
allowedVerbIds.contains(card.verbId) &&
|
||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
||||
(settings.showVosotros || card.personIndex != 4)
|
||||
}
|
||||
|
||||
// Prefer a card from a different verb than the last one shown.
|
||||
// Fall back to the same verb only if it's the sole due card.
|
||||
if let lastVerbId {
|
||||
if let different = eligible.first(where: { $0.verbId != lastVerbId }) {
|
||||
return different
|
||||
}
|
||||
}
|
||||
return eligible.first
|
||||
}
|
||||
|
||||
private func pickWeakForm() -> VerbForm? {
|
||||
@@ -222,6 +235,20 @@ struct PracticeSessionService {
|
||||
)
|
||||
}
|
||||
|
||||
private func pickCommonTenseForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let coreTenseIDs = TenseID.coreTenseIDs
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
guard let verb = verbs.randomElement() else { return nil }
|
||||
|
||||
let forms = referenceStore.fetchVerbForms(verbId: verb.id).filter { form in
|
||||
coreTenseIDs.contains(form.tenseId) &&
|
||||
(settings.showVosotros || form.personIndex != 4)
|
||||
}
|
||||
|
||||
return forms.randomElement()
|
||||
}
|
||||
|
||||
private func pickRandomForm() -> VerbForm? {
|
||||
let settings = settings()
|
||||
let verbs = referenceStore.fetchVerbs(selectedLevel: settings.selectedLevel)
|
||||
|
||||
155
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
155
Conjuga/Conjuga/Services/PronunciationService.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PronunciationService {
|
||||
var isRecording = false
|
||||
var transcript = ""
|
||||
var isAuthorized = false
|
||||
|
||||
private var recognizer: SFSpeechRecognizer?
|
||||
private var audioEngine: AVAudioEngine?
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private var recognizerResolved = false
|
||||
|
||||
func requestAuthorization() {
|
||||
#if targetEnvironment(simulator)
|
||||
print("[PronunciationService] skipping speech auth on simulator")
|
||||
return
|
||||
#else
|
||||
// Check current status first to avoid unnecessary prompt
|
||||
let currentStatus = SFSpeechRecognizer.authorizationStatus()
|
||||
if currentStatus == .authorized {
|
||||
isAuthorized = true
|
||||
return
|
||||
}
|
||||
if currentStatus == .denied || currentStatus == .restricted {
|
||||
isAuthorized = false
|
||||
return
|
||||
}
|
||||
|
||||
// Only request if not determined yet — do it on a background queue
|
||||
// to avoid blocking main thread, then update state on main
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.isAuthorized = (status == .authorized)
|
||||
print("[PronunciationService] authorization status: \(status.rawValue)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func resolveRecognizerIfNeeded() {
|
||||
guard !recognizerResolved else { return }
|
||||
recognizerResolved = true
|
||||
recognizer = SFSpeechRecognizer(locale: Locale(identifier: "es-ES"))
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
guard isAuthorized else {
|
||||
print("[PronunciationService] not authorized")
|
||||
return
|
||||
}
|
||||
resolveRecognizerIfNeeded()
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
print("[PronunciationService] recognizer unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
stopRecording()
|
||||
|
||||
do {
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
|
||||
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
audioEngine = AVAudioEngine()
|
||||
request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
guard let audioEngine, let request else { return }
|
||||
request.shouldReportPartialResults = true
|
||||
|
||||
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 }
|
||||
request.append(buffer)
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
|
||||
transcript = ""
|
||||
isRecording = true
|
||||
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
DispatchQueue.main.async {
|
||||
if let result {
|
||||
self?.transcript = result.bestTranscription.formattedString
|
||||
}
|
||||
if error != nil || (result?.isFinal == true) {
|
||||
self?.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[PronunciationService] startRecording failed: \(error)")
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
audioEngine?.stop()
|
||||
audioEngine?.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
task?.cancel()
|
||||
task = nil
|
||||
request = nil
|
||||
audioEngine = nil
|
||||
isRecording = false
|
||||
}
|
||||
|
||||
/// Compare spoken transcript against expected text, returns matched word ratio (0.0-1.0).
|
||||
static func scoreMatch(expected: String, spoken: String) -> (score: Double, matches: [WordMatch]) {
|
||||
let expectedWords = expected.lowercased()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let spokenWords = spoken.lowercased()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.map { $0.trimmingCharacters(in: .punctuationCharacters) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
let spokenSet = Set(spokenWords)
|
||||
var matches: [WordMatch] = []
|
||||
|
||||
for word in expectedWords {
|
||||
matches.append(WordMatch(word: word, matched: spokenSet.contains(word)))
|
||||
}
|
||||
|
||||
let matchCount = matches.filter(\.matched).count
|
||||
let score = expectedWords.isEmpty ? 0 : Double(matchCount) / Double(expectedWords.count)
|
||||
return (score, matches)
|
||||
}
|
||||
|
||||
struct WordMatch: Identifiable {
|
||||
let word: String
|
||||
let matched: Bool
|
||||
var id: String { word }
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,20 @@ import AVFoundation
|
||||
@MainActor
|
||||
final class SpeechService {
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let spanishVoice: AVSpeechSynthesisVoice?
|
||||
private var spanishVoice: AVSpeechSynthesisVoice?
|
||||
private var voiceResolved = false
|
||||
private var audioSessionConfigured = false
|
||||
|
||||
init() {
|
||||
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
|
||||
// AVSpeechSynthesisVoice can trigger a malloc double-free on
|
||||
// iOS 26 simulators when deserializing voice metadata. Defer
|
||||
// voice resolution to first use so the crash doesn't happen
|
||||
// during app launch.
|
||||
spanishVoice = nil
|
||||
}
|
||||
|
||||
func speak(_ text: String) {
|
||||
resolveVoiceIfNeeded()
|
||||
configureAudioSession()
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
let utterance = AVSpeechUtterance(string: text)
|
||||
@@ -27,6 +33,12 @@ final class SpeechService {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
|
||||
private func resolveVoiceIfNeeded() {
|
||||
guard !voiceResolved else { return }
|
||||
voiceResolved = true
|
||||
spanishVoice = AVSpeechSynthesisVoice(language: "es-ES")
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
guard !audioSessionConfigured else { return }
|
||||
do {
|
||||
|
||||
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
97
Conjuga/Conjuga/Services/StoryGenerator.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
struct StoryGenerator {
|
||||
|
||||
// MARK: - Generable Types
|
||||
|
||||
@Generable
|
||||
struct GeneratedStory {
|
||||
@Guide(description: "A short creative title for the story in Spanish, 3-6 words")
|
||||
var title: String
|
||||
|
||||
@Guide(description: "A one-paragraph story in Spanish, 5-8 sentences long, using vocabulary and grammar appropriate for the student level")
|
||||
var bodyES: String
|
||||
|
||||
@Guide(description: "An accurate English translation of bodyES")
|
||||
var bodyEN: String
|
||||
|
||||
@Guide(description: "Every word from the story annotated with its base form, English meaning, and part of speech. Include articles, prepositions, and all other words.")
|
||||
var words: [GeneratedAnnotation]
|
||||
|
||||
@Guide(description: "3 reading comprehension questions about the story, each with 4 answer options in Spanish", .count(3))
|
||||
var questions: [GeneratedQuestion]
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedAnnotation {
|
||||
@Guide(description: "The exact word as it appears in the story")
|
||||
var word: String
|
||||
@Guide(description: "The dictionary base form (infinitive for verbs, singular for nouns)")
|
||||
var baseForm: String
|
||||
@Guide(description: "English translation of the word")
|
||||
var english: String
|
||||
@Guide(description: "Part of speech: verb, noun, adjective, adverb, preposition, or other")
|
||||
var partOfSpeech: String
|
||||
}
|
||||
|
||||
@Generable
|
||||
struct GeneratedQuestion {
|
||||
@Guide(description: "A comprehension question about the story in Spanish")
|
||||
var question: String
|
||||
@Guide(description: "4 answer options in Spanish", .count(4))
|
||||
var options: [String]
|
||||
@Guide(description: "Index of the correct answer (0-3)", .range(0...3))
|
||||
var correctIndex: Int
|
||||
}
|
||||
|
||||
// MARK: - Generation
|
||||
|
||||
static func generate(level: String, tenses: [String]) async throws -> Story {
|
||||
let tenseNames = tenses.isEmpty
|
||||
? "present, preterite, imperfect, and future"
|
||||
: tenses.joined(separator: ", ")
|
||||
|
||||
let session = LanguageModelSession(instructions: """
|
||||
You are a Spanish language teacher creating a short reading exercise.
|
||||
The student's level is: \(level).
|
||||
Focus on these verb tenses: \(tenseNames).
|
||||
Write naturally but keep vocabulary appropriate for the level.
|
||||
Use common, everyday scenarios (shopping, travel, family, school, work, food).
|
||||
The story should be exactly one paragraph of 5-8 sentences.
|
||||
""")
|
||||
|
||||
let response = try await session.respond(
|
||||
to: "Create a short Spanish story for reading practice.",
|
||||
generating: GeneratedStory.self
|
||||
)
|
||||
|
||||
let story = response.content
|
||||
|
||||
let annotations = story.words.map {
|
||||
WordAnnotation(word: $0.word, baseForm: $0.baseForm, english: $0.english, partOfSpeech: $0.partOfSpeech)
|
||||
}
|
||||
let questions = story.questions.map {
|
||||
QuizQuestion(question: $0.question, options: $0.options, correctIndex: $0.correctIndex)
|
||||
}
|
||||
|
||||
let annotationsJSON = (try? String(data: JSONEncoder().encode(annotations), encoding: .utf8)) ?? "[]"
|
||||
let questionsJSON = (try? String(data: JSONEncoder().encode(questions), encoding: .utf8)) ?? "[]"
|
||||
|
||||
return Story(
|
||||
title: story.title,
|
||||
bodyES: story.bodyES,
|
||||
bodyEN: story.bodyEN,
|
||||
level: level,
|
||||
wordAnnotations: annotationsJSON,
|
||||
quizQuestions: questionsJSON
|
||||
)
|
||||
}
|
||||
|
||||
static var isAvailable: Bool {
|
||||
SystemLanguageModel.default.availability == .available
|
||||
}
|
||||
}
|
||||
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
46
Conjuga/Conjuga/Services/StudyTimerService.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StudyTimerService {
|
||||
private(set) var sessionStart: Date?
|
||||
private(set) var tick: Int = 0
|
||||
private var timer: Timer?
|
||||
|
||||
var isRunning: Bool { sessionStart != nil }
|
||||
|
||||
/// Seconds elapsed in the current live session.
|
||||
var currentSessionSeconds: Int {
|
||||
// Access `tick` so SwiftUI re-evaluates each second.
|
||||
_ = tick
|
||||
guard let start = sessionStart else { return 0 }
|
||||
return max(0, Int(Date().timeIntervalSince(start)))
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard sessionStart == nil else { return }
|
||||
sessionStart = Date()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.tick += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop(context: ModelContext) {
|
||||
guard let start = sessionStart else { return }
|
||||
let elapsed = max(0, Int(Date().timeIntervalSince(start)))
|
||||
sessionStart = nil
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
|
||||
guard elapsed > 0 else { return }
|
||||
|
||||
let todayString = DailyLog.todayString()
|
||||
let log = ReviewStore.fetchOrCreateDailyLog(dateString: todayString, context: context)
|
||||
log.studySeconds += elapsed
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ enum FocusMode: Sendable {
|
||||
case none
|
||||
case weakVerbs
|
||||
case irregularity(IrregularityFilter)
|
||||
case commonTenses
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -96,7 +97,7 @@ final class PracticeViewModel {
|
||||
hasCards = true
|
||||
isLoading = true
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
guard let cardLoad = service.nextCard(for: focusMode) else {
|
||||
guard let cardLoad = service.nextCard(for: focusMode, excludingVerbId: currentVerb?.id) else {
|
||||
clearCurrentCard()
|
||||
hasCards = false
|
||||
isLoading = false
|
||||
|
||||
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
224
Conjuga/Conjuga/Views/Course/CheckpointExamView.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct CheckpointExamView: View {
|
||||
let courseName: String
|
||||
let throughWeek: Int
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var allDecks: [CourseDeck]
|
||||
|
||||
@State private var cardsByWeek: [Int: [VocabCard]] = [:]
|
||||
@State private var checkpointResults: [TestResult] = []
|
||||
@State private var selectedCount = 25
|
||||
|
||||
private let questionCounts = [25, 50, 100]
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var totalAvailable: Int {
|
||||
cardsByWeek.values.reduce(0) { $0 + $1.count }
|
||||
}
|
||||
|
||||
/// Sample evenly across weeks, then fill remainder round-robin.
|
||||
private var sampledCards: [VocabCard] {
|
||||
let weekNumbers = cardsByWeek.keys.sorted()
|
||||
guard !weekNumbers.isEmpty else { return [] }
|
||||
|
||||
let target = min(selectedCount, totalAvailable)
|
||||
let perWeek = target / weekNumbers.count
|
||||
var remainder = target - (perWeek * weekNumbers.count)
|
||||
|
||||
var result: [VocabCard] = []
|
||||
for week in weekNumbers {
|
||||
guard let pool = cardsByWeek[week] else { continue }
|
||||
let shuffled = pool.shuffled()
|
||||
var take = min(perWeek, shuffled.count)
|
||||
// Distribute remainder one extra card per week until exhausted
|
||||
if remainder > 0 && take < shuffled.count {
|
||||
take += 1
|
||||
remainder -= 1
|
||||
}
|
||||
result.append(contentsOf: shuffled.prefix(take))
|
||||
}
|
||||
|
||||
// If some weeks had fewer cards than perWeek, fill from weeks with surplus
|
||||
if result.count < target {
|
||||
let used = Set(result.map { ObjectIdentifier($0) })
|
||||
var extras: [VocabCard] = []
|
||||
for week in weekNumbers {
|
||||
guard let pool = cardsByWeek[week] else { continue }
|
||||
extras.append(contentsOf: pool.filter { !used.contains(ObjectIdentifier($0)) })
|
||||
}
|
||||
extras.shuffle()
|
||||
result.append(contentsOf: extras.prefix(target - result.count))
|
||||
}
|
||||
|
||||
return result.shuffled()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.blue)
|
||||
Text("Checkpoint Exam")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
Text("Weeks 1–\(throughWeek)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Question count picker
|
||||
VStack(spacing: 8) {
|
||||
Text("Questions")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(questionCounts, id: \.self) { count in
|
||||
let available = count <= totalAvailable
|
||||
Button {
|
||||
withAnimation { selectedCount = count }
|
||||
} label: {
|
||||
Text("\(count)")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(selectedCount == count ? .blue : .secondary)
|
||||
.disabled(!available)
|
||||
.opacity(available ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Label("Multiple choice", systemImage: "list.bullet")
|
||||
Label("Cumulative vocabulary", systemImage: "books.vertical")
|
||||
Label("\(totalAvailable) words available", systemImage: "character.book.closed")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if cardsByWeek.isEmpty {
|
||||
ProgressView("Loading vocabulary...")
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
NavigationLink {
|
||||
CourseQuizView(
|
||||
cards: sampledCards,
|
||||
quizType: .checkpoint,
|
||||
courseName: courseName,
|
||||
weekNumber: throughWeek,
|
||||
isFocusMode: false
|
||||
)
|
||||
} label: {
|
||||
Text("Begin Exam")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Score History
|
||||
if !checkpointResults.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Score History")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(checkpointResults.prefix(10).enumerated()), id: \.offset) { _, result in
|
||||
HStack {
|
||||
Text(result.dateTaken.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(result.scorePercent)%")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(scoreColor(result.scorePercent))
|
||||
Text("\(result.correctCount)/\(result.totalQuestions)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
Divider().padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Checkpoint")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
loadCumulativeCards()
|
||||
loadResults()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCumulativeCards() {
|
||||
let course = courseName
|
||||
let maxWeek = throughWeek
|
||||
let weekDecks = allDecks.filter {
|
||||
$0.courseName == course && $0.weekNumber <= maxWeek && !$0.isReversed
|
||||
}
|
||||
var grouped: [Int: [VocabCard]] = [:]
|
||||
for deck in weekDecks {
|
||||
let deckId = deck.id
|
||||
let descriptor = FetchDescriptor<VocabCard>(
|
||||
predicate: #Predicate<VocabCard> { $0.deckId == deckId }
|
||||
)
|
||||
if let fetched = try? modelContext.fetch(descriptor) {
|
||||
grouped[deck.weekNumber, default: []].append(contentsOf: fetched)
|
||||
}
|
||||
}
|
||||
cardsByWeek = grouped
|
||||
}
|
||||
|
||||
private func loadResults() {
|
||||
let course = courseName
|
||||
let week = throughWeek
|
||||
let checkpointType = QuizType.checkpoint.rawValue
|
||||
let descriptor = FetchDescriptor<TestResult>(
|
||||
predicate: #Predicate<TestResult> {
|
||||
$0.courseName == course && $0.weekNumber == week && $0.quizType == checkpointType
|
||||
},
|
||||
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||
)
|
||||
checkpointResults = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func scoreColor(_ percent: Int) -> Color {
|
||||
if percent >= 90 { return .green }
|
||||
if percent >= 70 { return .orange }
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
CheckpointExamView(courseName: "LanGo Spanish | Beginner I", throughWeek: 3)
|
||||
}
|
||||
.modelContainer(for: [TestResult.self, CourseDeck.self, VocabCard.self], inMemory: true)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct CourseQuizView: View {
|
||||
@State private var correctCount = 0
|
||||
@State private var missedItems: [MissedCourseItem] = []
|
||||
@State private var isAdvancing = false
|
||||
@State private var sentenceQuestion: SentenceQuizEngine.Question?
|
||||
|
||||
// Per-question state
|
||||
@State private var userAnswer = ""
|
||||
@@ -61,25 +62,29 @@ struct CourseQuizView: View {
|
||||
.padding(.horizontal)
|
||||
|
||||
// Prompt
|
||||
VStack(spacing: 8) {
|
||||
Text(quizType.promptLanguage)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
if quizType.isCompleteSentence, let question = sentenceQuestion {
|
||||
sentencePrompt(question: question)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
Text(quizType.promptLanguage)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(quizType.prompt(for: card))
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(quizType.prompt(for: card))
|
||||
.font(.title.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if quizType.promptLanguage == "Spanish" {
|
||||
Button { speechService.speak(card.front) } label: {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
.font(.title3)
|
||||
if quizType.promptLanguage == "Spanish" {
|
||||
Button { speechService.speak(card.front) } label: {
|
||||
Image(systemName: "speaker.wave.2")
|
||||
.font(.title3)
|
||||
}
|
||||
.tint(.secondary)
|
||||
}
|
||||
.tint(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
// Answer area
|
||||
if quizType.isMultipleChoice {
|
||||
@@ -99,7 +104,7 @@ struct CourseQuizView: View {
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle(isFocusMode ? "Focus Area" : "Week \(weekNumber) Test")
|
||||
.navigationTitle(isFocusMode ? "Focus Area" : quizType == .checkpoint ? "Checkpoint" : "Week \(weekNumber) Test")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -112,15 +117,48 @@ struct CourseQuizView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
shuffledCards = cards.shuffled()
|
||||
let pool: [VocabCard]
|
||||
if quizType.isCompleteSentence {
|
||||
pool = cards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
|
||||
} else {
|
||||
pool = cards
|
||||
}
|
||||
shuffledCards = pool.shuffled()
|
||||
prepareQuestion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Complete the Sentence
|
||||
|
||||
private func sentencePrompt(question: SentenceQuizEngine.Question) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("Complete the Sentence")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
Text(question.displayTemplate)
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal)
|
||||
|
||||
if !question.sentenceEN.isEmpty {
|
||||
Text(question.sentenceEN)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Multiple Choice
|
||||
|
||||
private func multipleChoiceArea(card: VocabCard) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
let correct = correctAnswer(for: card)
|
||||
return VStack(spacing: 10) {
|
||||
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
||||
Button {
|
||||
guard !isAnswered else { return }
|
||||
@@ -132,7 +170,7 @@ struct CourseQuizView: View {
|
||||
.font(.body.weight(.medium))
|
||||
Spacer()
|
||||
if isAnswered {
|
||||
if option == quizType.answer(for: card) {
|
||||
if option.caseInsensitiveCompare(correct) == .orderedSame {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else if index == selectedOption {
|
||||
@@ -147,7 +185,7 @@ struct CourseQuizView: View {
|
||||
}
|
||||
.tint(mcTint(index: index, option: option, card: card))
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.opacity(isAnswered && option != quizType.answer(for: card) && index != selectedOption ? 0.4 : 1)
|
||||
.opacity(isAnswered && option.caseInsensitiveCompare(correct) != .orderedSame && index != selectedOption ? 0.4 : 1)
|
||||
.disabled(isAnswered)
|
||||
}
|
||||
}
|
||||
@@ -156,11 +194,18 @@ struct CourseQuizView: View {
|
||||
|
||||
private func mcTint(index: Int, option: String, card: VocabCard) -> Color {
|
||||
guard isAnswered else { return .primary }
|
||||
if option == quizType.answer(for: card) { return .green }
|
||||
if option.caseInsensitiveCompare(correctAnswer(for: card)) == .orderedSame { return .green }
|
||||
if index == selectedOption { return .red }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private func correctAnswer(for card: VocabCard) -> String {
|
||||
if quizType.isCompleteSentence, let blank = sentenceQuestion?.blankWord {
|
||||
return blank
|
||||
}
|
||||
return quizType.answer(for: card)
|
||||
}
|
||||
|
||||
// MARK: - Handwriting
|
||||
|
||||
private func handwritingArea(card: VocabCard) -> some View {
|
||||
@@ -418,6 +463,12 @@ struct CourseQuizView: View {
|
||||
selectedOption = nil
|
||||
userAnswer = ""
|
||||
|
||||
if quizType.isCompleteSentence {
|
||||
sentenceQuestion = SentenceQuizEngine.buildQuestion(for: card)
|
||||
} else {
|
||||
sentenceQuestion = nil
|
||||
}
|
||||
|
||||
if quizType.isMultipleChoice {
|
||||
options = generateOptions(for: card)
|
||||
} else {
|
||||
@@ -436,6 +487,7 @@ struct CourseQuizView: View {
|
||||
hwDrawing = PKDrawing()
|
||||
hwRecognizedText = ""
|
||||
isRecognizing = false
|
||||
sentenceQuestion = nil
|
||||
}
|
||||
|
||||
private func submitHandwriting(card: VocabCard) {
|
||||
@@ -453,11 +505,11 @@ struct CourseQuizView: View {
|
||||
}
|
||||
|
||||
private func generateOptions(for card: VocabCard) -> [String] {
|
||||
let correct = quizType.answer(for: card)
|
||||
let correct = correctAnswer(for: card)
|
||||
var distractors: [String] = []
|
||||
var seen: Set<String> = [correct.lowercased()]
|
||||
|
||||
// Pull distractors from all cards in the set
|
||||
// Pull distractors from all cards in the set using each card's own front
|
||||
for other in shuffledCards.shuffled() {
|
||||
let ans = quizType.answer(for: other)
|
||||
let lower = ans.lowercased()
|
||||
@@ -474,7 +526,7 @@ struct CourseQuizView: View {
|
||||
}
|
||||
|
||||
private func checkMCAnswer(_ selected: String, card: VocabCard) {
|
||||
let correct = quizType.answer(for: card)
|
||||
let correct = correctAnswer(for: card)
|
||||
isCorrect = selected.compare(correct, options: .caseInsensitive, locale: Locale(identifier: "es")) == .orderedSame
|
||||
recordAnswer(card: card)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,20 @@ struct CourseView: View {
|
||||
private func bestScore(for week: Int) -> Int? {
|
||||
let results = testResults.filter {
|
||||
$0.courseName == activeCourse && $0.weekNumber == week
|
||||
&& $0.quizType != QuizType.checkpoint.rawValue
|
||||
}
|
||||
guard !results.isEmpty else { return nil }
|
||||
return results.map(\.scorePercent).max()
|
||||
}
|
||||
|
||||
private func bestCheckpointScore(for week: Int) -> Int? {
|
||||
let results = testResults.filter {
|
||||
$0.courseName == activeCourse && $0.weekNumber == week
|
||||
&& $0.quizType == QuizType.checkpoint.rawValue
|
||||
}
|
||||
return results.map(\.scorePercent).max()
|
||||
}
|
||||
|
||||
private func shortName(_ full: String) -> String {
|
||||
full.replacingOccurrences(of: "LanGo Spanish | ", with: "")
|
||||
.replacingOccurrences(of: "LanGo Spanish ", with: "")
|
||||
@@ -103,6 +112,32 @@ struct CourseView: View {
|
||||
DeckRowView(deck: deck)
|
||||
}
|
||||
}
|
||||
|
||||
// Checkpoint exam
|
||||
NavigationLink(value: CheckpointDestination(courseName: activeCourse, throughWeek: week)) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.seal")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Checkpoint Exam")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Cumulative review: Weeks 1–\(week)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let best = bestCheckpointScore(for: week) {
|
||||
Text("\(best)%")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(best >= 90 ? .green : best >= 70 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Week \(week)")
|
||||
}
|
||||
@@ -117,6 +152,9 @@ struct CourseView: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +170,11 @@ struct WeekTestDestination: Hashable {
|
||||
let weekNumber: Int
|
||||
}
|
||||
|
||||
struct CheckpointDestination: Hashable {
|
||||
let courseName: String
|
||||
let throughWeek: Int
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
private struct DeckRowView: View {
|
||||
|
||||
@@ -56,7 +56,7 @@ struct WeekTestView: View {
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
|
||||
ForEach(QuizType.allCases) { type in
|
||||
ForEach(QuizType.weeklyQuizTypes, id: \.self) { type in
|
||||
NavigationLink {
|
||||
CourseQuizView(
|
||||
cards: weekCards,
|
||||
|
||||
@@ -4,6 +4,7 @@ import Charts
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(StudyTimerService.self) private var studyTimer
|
||||
@State private var userProgress: UserProgress?
|
||||
@State private var dailyLogs: [DailyLog] = []
|
||||
@State private var testResults: [TestResult] = []
|
||||
@@ -19,8 +20,17 @@ struct DashboardView: View {
|
||||
// Summary stats
|
||||
statsGrid
|
||||
|
||||
// Streak calendar
|
||||
streakCalendar
|
||||
// Study time + Activity — side by side on iPad, stacked on iPhone
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
studyTimeCard
|
||||
streakCalendar
|
||||
}
|
||||
VStack(spacing: 12) {
|
||||
studyTimeCard
|
||||
streakCalendar
|
||||
}
|
||||
}
|
||||
|
||||
// Accuracy chart
|
||||
accuracyChart
|
||||
@@ -71,6 +81,57 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Study Time Card
|
||||
|
||||
private var studyTimeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Study Time")
|
||||
.font(.headline)
|
||||
|
||||
let todaySeconds = todayStudySeconds + studyTimer.currentSessionSeconds
|
||||
HStack(spacing: 0) {
|
||||
VStack(spacing: 4) {
|
||||
Text(formatStudyTime(todaySeconds))
|
||||
.font(.title3.bold().monospacedDigit())
|
||||
Text("Today")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(formatStudyTime(totalStudySeconds))
|
||||
.font(.title3.bold().monospacedDigit())
|
||||
Text("Total")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
if weeklyStudyData.allSatisfy({ $0.minutes == 0 }) {
|
||||
Text("Start studying to see your time")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 80)
|
||||
} else {
|
||||
Chart(weeklyStudyData) { day in
|
||||
BarMark(
|
||||
x: .value("Day", day.label),
|
||||
y: .value("Minutes", day.minutes)
|
||||
)
|
||||
.foregroundStyle(.mint.gradient)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 80)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// MARK: - Streak Calendar
|
||||
|
||||
private var streakCalendar: some View {
|
||||
@@ -81,6 +142,7 @@ struct DashboardView: View {
|
||||
StreakCalendarView(dailyLogs: dailyLogs)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@@ -167,6 +229,48 @@ struct DashboardView: View {
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// MARK: - Study Time Computed
|
||||
|
||||
private var todayStudySeconds: Int {
|
||||
let today = DailyLog.todayString()
|
||||
return dailyLogs.first { $0.dateString == today }?.studySeconds ?? 0
|
||||
}
|
||||
|
||||
private var totalStudySeconds: Int {
|
||||
dailyLogs.reduce(0) { $0 + $1.studySeconds } + studyTimer.currentSessionSeconds
|
||||
}
|
||||
|
||||
private var weeklyStudySeconds: Int {
|
||||
weeklyStudyData.reduce(0) { $0 + $1.minutes } * 60
|
||||
}
|
||||
|
||||
private var weeklyStudyData: [StudyDay] {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
return (0..<7).reversed().map { daysAgo in
|
||||
let date = calendar.date(byAdding: .day, value: -daysAgo, to: today)!
|
||||
let dateStr = DailyLog.dateString(from: date)
|
||||
let seconds = dailyLogs.first { $0.dateString == dateStr }?.studySeconds ?? 0
|
||||
let dayLabel = daysAgo == 0 ? "Today" : Self.shortDayFormatter.string(from: date)
|
||||
return StudyDay(label: dayLabel, minutes: seconds / 60)
|
||||
}
|
||||
}
|
||||
|
||||
private static let shortDayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEE"
|
||||
return f
|
||||
}()
|
||||
|
||||
private func formatStudyTime(_ totalSeconds: Int) -> String {
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
}
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
private var recentLogs: [DailyLog] {
|
||||
@@ -222,7 +326,14 @@ private struct StatCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct StudyDay: Identifiable {
|
||||
let label: String
|
||||
let minutes: Int
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DashboardView()
|
||||
.environment(StudyTimerService())
|
||||
.modelContainer(for: [UserProgress.self, DailyLog.self, TestResult.self, ReviewCard.self], inMemory: true)
|
||||
}
|
||||
|
||||
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
164
Conjuga/Conjuga/Views/Guide/GrammarExerciseView.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GrammarExerciseView: View {
|
||||
let noteId: String
|
||||
let noteTitle: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var exercises: [GrammarExercise] = []
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isFinished {
|
||||
finishedView
|
||||
} else if let ex = exercises[safe: currentIndex] {
|
||||
exerciseView(ex)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Practice: \(noteTitle)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { exercises = Array(GrammarExercise.exercises(for: noteId).shuffled().prefix(10)) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func exerciseView(_ ex: GrammarExercise) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("\(currentIndex + 1) / \(exercises.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(exercises.count))
|
||||
.tint(.purple)
|
||||
|
||||
// Prompt
|
||||
Text(ex.prompt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Sentence
|
||||
Text(highlightBlank(ex.sentence))
|
||||
.font(.title3)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Options
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(ex.options.enumerated()), id: \.offset) { index, option in
|
||||
Button {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = index
|
||||
if option == ex.correctAnswer { correctCount += 1 }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option)
|
||||
.font(.body.weight(.medium))
|
||||
Spacer()
|
||||
if let selected = selectedOption {
|
||||
if option == ex.correctAnswer {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
} else if index == selected {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(optionBG(index: index, correct: ex.correctAnswer, options: ex.options), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// Explanation after answer
|
||||
if selectedOption != nil {
|
||||
Text(ex.explanation)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 10))
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedOption != nil {
|
||||
Button {
|
||||
if currentIndex + 1 < exercises.count {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
Text(currentIndex + 1 < exercises.count ? "Next" : "See Results")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: correctCount == exercises.count ? "star.fill" : "checkmark.circle")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(correctCount == exercises.count ? .yellow : .purple)
|
||||
|
||||
Text("\(correctCount) / \(exercises.count)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
|
||||
Text(correctCount == exercises.count ? "Perfect!" : "Keep reviewing this topic.")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Done")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func highlightBlank(_ text: String) -> AttributedString {
|
||||
var result = AttributedString(text)
|
||||
if let range = result.range(of: "_____") {
|
||||
result[range].foregroundColor = .purple
|
||||
result[range].font = .title3.bold()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func optionBG(index: Int, correct: String, options: [String]) -> some ShapeStyle {
|
||||
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
|
||||
if options[index] == correct { return AnyShapeStyle(.green.opacity(0.15)) }
|
||||
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
|
||||
return AnyShapeStyle(.fill.quaternary)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,20 @@ struct GrammarNoteDetailView: View {
|
||||
|
||||
// Parsed body
|
||||
FormattedGrammarBody(content: note.body)
|
||||
|
||||
// Practice button (if exercises exist for this note)
|
||||
if !GrammarExercise.exercises(for: note.id).isEmpty {
|
||||
NavigationLink {
|
||||
GrammarExerciseView(noteId: note.id, noteTitle: note.title)
|
||||
} label: {
|
||||
Label("Practice This", systemImage: "pencil.and.list.clipboard")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.purple)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
@@ -101,40 +115,148 @@ struct GrammarNoteDetailView: View {
|
||||
private struct FormattedGrammarBody: View {
|
||||
let content: String
|
||||
|
||||
private var lines: [GrammarLine] {
|
||||
GrammarLine.parse(content)
|
||||
private var sections: [GrammarSection] {
|
||||
GrammarSection.group(GrammarLine.parse(content))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(lines) { line in
|
||||
switch line.kind {
|
||||
case .paragraph(let text):
|
||||
renderParagraph(text)
|
||||
case .spanishExample(let spanish):
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
.padding(.leading, 12)
|
||||
case .examplePair(let spanish, let english):
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(sections) { section in
|
||||
if section.heading == nil {
|
||||
// Intro content before the first heading — no card
|
||||
renderLines(section.lines)
|
||||
} else {
|
||||
// Headed section in a card
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(section.heading!)
|
||||
.font(.headline)
|
||||
|
||||
renderLines(section.lines)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
case .heading(let text):
|
||||
Text(text)
|
||||
.font(.headline)
|
||||
.padding(.top, 6)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderLines(_ lines: [GrammarLine]) -> some View {
|
||||
let blocks = ContentBlock.group(lines)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(blocks) { block in
|
||||
switch block.kind {
|
||||
case .standalone(let line):
|
||||
renderSingleLine(line)
|
||||
case .exampleCard(let header, let examples):
|
||||
renderExampleCard(header: header, examples: examples)
|
||||
case .suffixCard(let suffix, let description, let examples):
|
||||
renderSuffixCard(suffix: suffix, description: description, examples: examples)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderSingleLine(_ line: GrammarLine) -> some View {
|
||||
switch line.kind {
|
||||
case .paragraph(let text):
|
||||
renderParagraph(text)
|
||||
case .spanishExample(let spanish):
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
.padding(.leading, 12)
|
||||
case .examplePair(let spanish, let english):
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
case .heading, .suffixDef:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderExampleCard(header: GrammarLine?, examples: [GrammarLine]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let header, case .paragraph(let text) = header.kind {
|
||||
renderParagraph(text)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(examples) { ex in
|
||||
if case .examplePair(let spanish, let english) = ex.kind {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if case .spanishExample(let spanish) = ex.kind {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderSuffixCard(suffix: String, description: String, examples: [GrammarLine]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(suffix)
|
||||
.font(.subheadline.weight(.bold).monospaced())
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(.tint.opacity(0.12), in: Capsule())
|
||||
|
||||
Text(description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !examples.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(examples) { ex in
|
||||
if case .examplePair(let spanish, let english) = ex.kind {
|
||||
HStack(spacing: 0) {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
Text(" ")
|
||||
Text(english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if case .spanishExample(let spanish) = ex.kind {
|
||||
Text(spanish)
|
||||
.font(.body.weight(.medium))
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderParagraph(_ text: String) -> some View {
|
||||
Text(parseInlineFormatting(text))
|
||||
@@ -200,11 +322,20 @@ private struct GrammarLine: Identifiable {
|
||||
let id: Int
|
||||
let kind: Kind
|
||||
|
||||
var isExample: Bool {
|
||||
switch kind {
|
||||
case .examplePair, .spanishExample: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
case paragraph(String)
|
||||
case heading(String)
|
||||
case spanishExample(String)
|
||||
case examplePair(spanish: String, english: String)
|
||||
/// A suffix definition line like `*-ito / -ita* — description`
|
||||
case suffixDef(suffix: String, description: String)
|
||||
}
|
||||
|
||||
static func parse(_ body: String) -> [GrammarLine] {
|
||||
@@ -255,7 +386,13 @@ private struct GrammarLine: Identifiable {
|
||||
let englishPart = String(line[dashRange.upperBound...])
|
||||
.replacingOccurrences(of: "*", with: "")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||
|
||||
// Detect suffix definitions: spanish part starts with "-"
|
||||
if spanishPart.hasPrefix("-") {
|
||||
result.append(GrammarLine(id: result.count, kind: .suffixDef(suffix: spanishPart, description: englishPart)))
|
||||
} else {
|
||||
result.append(GrammarLine(id: result.count, kind: .examplePair(spanish: spanishPart, english: englishPart)))
|
||||
}
|
||||
} else {
|
||||
// Just a Spanish example without translation
|
||||
let spanish = line.replacingOccurrences(of: "*", with: "")
|
||||
@@ -279,6 +416,126 @@ private struct GrammarLine: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Block Grouping
|
||||
|
||||
/// Groups paragraphs with their trailing examples into visual cards.
|
||||
/// Also handles suffix definitions as a special card type.
|
||||
private struct ContentBlock: Identifiable {
|
||||
let id: Int
|
||||
|
||||
enum Kind {
|
||||
/// A standalone line (paragraph with no examples, or orphaned example)
|
||||
case standalone(GrammarLine)
|
||||
/// A paragraph header followed by example pairs — rendered as a card
|
||||
case exampleCard(header: GrammarLine?, examples: [GrammarLine])
|
||||
/// A suffix definition with its examples — rendered as a pill card
|
||||
case suffixCard(suffix: String, description: String, examples: [GrammarLine])
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
|
||||
static func group(_ lines: [GrammarLine]) -> [ContentBlock] {
|
||||
var result: [ContentBlock] = []
|
||||
var i = 0
|
||||
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
|
||||
// Suffix definition: collect trailing examples
|
||||
if case .suffixDef(let suffix, let desc) = line.kind {
|
||||
var examples: [GrammarLine] = []
|
||||
var j = i + 1
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .suffixCard(suffix: suffix, description: desc, examples: examples)))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph followed by examples: group into a card
|
||||
if case .paragraph = line.kind {
|
||||
var j = i + 1
|
||||
// Check if examples follow
|
||||
if j < lines.count, lines[j].isExample {
|
||||
var examples: [GrammarLine] = []
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: line, examples: examples)))
|
||||
i = j
|
||||
} else {
|
||||
// Standalone paragraph
|
||||
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||
i += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphaned examples (no preceding paragraph) — group into a card
|
||||
if line.isExample {
|
||||
var examples: [GrammarLine] = []
|
||||
var j = i
|
||||
while j < lines.count, lines[j].isExample {
|
||||
examples.append(lines[j])
|
||||
j += 1
|
||||
}
|
||||
result.append(ContentBlock(id: result.count, kind: .exampleCard(header: nil, examples: examples)))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip headings (handled at section level)
|
||||
if case .heading = line.kind {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
result.append(ContentBlock(id: result.count, kind: .standalone(line)))
|
||||
i += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grammar Section Grouping
|
||||
|
||||
private struct GrammarSection: Identifiable {
|
||||
let id: Int
|
||||
let heading: String?
|
||||
let lines: [GrammarLine]
|
||||
|
||||
static func group(_ lines: [GrammarLine]) -> [GrammarSection] {
|
||||
var sections: [GrammarSection] = []
|
||||
var currentHeading: String? = nil
|
||||
var currentLines: [GrammarLine] = []
|
||||
var sectionIndex = 0
|
||||
|
||||
for line in lines {
|
||||
if case .heading(let text) = line.kind {
|
||||
// Flush the previous section
|
||||
if !currentLines.isEmpty || currentHeading != nil {
|
||||
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||
sectionIndex += 1
|
||||
currentLines = []
|
||||
}
|
||||
currentHeading = text
|
||||
} else {
|
||||
currentLines.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush final section
|
||||
if !currentLines.isEmpty || currentHeading != nil {
|
||||
sections.append(GrammarSection(id: sectionIndex, heading: currentHeading, lines: currentLines))
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hashable/Equatable conformance for NavigationLink
|
||||
|
||||
extension GrammarNote: Hashable, Equatable {
|
||||
|
||||
@@ -100,12 +100,25 @@ private struct TenseRowView: View {
|
||||
let tense: TenseInfo
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tense.english)
|
||||
.font(.headline)
|
||||
Text(tense.spanish)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tense.english)
|
||||
.font(.headline)
|
||||
Text(tense.spanish)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if tense.isCore {
|
||||
Text("Essential")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.orange.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal file
126
Conjuga/Conjuga/Views/Practice/Chat/ChatLibraryView.swift
Normal file
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct ChatLibraryView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var showingScenarioPicker = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if conversations.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Conversations Yet",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
description: Text("Tap + to start a Spanish conversation.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(conversations) { conv in
|
||||
NavigationLink {
|
||||
ChatView(conversation: conv)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(conv.scenario)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Text(conv.level.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.green.opacity(0.12), in: Capsule())
|
||||
let msgCount = conv.decodedMessages.count
|
||||
Text("\(msgCount) message\(msgCount == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteConversations)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Conversations")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingScenarioPicker = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.disabled(!ConversationService.isAvailable)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingScenarioPicker) {
|
||||
ScenarioPickerView { scenario in
|
||||
showingScenarioPicker = false
|
||||
createConversation(scenario: scenario)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadConversations)
|
||||
}
|
||||
|
||||
private func loadConversations() {
|
||||
let descriptor = FetchDescriptor<Conversation>(
|
||||
sortBy: [SortDescriptor(\Conversation.createdDate, order: .reverse)]
|
||||
)
|
||||
conversations = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func createConversation(scenario: String) {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
let conv = Conversation(scenario: scenario, level: progress.selectedLevel)
|
||||
cloudContext.insert(conv)
|
||||
try? cloudContext.save()
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
private func deleteConversations(at offsets: IndexSet) {
|
||||
for index in offsets { cloudContext.delete(conversations[index]) }
|
||||
try? cloudContext.save()
|
||||
loadConversations()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scenario Picker
|
||||
|
||||
struct ScenarioPickerView: View {
|
||||
let onPick: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(ConversationService.scenarios, id: \.self) { scenario in
|
||||
Button {
|
||||
onPick(scenario)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(scenario)
|
||||
.font(.body)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Choose a Scenario")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
150
Conjuga/Conjuga/Views/Practice/Chat/ChatView.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct ChatView: View {
|
||||
let conversation: Conversation
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var service = ConversationService()
|
||||
@State private var messages: [ChatMessage] = []
|
||||
@State private var inputText = ""
|
||||
@State private var errorMessage: String?
|
||||
@State private var hasStarted = false
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(messages) { message in
|
||||
ChatBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
if service.isResponding {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.onChange(of: messages.count) {
|
||||
if let last = messages.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Input
|
||||
HStack(spacing: 8) {
|
||||
TextField("Type in Spanish...", text: $inputText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.onSubmit { sendMessage() }
|
||||
|
||||
Button {
|
||||
sendMessage()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
.disabled(inputText.trimmingCharacters(in: .whitespaces).isEmpty || service.isResponding)
|
||||
.tint(.green)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(conversation.scenario)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { errorMessage = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
messages = conversation.decodedMessages
|
||||
if !hasStarted && messages.isEmpty {
|
||||
startConversation()
|
||||
}
|
||||
hasStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
private func startConversation() {
|
||||
let opening = service.startConversation(scenario: conversation.scenario, level: conversation.level)
|
||||
let msg = ChatMessage(role: "assistant", content: opening)
|
||||
conversation.appendMessage(msg)
|
||||
messages = conversation.decodedMessages
|
||||
try? cloudContext.save()
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = inputText.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
let userMsg = ChatMessage(role: "user", content: text)
|
||||
conversation.appendMessage(userMsg)
|
||||
messages = conversation.decodedMessages
|
||||
inputText = ""
|
||||
try? cloudContext.save()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let response = try await service.respond(to: text)
|
||||
let assistantMsg = ChatMessage(role: "assistant", content: response)
|
||||
conversation.appendMessage(assistantMsg)
|
||||
messages = conversation.decodedMessages
|
||||
try? cloudContext.save()
|
||||
} catch {
|
||||
errorMessage = "Failed to get response: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat Bubble
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
|
||||
private var isUser: Bool { message.role == "user" }
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
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 let correction = message.correction, !correction.isEmpty {
|
||||
Text(correction)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if !isUser { Spacer(minLength: 60) }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal file
212
Conjuga/Conjuga/Views/Practice/ClozeView.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct ClozeView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
|
||||
@State private var questions: [ClozeQuestion] = []
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@State private var isFinished = false
|
||||
@State private var isLoading = true
|
||||
|
||||
private var cloudContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isLoading {
|
||||
ProgressView("Loading questions...")
|
||||
} else if isFinished || questions.isEmpty {
|
||||
finishedView
|
||||
} else if let q = questions[safe: currentIndex] {
|
||||
questionView(q)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Cloze Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadQuestions)
|
||||
}
|
||||
|
||||
// MARK: - Question View
|
||||
|
||||
@ViewBuilder
|
||||
private func questionView(_ q: ClozeQuestion) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("\(currentIndex + 1) / \(questions.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(questions.count))
|
||||
.tint(.indigo)
|
||||
|
||||
// Sentence with blank
|
||||
Text(highlightedTemplate(q.displayTemplate))
|
||||
.font(.title3)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// English hint
|
||||
if !q.sentenceEN.isEmpty {
|
||||
Text(q.sentenceEN)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// Options
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(q.options.enumerated()), id: \.offset) { index, option in
|
||||
Button {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = index
|
||||
if option.lowercased() == q.answer.lowercased() {
|
||||
correctCount += 1
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option)
|
||||
.font(.body)
|
||||
Spacer()
|
||||
if let selected = selectedOption {
|
||||
if option.lowercased() == q.answer.lowercased() {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
} else if index == selected {
|
||||
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(optionBG(index: index, answer: q.answer, options: q.options), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedOption != nil {
|
||||
Button {
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
Text(currentIndex + 1 < questions.count ? "Next" : "See Results")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finished
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: questions.isEmpty ? "text.badge.xmark" : "checkmark.circle")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(questions.isEmpty ? Color.secondary : Color.indigo)
|
||||
|
||||
if questions.isEmpty {
|
||||
Text("No cloze questions available")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Complete some course decks first to unlock cloze practice.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("\(correctCount) / \(questions.count)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
Text(correctCount == questions.count ? "Perfect!" : "Keep practicing!")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func highlightedTemplate(_ template: String) -> AttributedString {
|
||||
var result = AttributedString(template)
|
||||
if let range = result.range(of: SentenceQuizEngine.blankMarker) {
|
||||
result[range].foregroundColor = .indigo
|
||||
result[range].font = .title3.bold()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func optionBG(index: Int, answer: String, options: [String]) -> some ShapeStyle {
|
||||
guard let selected = selectedOption else { return AnyShapeStyle(.fill.quaternary) }
|
||||
if options[index].lowercased() == answer.lowercased() { return AnyShapeStyle(.green.opacity(0.15)) }
|
||||
if index == selected { return AnyShapeStyle(.red.opacity(0.15)) }
|
||||
return AnyShapeStyle(.fill.quaternary)
|
||||
}
|
||||
|
||||
private func loadQuestions() {
|
||||
let descriptor = FetchDescriptor<VocabCard>()
|
||||
let allCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
let eligible = allCards.filter { SentenceQuizEngine.hasValidSentence(for: $0) }
|
||||
|
||||
var result: [ClozeQuestion] = []
|
||||
let pool = eligible.shuffled().prefix(20)
|
||||
|
||||
for card in pool {
|
||||
guard let q = SentenceQuizEngine.buildQuestion(for: card) else { continue }
|
||||
|
||||
// Build distractors from other cards
|
||||
var distractors = eligible
|
||||
.filter { $0.front != card.front }
|
||||
.shuffled()
|
||||
.prefix(3)
|
||||
.map(\.front)
|
||||
|
||||
while distractors.count < 3 {
|
||||
distractors.append("---")
|
||||
}
|
||||
|
||||
var options = Array(distractors) + [q.blankWord]
|
||||
options.shuffle()
|
||||
|
||||
result.append(ClozeQuestion(
|
||||
displayTemplate: q.displayTemplate,
|
||||
sentenceEN: q.sentenceEN,
|
||||
answer: q.blankWord,
|
||||
options: options
|
||||
))
|
||||
|
||||
if result.count >= 10 { break }
|
||||
}
|
||||
|
||||
questions = result
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClozeQuestion {
|
||||
let displayTemplate: String
|
||||
let sentenceEN: String
|
||||
let answer: String
|
||||
let options: [String]
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal file
319
Conjuga/Conjuga/Views/Practice/ListeningView.swift
Normal file
@@ -0,0 +1,319 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct ListeningView: View {
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@State private var pronunciation = PronunciationService()
|
||||
@State private var speechService = SpeechService()
|
||||
|
||||
@State private var sentences: [(spanish: String, english: String)] = []
|
||||
@State private var currentIndex = 0
|
||||
@State private var userInput = ""
|
||||
@State private var isRevealed = false
|
||||
@State private var score: Double?
|
||||
@State private var wordMatches: [PronunciationService.WordMatch] = []
|
||||
@State private var mode: ListeningMode = .listenType
|
||||
@State private var correctCount = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
enum ListeningMode: String, CaseIterable {
|
||||
case listenType = "Listen & Type"
|
||||
case speakCheck = "Pronunciation"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
if isFinished {
|
||||
finishedView
|
||||
} else if sentences.isEmpty {
|
||||
ContentUnavailableView("No sentences available", systemImage: "waveform", description: Text("Complete some course decks first."))
|
||||
} else {
|
||||
exerciseView
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Listening Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
print("[ListeningView] onAppear — loading sentences")
|
||||
loadSentences()
|
||||
print("[ListeningView] loaded \(sentences.count) sentences, requesting auth")
|
||||
Task {
|
||||
pronunciation.requestAuthorization()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exercise
|
||||
|
||||
@ViewBuilder
|
||||
private var exerciseView: some View {
|
||||
VStack(spacing: 20) {
|
||||
// Mode picker
|
||||
Picker("Mode", selection: $mode) {
|
||||
ForEach(ListeningMode.allCases, id: \.self) { m in
|
||||
Text(m.rawValue).tag(m)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text("\(currentIndex + 1) / \(sentences.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if mode == .listenType {
|
||||
listenAndTypeView
|
||||
} else {
|
||||
pronunciationCheckView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Listen & Type
|
||||
|
||||
@ViewBuilder
|
||||
private var listenAndTypeView: some View {
|
||||
let sentence = sentences[currentIndex]
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Play button
|
||||
Button {
|
||||
speechService.speak(sentence.spanish)
|
||||
} label: {
|
||||
Label("Play", systemImage: "speaker.wave.2.fill")
|
||||
.font(.headline)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.blue)
|
||||
|
||||
// User types what they heard
|
||||
TextField("Type what you hear...", text: $userInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
|
||||
if isRevealed {
|
||||
// Show correct answer
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Correct:")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(sentence.spanish)
|
||||
.font(.body.weight(.medium))
|
||||
Text(sentence.english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||
Text("Score: \(Int(result.score * 100))%")
|
||||
.font(.headline)
|
||||
.foregroundStyle(result.score >= 0.8 ? .green : result.score >= 0.5 ? .orange : .red)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
nextButton
|
||||
} else {
|
||||
Button {
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: userInput)
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
withAnimation { isRevealed = true }
|
||||
} label: {
|
||||
Text("Check")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
.disabled(userInput.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pronunciation Check
|
||||
|
||||
@ViewBuilder
|
||||
private var pronunciationCheckView: some View {
|
||||
let sentence = sentences[currentIndex]
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Show the sentence to read
|
||||
Text(sentence.spanish)
|
||||
.font(.title3.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text(sentence.english)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Mic button
|
||||
Button {
|
||||
if pronunciation.isRecording {
|
||||
pronunciation.stopRecording()
|
||||
// Score after stopping
|
||||
let result = PronunciationService.scoreMatch(expected: sentence.spanish, spoken: pronunciation.transcript)
|
||||
score = result.score
|
||||
wordMatches = result.matches
|
||||
if result.score >= 0.7 { correctCount += 1 }
|
||||
withAnimation { isRevealed = true }
|
||||
} else {
|
||||
pronunciation.startRecording()
|
||||
}
|
||||
} label: {
|
||||
Label(pronunciation.isRecording ? "Stop" : "Start Speaking", systemImage: pronunciation.isRecording ? "stop.circle.fill" : "mic.circle.fill")
|
||||
.font(.headline)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(pronunciation.isRecording ? .red : .green)
|
||||
.disabled(!pronunciation.isAuthorized)
|
||||
|
||||
if !pronunciation.isAuthorized {
|
||||
Text("Microphone access required. Enable in Settings.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if pronunciation.isRecording {
|
||||
Text(pronunciation.transcript.isEmpty ? "Listening..." : pronunciation.transcript)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
}
|
||||
|
||||
if isRevealed, let score {
|
||||
VStack(spacing: 8) {
|
||||
Text("\(Int(score * 100))% match")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(score >= 0.8 ? .green : score >= 0.5 ? .orange : .red)
|
||||
|
||||
// Word-by-word feedback
|
||||
FlowLayout(spacing: 4) {
|
||||
ForEach(wordMatches) { match in
|
||||
Text(match.word)
|
||||
.font(.body)
|
||||
.foregroundStyle(match.matched ? .green : .red)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(match.matched ? .green.opacity(0.1) : .red.opacity(0.1), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
nextButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
private var nextButton: some View {
|
||||
Button {
|
||||
if currentIndex + 1 < sentences.count {
|
||||
currentIndex += 1
|
||||
userInput = ""
|
||||
isRevealed = false
|
||||
score = nil
|
||||
wordMatches = []
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
Text(currentIndex + 1 < sentences.count ? "Next" : "See Results")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
Image(systemName: "ear.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.blue)
|
||||
Text("\(correctCount) / \(sentences.count)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
Text("Listening complete!")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSentences() {
|
||||
print("[ListeningView] fetching VocabCards from localContext...")
|
||||
print("[ListeningView] context: \(localContext)")
|
||||
let descriptor = FetchDescriptor<VocabCard>()
|
||||
let cards: [VocabCard]
|
||||
do {
|
||||
let count = try localContext.fetchCount(descriptor)
|
||||
print("[ListeningView] fetchCount = \(count)")
|
||||
cards = try localContext.fetch(descriptor)
|
||||
print("[ListeningView] fetched \(cards.count) VocabCards")
|
||||
} catch {
|
||||
print("[ListeningView] ERROR fetching VocabCards: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
var results: [(String, String)] = []
|
||||
for card in cards.shuffled() {
|
||||
for i in card.examplesES.indices {
|
||||
let es = card.examplesES[i]
|
||||
let en = i < card.examplesEN.count ? card.examplesEN[i] : ""
|
||||
if es.split(separator: " ").count >= 4 {
|
||||
results.append((es, en))
|
||||
}
|
||||
if results.count >= 10 { break }
|
||||
}
|
||||
if results.count >= 10 { break }
|
||||
}
|
||||
sentences = results
|
||||
print("[ListeningView] selected \(sentences.count) sentences")
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse FlowLayout from StoryReaderView — import not needed since it's in the same module
|
||||
// but we need a local copy since it's private there
|
||||
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 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,216 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import Translation
|
||||
|
||||
struct LyricsConfirmationView: View {
|
||||
let result: LyricsSearchResult
|
||||
let onSave: () -> Void
|
||||
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
@State private var translatedEN = ""
|
||||
@State private var isTranslating = true
|
||||
@State private var translationError = false
|
||||
@State private var translationConfig: TranslationSession.Configuration?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerSection
|
||||
lyricsPreview
|
||||
actionButtons
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle("Confirm Lyrics")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
translationConfig = .init(
|
||||
source: Locale.Language(identifier: "es"),
|
||||
target: Locale.Language(identifier: "en")
|
||||
)
|
||||
}
|
||||
.translationTask(translationConfig) { session in
|
||||
await translateLyrics(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.fill.quaternary)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.frame(width: 200, height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(result.title)
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(result.artist)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var lyricsPreview: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Spanish Lyrics")
|
||||
.font(.headline)
|
||||
|
||||
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("English Translation")
|
||||
.font(.headline)
|
||||
if isTranslating {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
if translationError {
|
||||
Label("Translation unavailable. You can still save with Spanish only.",
|
||||
systemImage: "exclamationmark.triangle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else if translatedEN.isEmpty && isTranslating {
|
||||
Text("Translating...")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 16) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
saveSong()
|
||||
} label: {
|
||||
Text("Save")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Logic
|
||||
|
||||
private func translateLyrics(session: sending TranslationSession) async {
|
||||
await MainActor.run { isTranslating = true }
|
||||
let text = result.lyricsES
|
||||
let esLines = text.components(separatedBy: "\n")
|
||||
print("[LyricsTranslation] Spanish line count: \(esLines.count)")
|
||||
print("[LyricsTranslation] Spanish blank lines: \(esLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
||||
print("[LyricsTranslation] First 10 ES lines:")
|
||||
for (i, line) in esLines.prefix(10).enumerated() {
|
||||
print(" ES[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await session.translate(text)
|
||||
let enLines = response.targetText.components(separatedBy: "\n")
|
||||
print("[LyricsTranslation] English line count: \(enLines.count)")
|
||||
print("[LyricsTranslation] English blank lines: \(enLines.filter { $0.trimmingCharacters(in: .whitespaces).isEmpty }.count)")
|
||||
print("[LyricsTranslation] First 10 EN lines:")
|
||||
for (i, line) in enLines.prefix(10).enumerated() {
|
||||
print(" EN[\(i)]: \(line.isEmpty ? "(blank)" : line)")
|
||||
}
|
||||
// The Translation framework often inserts a blank line after every
|
||||
// translated line. Collapse consecutive blank lines into single blanks,
|
||||
// then trim leading/trailing blanks so the EN structure matches the ES.
|
||||
let normalized = Self.normalizeTranslationLineBreaks(
|
||||
translated: response.targetText,
|
||||
originalES: text
|
||||
)
|
||||
let normalizedLines = normalized.components(separatedBy: "\n")
|
||||
print("[LyricsTranslation] After normalization: EN lines \(enLines.count) → \(normalizedLines.count) (target: \(esLines.count))")
|
||||
|
||||
await MainActor.run { translatedEN = normalized }
|
||||
} catch {
|
||||
print("[LyricsTranslation] Translation error: \(error)")
|
||||
await MainActor.run { translationError = true }
|
||||
}
|
||||
await MainActor.run { isTranslating = false }
|
||||
}
|
||||
|
||||
/// Re-align translated line breaks to match the original Spanish structure.
|
||||
/// The Translation framework often inserts a blank line after every translated
|
||||
/// line. We strip all blanks from the EN output, then re-insert them at the
|
||||
/// same positions where the original ES text has blank lines.
|
||||
/// Re-align translated line breaks to match the original Spanish structure.
|
||||
private static func normalizeTranslationLineBreaks(translated: String, originalES: String) -> String {
|
||||
let esLines = originalES.components(separatedBy: "\n")
|
||||
let enContentLines = translated.components(separatedBy: "\n")
|
||||
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
||||
|
||||
// Walk ES lines. For each blank ES line, insert a blank in the result.
|
||||
// For each content ES line, consume the next EN content line.
|
||||
var result: [String] = []
|
||||
var enIndex = 0
|
||||
for esLine in esLines {
|
||||
if esLine.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
result.append("")
|
||||
} else if enIndex < enContentLines.count {
|
||||
result.append(enContentLines[enIndex])
|
||||
enIndex += 1
|
||||
} else {
|
||||
result.append("")
|
||||
}
|
||||
}
|
||||
|
||||
print("[LyricsNormalize] ES lines: \(esLines.count), EN content: \(enContentLines.count), result: \(result.count), EN consumed: \(enIndex)")
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func saveSong() {
|
||||
let song = SavedSong(
|
||||
title: result.title,
|
||||
artist: result.artist,
|
||||
lyricsES: result.lyricsES,
|
||||
lyricsEN: translatedEN,
|
||||
albumArtURL: result.albumArtURL ?? "",
|
||||
appleMusicURL: result.appleMusicURL ?? ""
|
||||
)
|
||||
cloudModelContext.insert(song)
|
||||
try? cloudModelContext.save()
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
107
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift
Normal file
107
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsLibraryView.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct LyricsLibraryView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var songs: [SavedSong] = []
|
||||
@State private var showingSearch = false
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if songs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Songs Yet",
|
||||
systemImage: "music.note.list",
|
||||
description: Text("Tap + to search for Spanish song lyrics.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(songs) { song in
|
||||
NavigationLink {
|
||||
LyricsReaderView(song: song)
|
||||
} label: {
|
||||
SongRowView(song: song)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteSongs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Lyrics")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingSearch = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSearch) {
|
||||
NavigationStack {
|
||||
LyricsSearchView {
|
||||
showingSearch = false
|
||||
loadSongs()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear(perform: loadSongs)
|
||||
}
|
||||
|
||||
private func loadSongs() {
|
||||
let descriptor = FetchDescriptor<SavedSong>(
|
||||
sortBy: [SortDescriptor(\SavedSong.savedDate, order: .reverse)]
|
||||
)
|
||||
songs = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func deleteSongs(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
cloudModelContext.delete(songs[index])
|
||||
}
|
||||
try? cloudModelContext.save()
|
||||
loadSongs()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Song Row
|
||||
|
||||
private struct SongRowView: View {
|
||||
let song: SavedSong
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Image(systemName: "music.note")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(song.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(song.artist)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
97
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
97
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsReaderView.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct LyricsReaderView: View {
|
||||
let song: SavedSong
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
headerSection
|
||||
lyricsBody
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer()
|
||||
}
|
||||
.navigationTitle(song.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 160, height: 160)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
Text(song.title)
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(song.artist)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
|
||||
Link(destination: url) {
|
||||
Label("Open in Apple Music", systemImage: "apple.logo")
|
||||
.font(.caption.weight(.medium))
|
||||
}
|
||||
.tint(.pink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lyrics Body
|
||||
|
||||
private var lyricsBody: some 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
|
||||
let es = index < spanishLines.count ? spanishLines[index] : ""
|
||||
let en = index < englishLines.count ? englishLines[index] : ""
|
||||
|
||||
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
en.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
// Blank line = section divider
|
||||
Spacer().frame(height: 20)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if !es.isEmpty {
|
||||
Text(es)
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
if !en.isEmpty {
|
||||
Text(en)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
179
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
179
Conjuga/Conjuga/Views/Practice/Lyrics/LyricsSearchView.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import Translation
|
||||
|
||||
struct LyricsSearchView: View {
|
||||
var onSaved: (() -> Void)?
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var artist = ""
|
||||
@State private var songTitle = ""
|
||||
@State private var isSearching = false
|
||||
@State private var searchResults: [LyricsSearchResult] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var selectedResult: LyricsSearchResult?
|
||||
|
||||
private let service = LyricsSearchService()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
searchSection
|
||||
if isSearching {
|
||||
loadingSection
|
||||
}
|
||||
if let errorMessage {
|
||||
errorSection(errorMessage)
|
||||
}
|
||||
if !searchResults.isEmpty {
|
||||
resultsSection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Search Lyrics")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(item: $selectedResult) { result in
|
||||
LyricsConfirmationView(result: result) {
|
||||
if let onSaved {
|
||||
onSaved()
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var searchSection: some View {
|
||||
Section {
|
||||
TextField("Artist", text: $artist)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Song Title", text: $songTitle)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
performSearch()
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
|
||||
isSearching)
|
||||
} header: {
|
||||
Text("Find a Song")
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingSection: some View {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Searching...")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func errorSection(_ message: String) -> some View {
|
||||
Section {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private var resultsSection: some View {
|
||||
Section {
|
||||
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
|
||||
Button {
|
||||
selectedResult = result
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.fill.quaternary)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
Image(systemName: "music.note")
|
||||
.font(.title2)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(result.artist)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
} header: {
|
||||
Text("Results")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func performSearch() {
|
||||
isSearching = true
|
||||
errorMessage = nil
|
||||
searchResults = []
|
||||
|
||||
Task {
|
||||
do {
|
||||
let results = try await service.searchLyrics(artist: artist, title: songTitle)
|
||||
searchResults = results
|
||||
if results.isEmpty {
|
||||
errorMessage = "No lyrics found. Try a different spelling."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Search failed. Check your connection."
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identifiable conformance for navigation
|
||||
|
||||
extension LyricsSearchResult: Equatable {
|
||||
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
|
||||
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricsSearchResult: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(title)
|
||||
hasher.combine(artist)
|
||||
}
|
||||
}
|
||||
|
||||
extension LyricsSearchResult: Identifiable {
|
||||
var id: String { "\(artist)—\(title)" }
|
||||
}
|
||||
@@ -98,12 +98,245 @@ struct PracticeView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Lyrics
|
||||
NavigationLink {
|
||||
LyricsLibraryView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "music.note.list")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.pink)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Lyrics")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read Spanish song lyrics with translations")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Conversation Practice
|
||||
NavigationLink {
|
||||
ChatLibraryView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.green)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Conversation")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Chat with AI in Spanish scenarios")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Listening Practice
|
||||
NavigationLink {
|
||||
ListeningView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "ear.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Listening")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Listen and type, or practice pronunciation")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Cloze Practice
|
||||
NavigationLink {
|
||||
ClozeView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "text.badge.minus")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.indigo)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Cloze Practice")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Fill in the missing word in context")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Stories
|
||||
NavigationLink {
|
||||
StoryLibraryView()
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "book.fill")
|
||||
.font(.title3)
|
||||
.frame(width: 36)
|
||||
.foregroundStyle(.teal)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Stories")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Read AI-generated Spanish stories")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.tint(.primary)
|
||||
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
|
||||
.padding(.horizontal)
|
||||
|
||||
// Quick Actions
|
||||
VStack(spacing: 12) {
|
||||
Text("Quick Actions")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// 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")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.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
|
||||
|
||||
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
134
Conjuga/Conjuga/Views/Practice/Stories/StoryLibraryView.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct StoryLibraryView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var stories: [Story] = []
|
||||
@State private var isGenerating = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if stories.isEmpty && !isGenerating {
|
||||
ContentUnavailableView(
|
||||
"No Stories Yet",
|
||||
systemImage: "book.closed",
|
||||
description: Text("Tap + to generate a Spanish story with AI.")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if isGenerating {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Generating story...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
ForEach(stories) { story in
|
||||
NavigationLink {
|
||||
StoryReaderView(story: story)
|
||||
} label: {
|
||||
StoryRowView(story: story)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteStories)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Stories")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
generateStory()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.disabled(isGenerating || !StoryGenerator.isAvailable)
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .init(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { errorMessage = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.onAppear(perform: loadStories)
|
||||
}
|
||||
|
||||
private func loadStories() {
|
||||
let descriptor = FetchDescriptor<Story>(
|
||||
sortBy: [SortDescriptor(\Story.createdDate, order: .reverse)]
|
||||
)
|
||||
stories = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
private func generateStory() {
|
||||
guard !isGenerating else { return }
|
||||
|
||||
guard StoryGenerator.isAvailable else {
|
||||
errorMessage = "Apple Intelligence is not available on this device. Stories require an iPhone 15 Pro or later with Apple Intelligence enabled."
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating = true
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
let level = progress.selectedLevel
|
||||
let tenses = progress.enabledTenseIDs
|
||||
|
||||
Task {
|
||||
do {
|
||||
let story = try await StoryGenerator.generate(level: level, tenses: tenses)
|
||||
cloudModelContext.insert(story)
|
||||
try? cloudModelContext.save()
|
||||
loadStories()
|
||||
} catch {
|
||||
errorMessage = "Failed to generate story: \(error.localizedDescription)"
|
||||
}
|
||||
isGenerating = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteStories(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
cloudModelContext.delete(stories[index])
|
||||
}
|
||||
try? cloudModelContext.save()
|
||||
loadStories()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Story Row
|
||||
|
||||
private struct StoryRowView: View {
|
||||
let story: Story
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(story.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(story.level.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.teal)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.teal.opacity(0.12), in: Capsule())
|
||||
|
||||
Text(story.createdDate.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
152
Conjuga/Conjuga/Views/Practice/Stories/StoryQuizView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct StoryQuizView: View {
|
||||
let story: Story
|
||||
|
||||
@State private var currentIndex = 0
|
||||
@State private var selectedOption: Int?
|
||||
@State private var correctCount = 0
|
||||
@State private var isFinished = false
|
||||
|
||||
private var questions: [QuizQuestion] { story.decodedQuestions }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
if isFinished {
|
||||
finishedView
|
||||
} else if let question = questions[safe: currentIndex] {
|
||||
questionView(question)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 600)
|
||||
.navigationTitle("Comprehension Quiz")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Question View
|
||||
|
||||
@ViewBuilder
|
||||
private func questionView(_ question: QuizQuestion) -> some View {
|
||||
VStack(spacing: 20) {
|
||||
// Progress
|
||||
Text("Question \(currentIndex + 1) of \(questions.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Question
|
||||
Text(question.question)
|
||||
.font(.title3.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Options
|
||||
VStack(spacing: 10) {
|
||||
ForEach(Array(question.options.enumerated()), id: \.offset) { index, option in
|
||||
Button {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = index
|
||||
if index == question.correctIndex {
|
||||
correctCount += 1
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option)
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
if let selected = selectedOption {
|
||||
if index == question.correctIndex {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else if index == selected {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(optionBackground(index: index, correct: question.correctIndex), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Next button
|
||||
if selectedOption != nil {
|
||||
Button {
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
selectedOption = nil
|
||||
} else {
|
||||
withAnimation { isFinished = true }
|
||||
}
|
||||
} label: {
|
||||
Text(currentIndex + 1 < questions.count ? "Next Question" : "See Results")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finished View
|
||||
|
||||
private var finishedView: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: correctCount == questions.count ? "star.fill" : "checkmark.circle")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(correctCount == questions.count ? .yellow : .teal)
|
||||
|
||||
Text("\(correctCount) / \(questions.count)")
|
||||
.font(.system(size: 48, weight: .bold).monospacedDigit())
|
||||
|
||||
Text(scoreMessage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var scoreMessage: String {
|
||||
switch correctCount {
|
||||
case questions.count: return "Perfect score!"
|
||||
case _ where correctCount > questions.count / 2: return "Good job! Keep reading."
|
||||
default: return "Try re-reading the story and quiz again."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func optionBackground(index: Int, correct: Int) -> some ShapeStyle {
|
||||
guard let selected = selectedOption else {
|
||||
return AnyShapeStyle(.fill.quaternary)
|
||||
}
|
||||
if index == correct {
|
||||
return AnyShapeStyle(.green.opacity(0.15))
|
||||
}
|
||||
if index == selected {
|
||||
return AnyShapeStyle(.red.opacity(0.15))
|
||||
}
|
||||
return AnyShapeStyle(.fill.quaternary)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
333
Conjuga/Conjuga/Views/Practice/Stories/StoryReaderView.swift
Normal file
@@ -0,0 +1,333 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import FoundationModels
|
||||
|
||||
struct StoryReaderView: View {
|
||||
let story: Story
|
||||
|
||||
@Environment(DictionaryService.self) private var dictionary
|
||||
@State private var selectedWord: WordAnnotation?
|
||||
@State private var showTranslation = false
|
||||
@State private var lookupCache: [String: WordAnnotation] = [:]
|
||||
|
||||
private var annotations: [WordAnnotation] { story.decodedAnnotations }
|
||||
private var annotationMap: [String: WordAnnotation] {
|
||||
Dictionary(annotations.map { (cleanWord($0.word), $0) }, uniquingKeysWith: { first, _ in first })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Title
|
||||
Text(story.title)
|
||||
.font(.title2.bold())
|
||||
|
||||
// Level badge
|
||||
Text(story.level.capitalized)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.teal)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.teal.opacity(0.12), in: Capsule())
|
||||
|
||||
Divider()
|
||||
|
||||
// Tappable Spanish text
|
||||
tappableText
|
||||
.padding()
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
// Translation toggle
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation { showTranslation.toggle() }
|
||||
} label: {
|
||||
Label(
|
||||
showTranslation ? "Hide Translation" : "Show Translation",
|
||||
systemImage: showTranslation ? "eye.slash" : "eye"
|
||||
)
|
||||
.font(.subheadline.weight(.medium))
|
||||
}
|
||||
.tint(.secondary)
|
||||
|
||||
if showTranslation {
|
||||
Text(story.bodyEN)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
|
||||
// Quiz button
|
||||
if !story.decodedQuestions.isEmpty {
|
||||
NavigationLink {
|
||||
StoryQuizView(story: story)
|
||||
} label: {
|
||||
Label("Take Comprehension Quiz", systemImage: "questionmark.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.teal)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.navigationTitle("Story")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $selectedWord) { word in
|
||||
WordDetailSheet(word: word)
|
||||
.presentationDetents([.height(200)])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tappable Text
|
||||
|
||||
private var tappableText: some View {
|
||||
let words = story.bodyES.components(separatedBy: " ")
|
||||
let map = annotationMap
|
||||
let cache = lookupCache
|
||||
let context = story.bodyES
|
||||
|
||||
return WrappingHStack(words: words) { word in
|
||||
WordButton(word: word, map: map, cache: cache) { ann in
|
||||
if ann.english.isEmpty {
|
||||
lookupWord(ann.word, inContext: context)
|
||||
} else {
|
||||
selectedWord = ann
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lookupWord(_ word: String, inContext sentence: String) {
|
||||
// Try offline 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
|
||||
selectedWord = annotation
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to on-device AI lookup
|
||||
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Looking up...", partOfSpeech: "")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let annotation = try await WordLookup.lookup(word: word, inContext: sentence)
|
||||
lookupCache[word] = annotation
|
||||
selectedWord = annotation
|
||||
} catch {
|
||||
selectedWord = WordAnnotation(word: word, baseForm: word, english: "Lookup unavailable", partOfSpeech: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanWord(_ word: String) -> String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Word Button
|
||||
|
||||
private struct WordButton: View {
|
||||
let word: String
|
||||
let map: [String: WordAnnotation]
|
||||
let cache: [String: WordAnnotation]
|
||||
let onTap: (WordAnnotation) -> Void
|
||||
|
||||
private var cleaned: String {
|
||||
word.lowercased()
|
||||
.trimmingCharacters(in: .punctuationCharacters)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private var resolvedAnnotation: WordAnnotation {
|
||||
map[cleaned] ?? cache[cleaned] ?? WordAnnotation(word: cleaned, baseForm: cleaned, english: "", partOfSpeech: "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
onTap(resolvedAnnotation)
|
||||
} label: {
|
||||
Text(word + " ")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
.underline(true, color: .teal.opacity(0.3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wrapping HStack
|
||||
|
||||
private struct WrappingHStack<Content: View>: View {
|
||||
let words: [String]
|
||||
let content: (String) -> Content
|
||||
|
||||
var body: some View {
|
||||
FlowLayout(spacing: 0) {
|
||||
ForEach(Array(words.enumerated()), id: \.offset) { _, word in
|
||||
content(word)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-Demand Word Lookup
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal file
180
Conjuga/Conjuga/Views/Practice/VocabReviewView.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct VocabReviewView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.modelContext) private var localContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var dueCards: [CourseReviewCard] = []
|
||||
@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("Vocab Review")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadDueCards)
|
||||
}
|
||||
|
||||
// MARK: - Card View
|
||||
|
||||
@ViewBuilder
|
||||
private func cardView(_ card: CourseReviewCard) -> some View {
|
||||
VStack(spacing: 24) {
|
||||
// Progress
|
||||
Text("\(currentIndex + 1) of \(dueCards.count)")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ProgressView(value: Double(currentIndex), total: Double(dueCards.count))
|
||||
.tint(.teal)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Front (Spanish)
|
||||
Text(card.front)
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isRevealed {
|
||||
// Back (English)
|
||||
Text(card.back)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
|
||||
Spacer()
|
||||
|
||||
// Rating buttons
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Finished View
|
||||
|
||||
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 vocabulary 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()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
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 }
|
||||
|
||||
let store = CourseReviewStore(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 descriptor = FetchDescriptor<CourseReviewCard>(
|
||||
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now },
|
||||
sortBy: [SortDescriptor(\CourseReviewCard.dueDate)]
|
||||
)
|
||||
dueCards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
static func dueCount(context: ModelContext) -> Int {
|
||||
let now = Date()
|
||||
let descriptor = FetchDescriptor<CourseReviewCard>(
|
||||
predicate: #Predicate<CourseReviewCard> { $0.dueDate <= now }
|
||||
)
|
||||
return (try? context.fetchCount(descriptor)) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal file
252
Conjuga/Conjuga/Views/Settings/FeatureReferenceView.swift
Normal file
@@ -0,0 +1,252 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FeatureReferenceView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Verb Conjugation Practice") {
|
||||
featureRow(
|
||||
icon: "rectangle.stack", color: .blue,
|
||||
title: "Flashcard / Typing / MC / Handwriting / Sentence Builder",
|
||||
details: [
|
||||
"Pulls from verb conjugation database (1,750 verbs)",
|
||||
"Filtered by your Level setting",
|
||||
"Filtered by your Enabled Tenses",
|
||||
"Respects Include Vosotros setting",
|
||||
"Due cards (SRS) shown first, then random",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "tablecells", color: .blue,
|
||||
title: "Full Table",
|
||||
details: [
|
||||
"Shows all 6 person forms for one verb + tense",
|
||||
"Random verb from your Level",
|
||||
"Random tense from your Enabled Tenses",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Quick Actions") {
|
||||
featureRow(
|
||||
icon: "star.fill", color: .orange,
|
||||
title: "Common Tenses",
|
||||
details: [
|
||||
"Restricts to 6 essential tenses: Present, Preterite, Imperfect, Future, Present Subjunctive, Imperative",
|
||||
"Still filtered by your Level",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "exclamationmark.triangle", color: .red,
|
||||
title: "Weak Verbs",
|
||||
details: [
|
||||
"Shows verbs you've struggled with (ease factor < 2.0)",
|
||||
"Only includes verbs you've reviewed at least once",
|
||||
"Weakest verbs shown first",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "wand.and.stars", color: .purple,
|
||||
title: "Irregularity Drills",
|
||||
details: [
|
||||
"Spelling Changes: c->qu, g->gu, z->c patterns",
|
||||
"Stem Changes: e->ie, o->ue, e->i patterns",
|
||||
"Unique Irregulars: ser, ir, haber, etc.",
|
||||
"Filtered by your Level and Enabled Tenses",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "rectangle.stack.fill", color: .teal,
|
||||
title: "Vocab Review",
|
||||
details: [
|
||||
"Reviews 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",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Practice Activities") {
|
||||
featureRow(
|
||||
icon: "bubble.left.and.bubble.right.fill", color: .green,
|
||||
title: "Conversation",
|
||||
details: [
|
||||
"AI chat partner using Apple Intelligence (on-device)",
|
||||
"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",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "ear.fill", color: .blue,
|
||||
title: "Listening",
|
||||
details: [
|
||||
"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",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
icon: "text.badge.minus", color: .indigo,
|
||||
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",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
]
|
||||
)
|
||||
|
||||
featureRow(
|
||||
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",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
Section("Course") {
|
||||
featureRow(
|
||||
icon: "list.clipboard", color: .orange,
|
||||
title: "Course Quizzes",
|
||||
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: "checkmark.seal", color: .orange,
|
||||
title: "Checkpoint Exams",
|
||||
details: [
|
||||
"Cumulative review across multiple weeks",
|
||||
"Cards sampled evenly across all covered weeks",
|
||||
"Choose 25, 50, or 100 questions",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
"7-day bar chart of daily study time",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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: "Daily Goal", affects: "Dashboard progress tracking only")
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Features Work")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
@ViewBuilder
|
||||
private func featureRow(icon: String, color: Color, title: String, details: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.body)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 24)
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(details, id: \.self) { detail in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 34)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingRow(name: String, affects: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(affects)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Reference") {
|
||||
NavigationLink("How Features Work") {
|
||||
FeatureReferenceView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0.0")
|
||||
}
|
||||
|
||||
@@ -38,15 +38,30 @@ struct VerbDetailView: View {
|
||||
ContentUnavailableView("No forms loaded", systemImage: "exclamationmark.triangle")
|
||||
} else {
|
||||
ForEach(formsForTense, id: \.personIndex) { form in
|
||||
HStack {
|
||||
Text(TenseInfo.persons[form.personIndex])
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 100, alignment: .leading)
|
||||
Text(form.form)
|
||||
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
|
||||
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(TenseInfo.persons[form.personIndex])
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 100, alignment: .leading)
|
||||
Text(form.form)
|
||||
.fontWeight(form.regularity != "ordinary" ? .semibold : .regular)
|
||||
.foregroundStyle(form.regularity != "ordinary" ? .red : .primary)
|
||||
}
|
||||
Text(EnglishConjugator.translate(
|
||||
english: verb.english,
|
||||
tenseId: selectedTense.id,
|
||||
personIndex: form.personIndex
|
||||
))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if formsForTense.contains(where: { $0.regularity != "ordinary" }) {
|
||||
Label("Red indicates an irregular conjugation", systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Conjugation")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8486,7 +8486,7 @@
|
||||
"cards": [
|
||||
{
|
||||
"front": "tener",
|
||||
"back": "tengo",
|
||||
"back": "tengo — I have",
|
||||
"examples": [
|
||||
{
|
||||
"es": "The Spanish Verb \"Tener\"",
|
||||
@@ -8504,7 +8504,7 @@
|
||||
},
|
||||
{
|
||||
"front": "venir",
|
||||
"back": "vengo",
|
||||
"back": "vengo — I come",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Lo mejor está por venir.",
|
||||
@@ -8522,7 +8522,7 @@
|
||||
},
|
||||
{
|
||||
"front": "hacer",
|
||||
"back": "hago",
|
||||
"back": "hago — I do, I make",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Expressions with \"Hacer\"",
|
||||
@@ -8540,7 +8540,7 @@
|
||||
},
|
||||
{
|
||||
"front": "salir",
|
||||
"back": "salgo",
|
||||
"back": "salgo — I go out",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Usa el ascensor para salir.",
|
||||
@@ -8558,7 +8558,7 @@
|
||||
},
|
||||
{
|
||||
"front": "caer",
|
||||
"back": "caigo",
|
||||
"back": "caigo — I fall",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||
@@ -8576,7 +8576,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traer",
|
||||
"back": "traigo",
|
||||
"back": "traigo — I bring",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||
@@ -8594,7 +8594,7 @@
|
||||
},
|
||||
{
|
||||
"front": "poner",
|
||||
"back": "pongo",
|
||||
"back": "pongo — I put",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||
@@ -8612,7 +8612,7 @@
|
||||
},
|
||||
{
|
||||
"front": "decir",
|
||||
"back": "digo",
|
||||
"back": "digo — I say",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Jura decir la verdad?",
|
||||
@@ -8630,7 +8630,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conducir",
|
||||
"back": "conduzco",
|
||||
"back": "conduzco — I lead, I drive",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conducir(kohn-doo-seer)",
|
||||
@@ -8648,7 +8648,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conocer",
|
||||
"back": "conozco",
|
||||
"back": "conozco — I know, I meet",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conocer(koh-noh-sehr)",
|
||||
@@ -8666,7 +8666,7 @@
|
||||
},
|
||||
{
|
||||
"front": "agradecer",
|
||||
"back": "agradezco",
|
||||
"back": "agradezco — I thank",
|
||||
"examples": [
|
||||
{
|
||||
"es": "agradecer(ah-grah-deh-sehr)",
|
||||
@@ -8684,7 +8684,7 @@
|
||||
},
|
||||
{
|
||||
"front": "parecer",
|
||||
"back": "parezco",
|
||||
"back": "parezco — I seem",
|
||||
"examples": [
|
||||
{
|
||||
"es": "parecer(pah-reh-sehr)",
|
||||
@@ -8702,7 +8702,7 @@
|
||||
},
|
||||
{
|
||||
"front": "crecer",
|
||||
"back": "crezco",
|
||||
"back": "crezco — I grow",
|
||||
"examples": [
|
||||
{
|
||||
"es": "crecer(kreh-sehr)",
|
||||
@@ -8720,7 +8720,7 @@
|
||||
},
|
||||
{
|
||||
"front": "producir",
|
||||
"back": "produzco",
|
||||
"back": "produzco — I produce",
|
||||
"examples": [
|
||||
{
|
||||
"es": "producir(proh-doo-seer)",
|
||||
@@ -8738,7 +8738,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traducir",
|
||||
"back": "traduzco",
|
||||
"back": "traduzco — I translate",
|
||||
"examples": [
|
||||
{
|
||||
"es": "traducir(trah-doo-seer)",
|
||||
@@ -8756,7 +8756,7 @@
|
||||
},
|
||||
{
|
||||
"front": "establecer",
|
||||
"back": "establezco",
|
||||
"back": "establezco — I establish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||
@@ -8774,7 +8774,7 @@
|
||||
},
|
||||
{
|
||||
"front": "elejir",
|
||||
"back": "elijo",
|
||||
"back": "elijo — I choose",
|
||||
"examples": [
|
||||
{
|
||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||
@@ -8792,7 +8792,7 @@
|
||||
},
|
||||
{
|
||||
"front": "proteger",
|
||||
"back": "protejo",
|
||||
"back": "protejo — I protect",
|
||||
"examples": [
|
||||
{
|
||||
"es": "proteger(proh-teh-hehr)",
|
||||
@@ -8810,7 +8810,7 @@
|
||||
},
|
||||
{
|
||||
"front": "dirigir",
|
||||
"back": "dirijo",
|
||||
"back": "dirijo — I manage, I direct",
|
||||
"examples": [
|
||||
{
|
||||
"es": "dirigir(dee-ree-heer)",
|
||||
@@ -8828,7 +8828,7 @@
|
||||
},
|
||||
{
|
||||
"front": "fingir",
|
||||
"back": "finjo",
|
||||
"back": "finjo — I pretend, I feign",
|
||||
"examples": [
|
||||
{
|
||||
"es": "fingir(feen-heer)",
|
||||
@@ -8846,7 +8846,7 @@
|
||||
},
|
||||
{
|
||||
"front": "sumergir",
|
||||
"back": "sumerjo",
|
||||
"back": "sumerjo — I submerge",
|
||||
"examples": [
|
||||
{
|
||||
"es": "sumergir(soo-mehr-heer)",
|
||||
@@ -8864,7 +8864,7 @@
|
||||
},
|
||||
{
|
||||
"front": "ver",
|
||||
"back": "veo",
|
||||
"back": "veo — I see",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Quieres ver mi carro nuevo?",
|
||||
@@ -8882,7 +8882,7 @@
|
||||
},
|
||||
{
|
||||
"front": "saber",
|
||||
"back": "sé",
|
||||
"back": "sé — I know, I taste",
|
||||
"examples": [
|
||||
{
|
||||
"es": "El saber popular se basa en creencias.",
|
||||
@@ -8900,7 +8900,7 @@
|
||||
},
|
||||
{
|
||||
"front": "distinguir",
|
||||
"back": "distingo",
|
||||
"back": "distingo — I distinguish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "distinguir(dees-teeng-geer)",
|
||||
@@ -8918,7 +8918,7 @@
|
||||
},
|
||||
{
|
||||
"front": "oír",
|
||||
"back": "oigo",
|
||||
"back": "oigo — I hear",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||
@@ -8943,7 +8943,7 @@
|
||||
"cards": [
|
||||
{
|
||||
"front": "tener",
|
||||
"back": "tengo",
|
||||
"back": "tengo — I have",
|
||||
"examples": [
|
||||
{
|
||||
"es": "The Spanish Verb \"Tener\"",
|
||||
@@ -8961,7 +8961,7 @@
|
||||
},
|
||||
{
|
||||
"front": "venir",
|
||||
"back": "vengo",
|
||||
"back": "vengo — I come",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Lo mejor está por venir.",
|
||||
@@ -8979,7 +8979,7 @@
|
||||
},
|
||||
{
|
||||
"front": "hacer",
|
||||
"back": "hago",
|
||||
"back": "hago — I do, I make",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Expressions with \"Hacer\"",
|
||||
@@ -8997,7 +8997,7 @@
|
||||
},
|
||||
{
|
||||
"front": "salir",
|
||||
"back": "salgo",
|
||||
"back": "salgo — I go out",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Usa el ascensor para salir.",
|
||||
@@ -9015,7 +9015,7 @@
|
||||
},
|
||||
{
|
||||
"front": "caer",
|
||||
"back": "caigo",
|
||||
"back": "caigo — I fall",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¡Acabo de caer en que tengo una cita con el dentista en diez minutos!",
|
||||
@@ -9033,7 +9033,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traer",
|
||||
"back": "traigo",
|
||||
"back": "traigo — I bring",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Esto puede traer cambios profundos en bares, discotecas y restaurantes.",
|
||||
@@ -9051,7 +9051,7 @@
|
||||
},
|
||||
{
|
||||
"front": "poner",
|
||||
"back": "pongo",
|
||||
"back": "pongo — I put",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Después de bañar a la bebé, hay que ponerle ropa limpia.",
|
||||
@@ -9069,7 +9069,7 @@
|
||||
},
|
||||
{
|
||||
"front": "decir",
|
||||
"back": "digo",
|
||||
"back": "digo — I say",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Jura decir la verdad?",
|
||||
@@ -9087,7 +9087,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conducir",
|
||||
"back": "conduzco",
|
||||
"back": "conduzco — I lead, I drive",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conducir(kohn-doo-seer)",
|
||||
@@ -9105,7 +9105,7 @@
|
||||
},
|
||||
{
|
||||
"front": "conocer",
|
||||
"back": "conozco",
|
||||
"back": "conozco — I know, I meet",
|
||||
"examples": [
|
||||
{
|
||||
"es": "conocer(koh-noh-sehr)",
|
||||
@@ -9123,7 +9123,7 @@
|
||||
},
|
||||
{
|
||||
"front": "agradecer",
|
||||
"back": "agradezco",
|
||||
"back": "agradezco — I thank",
|
||||
"examples": [
|
||||
{
|
||||
"es": "agradecer(ah-grah-deh-sehr)",
|
||||
@@ -9141,7 +9141,7 @@
|
||||
},
|
||||
{
|
||||
"front": "parecer",
|
||||
"back": "parezco",
|
||||
"back": "parezco — I seem",
|
||||
"examples": [
|
||||
{
|
||||
"es": "parecer(pah-reh-sehr)",
|
||||
@@ -9159,7 +9159,7 @@
|
||||
},
|
||||
{
|
||||
"front": "crecer",
|
||||
"back": "crezco",
|
||||
"back": "crezco — I grow",
|
||||
"examples": [
|
||||
{
|
||||
"es": "crecer(kreh-sehr)",
|
||||
@@ -9177,7 +9177,7 @@
|
||||
},
|
||||
{
|
||||
"front": "producir",
|
||||
"back": "produzco",
|
||||
"back": "produzco — I produce",
|
||||
"examples": [
|
||||
{
|
||||
"es": "producir(proh-doo-seer)",
|
||||
@@ -9195,7 +9195,7 @@
|
||||
},
|
||||
{
|
||||
"front": "traducir",
|
||||
"back": "traduzco",
|
||||
"back": "traduzco — I translate",
|
||||
"examples": [
|
||||
{
|
||||
"es": "traducir(trah-doo-seer)",
|
||||
@@ -9213,7 +9213,7 @@
|
||||
},
|
||||
{
|
||||
"front": "establecer",
|
||||
"back": "establezco",
|
||||
"back": "establezco — I establish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "establecer(ehs-tah-bleh-sehr)",
|
||||
@@ -9231,7 +9231,7 @@
|
||||
},
|
||||
{
|
||||
"front": "elejir",
|
||||
"back": "elijo",
|
||||
"back": "elijo — I choose",
|
||||
"examples": [
|
||||
{
|
||||
"es": "En realidad cada persona será libre de elejir su comida.",
|
||||
@@ -9249,7 +9249,7 @@
|
||||
},
|
||||
{
|
||||
"front": "proteger",
|
||||
"back": "protejo",
|
||||
"back": "protejo — I protect",
|
||||
"examples": [
|
||||
{
|
||||
"es": "proteger(proh-teh-hehr)",
|
||||
@@ -9267,7 +9267,7 @@
|
||||
},
|
||||
{
|
||||
"front": "dirigir",
|
||||
"back": "dirijo",
|
||||
"back": "dirijo — I manage, I direct",
|
||||
"examples": [
|
||||
{
|
||||
"es": "dirigir(dee-ree-heer)",
|
||||
@@ -9285,7 +9285,7 @@
|
||||
},
|
||||
{
|
||||
"front": "fingir",
|
||||
"back": "finjo",
|
||||
"back": "finjo — I pretend, I feign",
|
||||
"examples": [
|
||||
{
|
||||
"es": "fingir(feen-heer)",
|
||||
@@ -9303,7 +9303,7 @@
|
||||
},
|
||||
{
|
||||
"front": "sumergir",
|
||||
"back": "sumerjo",
|
||||
"back": "sumerjo — I submerge",
|
||||
"examples": [
|
||||
{
|
||||
"es": "sumergir(soo-mehr-heer)",
|
||||
@@ -9321,7 +9321,7 @@
|
||||
},
|
||||
{
|
||||
"front": "ver",
|
||||
"back": "veo",
|
||||
"back": "veo — I see",
|
||||
"examples": [
|
||||
{
|
||||
"es": "¿Quieres ver mi carro nuevo?",
|
||||
@@ -9339,7 +9339,7 @@
|
||||
},
|
||||
{
|
||||
"front": "saber",
|
||||
"back": "sé",
|
||||
"back": "sé — I know, I taste",
|
||||
"examples": [
|
||||
{
|
||||
"es": "El saber popular se basa en creencias.",
|
||||
@@ -9357,7 +9357,7 @@
|
||||
},
|
||||
{
|
||||
"front": "distinguir",
|
||||
"back": "distingo",
|
||||
"back": "distingo — I distinguish",
|
||||
"examples": [
|
||||
{
|
||||
"es": "distinguir(dees-teeng-geer)",
|
||||
@@ -9375,7 +9375,7 @@
|
||||
},
|
||||
{
|
||||
"front": "oír",
|
||||
"back": "oigo",
|
||||
"back": "oigo — I hear",
|
||||
"examples": [
|
||||
{
|
||||
"es": "Me quejé a mucha gente, pero nadie quiso oírme.",
|
||||
|
||||
@@ -3,11 +3,15 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SharedModels",
|
||||
platforms: [.iOS(.v18)],
|
||||
platforms: [.iOS(.v18), .macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "SharedModels", targets: ["SharedModels"]),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "SharedModels"),
|
||||
.testTarget(
|
||||
name: "SharedModelsTests",
|
||||
dependencies: ["SharedModels"]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal file
48
Conjuga/SharedModels/Sources/SharedModels/Conversation.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class Conversation {
|
||||
public var id: String = ""
|
||||
public var scenario: String = ""
|
||||
public var level: String = ""
|
||||
public var messages: String = "[]"
|
||||
public var createdDate: Date = Date()
|
||||
|
||||
public init(scenario: String, level: String) {
|
||||
self.id = UUID().uuidString
|
||||
self.scenario = scenario
|
||||
self.level = level
|
||||
self.messages = "[]"
|
||||
self.createdDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessage: Codable, Identifiable, Hashable {
|
||||
public var id: String
|
||||
public let role: String // "assistant" or "user"
|
||||
public let content: String
|
||||
public let correction: String?
|
||||
|
||||
public init(role: String, content: String, correction: String? = nil) {
|
||||
self.id = UUID().uuidString
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.correction = correction
|
||||
}
|
||||
}
|
||||
|
||||
extension Conversation {
|
||||
public var decodedMessages: [ChatMessage] {
|
||||
guard let data = messages.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([ChatMessage].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func appendMessage(_ message: ChatMessage) {
|
||||
var msgs = decodedMessages
|
||||
msgs.append(message)
|
||||
if let data = try? JSONEncoder().encode(msgs), let str = String(data: data, encoding: .utf8) {
|
||||
messages = str
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
|
||||
/// Constructs approximate English translations for Spanish conjugation forms
|
||||
/// by combining the verb's English infinitive with person pronouns and tense auxiliaries.
|
||||
///
|
||||
/// Not perfect for irregular English verbs (go→went, be→was) but covers the
|
||||
/// common patterns well enough for a learning context.
|
||||
public enum EnglishConjugator {
|
||||
|
||||
public static func translate(english: String, tenseId: String, personIndex: Int) -> String {
|
||||
let base = english.hasPrefix("to ") ? String(english.dropFirst(3)).trimmingCharacters(in: .whitespaces) : english
|
||||
guard !base.isEmpty else { return "" }
|
||||
|
||||
let pronoun = pronoun(for: personIndex)
|
||||
|
||||
switch tenseId {
|
||||
// Indicative
|
||||
case "ind_presente":
|
||||
return "\(pronoun) \(presentForm(base, personIndex: personIndex))"
|
||||
case "ind_preterito":
|
||||
return "\(pronoun) \(pastForm(base))"
|
||||
case "ind_imperfecto":
|
||||
return "\(pronoun) used to \(base)"
|
||||
case "ind_futuro":
|
||||
return "\(pronoun) will \(base)"
|
||||
case "ind_perfecto":
|
||||
return "\(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
|
||||
case "ind_pluscuamperfecto":
|
||||
return "\(pronoun) had \(pastParticiple(base))"
|
||||
case "ind_futuro_perfecto":
|
||||
return "\(pronoun) will have \(pastParticiple(base))"
|
||||
case "ind_preterito_anterior":
|
||||
return "\(pronoun) had \(pastParticiple(base))"
|
||||
|
||||
// Conditional
|
||||
case "cond_presente":
|
||||
return "\(pronoun) would \(base)"
|
||||
case "cond_perfecto":
|
||||
return "\(pronoun) would have \(pastParticiple(base))"
|
||||
|
||||
// Subjunctive
|
||||
case "subj_presente":
|
||||
return "that \(pronoun) \(base)"
|
||||
case "subj_imperfecto_1", "subj_imperfecto_2":
|
||||
return "that \(pronoun) would \(base)"
|
||||
case "subj_perfecto":
|
||||
return "that \(pronoun) \(haveForm(personIndex)) \(pastParticiple(base))"
|
||||
case "subj_pluscuamperfecto_1", "subj_pluscuamperfecto_2":
|
||||
return "that \(pronoun) had \(pastParticiple(base))"
|
||||
case "subj_futuro":
|
||||
return "that \(pronoun) will \(base)"
|
||||
case "subj_futuro_perfecto":
|
||||
return "that \(pronoun) will have \(pastParticiple(base))"
|
||||
|
||||
// Imperative
|
||||
case "imp_afirmativo":
|
||||
return imperativeAffirmative(base, personIndex: personIndex)
|
||||
case "imp_negativo":
|
||||
return "don't \(base)"
|
||||
|
||||
default:
|
||||
return "\(pronoun) \(base)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pronouns
|
||||
|
||||
private static func pronoun(for personIndex: Int) -> String {
|
||||
switch personIndex {
|
||||
case 0: "I"
|
||||
case 1: "you"
|
||||
case 2: "he/she"
|
||||
case 3: "we"
|
||||
case 4: "you all"
|
||||
case 5: "they"
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Present tense
|
||||
|
||||
private static func presentForm(_ base: String, personIndex: Int) -> String {
|
||||
// 3rd person singular adds -s/-es
|
||||
guard personIndex == 2 else { return base }
|
||||
let words = base.split(separator: " ")
|
||||
guard let first = words.first else { return base }
|
||||
let verb = String(first)
|
||||
let rest = words.dropFirst().joined(separator: " ")
|
||||
let conjugated = addThirdPersonS(verb)
|
||||
return rest.isEmpty ? conjugated : "\(conjugated) \(rest)"
|
||||
}
|
||||
|
||||
private static func addThirdPersonS(_ verb: String) -> String {
|
||||
if verb == "have" { return "has" }
|
||||
if verb == "be" { return "is" }
|
||||
if verb == "do" { return "does" }
|
||||
if verb == "go" { return "goes" }
|
||||
if verb.hasSuffix("sh") || verb.hasSuffix("ch") || verb.hasSuffix("x") ||
|
||||
verb.hasSuffix("s") || verb.hasSuffix("z") || verb.hasSuffix("o") {
|
||||
return verb + "es"
|
||||
}
|
||||
if verb.hasSuffix("y") && verb.count > 1 {
|
||||
let yIndex = verb.index(before: verb.endIndex)
|
||||
let beforeY = verb[verb.index(before: yIndex)]
|
||||
if !"aeiou".contains(beforeY) {
|
||||
return String(verb.dropLast()) + "ies"
|
||||
}
|
||||
}
|
||||
return verb + "s"
|
||||
}
|
||||
|
||||
// MARK: - Past tense
|
||||
|
||||
private static func pastForm(_ base: String) -> String {
|
||||
// Check common irregulars first
|
||||
let words = base.split(separator: " ")
|
||||
guard let first = words.first else { return base }
|
||||
let verb = String(first).lowercased()
|
||||
let rest = words.dropFirst().joined(separator: " ")
|
||||
|
||||
let irregular: String? = commonIrregularPast[verb]
|
||||
let past = irregular ?? addEd(String(first))
|
||||
return rest.isEmpty ? past : "\(past) \(rest)"
|
||||
}
|
||||
|
||||
private static func addEd(_ verb: String) -> String {
|
||||
if verb.hasSuffix("e") { return verb + "d" }
|
||||
if verb.hasSuffix("y") && verb.count > 1 {
|
||||
let beforeY = verb[verb.index(before: verb.endIndex)]
|
||||
if !"aeiou".contains(beforeY) {
|
||||
return String(verb.dropLast()) + "ied"
|
||||
}
|
||||
}
|
||||
return verb + "ed"
|
||||
}
|
||||
|
||||
// MARK: - Past participle
|
||||
|
||||
private static func pastParticiple(_ base: String) -> String {
|
||||
let words = base.split(separator: " ")
|
||||
guard let first = words.first else { return base }
|
||||
let verb = String(first).lowercased()
|
||||
let rest = words.dropFirst().joined(separator: " ")
|
||||
|
||||
let irregular: String? = commonIrregularParticiple[verb]
|
||||
let participle = irregular ?? addEd(String(first))
|
||||
return rest.isEmpty ? participle : "\(participle) \(rest)"
|
||||
}
|
||||
|
||||
// MARK: - Gerund
|
||||
|
||||
private static func gerund(_ base: String) -> String {
|
||||
let words = base.split(separator: " ")
|
||||
guard let first = words.first else { return base }
|
||||
let verb = String(first)
|
||||
let rest = words.dropFirst().joined(separator: " ")
|
||||
|
||||
let ing: String
|
||||
if verb.hasSuffix("ie") {
|
||||
ing = String(verb.dropLast(2)) + "ying"
|
||||
} else if verb.hasSuffix("e") && !verb.hasSuffix("ee") {
|
||||
ing = String(verb.dropLast()) + "ing"
|
||||
} else {
|
||||
ing = verb + "ing"
|
||||
}
|
||||
return rest.isEmpty ? ing : "\(ing) \(rest)"
|
||||
}
|
||||
|
||||
// MARK: - Auxiliaries
|
||||
|
||||
private static func haveForm(_ personIndex: Int) -> String {
|
||||
personIndex == 2 ? "has" : "have"
|
||||
}
|
||||
|
||||
private static func beForm(_ personIndex: Int) -> String {
|
||||
switch personIndex {
|
||||
case 0: "am"
|
||||
case 2: "is"
|
||||
default: "are"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Imperative
|
||||
|
||||
private static func imperativeAffirmative(_ base: String, personIndex: Int) -> String {
|
||||
switch personIndex {
|
||||
case 1, 4: "\(base)!"
|
||||
case 3: "let's \(base)!"
|
||||
default: "\(base)!"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Irregular lookups (most common English irregulars)
|
||||
|
||||
private static let commonIrregularPast: [String: String] = [
|
||||
"be": "was/were", "have": "had", "do": "did", "go": "went",
|
||||
"say": "said", "get": "got", "make": "made", "know": "knew",
|
||||
"think": "thought", "take": "took", "come": "came", "see": "saw",
|
||||
"want": "wanted", "give": "gave", "tell": "told", "find": "found",
|
||||
"put": "put", "leave": "left", "bring": "brought", "begin": "began",
|
||||
"keep": "kept", "hold": "held", "write": "wrote", "stand": "stood",
|
||||
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
|
||||
"meet": "met", "run": "ran", "pay": "paid", "sit": "sat",
|
||||
"speak": "spoke", "read": "read", "grow": "grew", "lose": "lost",
|
||||
"fall": "fell", "feel": "felt", "cut": "cut", "sell": "sold",
|
||||
"drive": "drove", "buy": "bought", "wear": "wore", "choose": "chose",
|
||||
"sleep": "slept", "eat": "ate", "drink": "drank", "swim": "swam",
|
||||
"fly": "flew", "break": "broke", "sing": "sang", "catch": "caught",
|
||||
"send": "sent", "build": "built", "spend": "spent", "win": "won",
|
||||
"fight": "fought", "throw": "threw", "teach": "taught", "lead": "led",
|
||||
"understand": "understood", "draw": "drew", "ride": "rode",
|
||||
"rise": "rose", "shake": "shook", "forget": "forgot",
|
||||
"shoot": "shot", "wake": "woke", "bite": "bit", "hide": "hid",
|
||||
"lay": "laid", "lie": "lay", "strike": "struck", "hang": "hung",
|
||||
"blow": "blew", "dig": "dug", "feed": "fed", "forgive": "forgave",
|
||||
"freeze": "froze", "hurt": "hurt", "light": "lit", "shut": "shut",
|
||||
"steal": "stole", "stick": "stuck", "sweep": "swept",
|
||||
"swing": "swung", "tear": "tore",
|
||||
]
|
||||
|
||||
private static let commonIrregularParticiple: [String: String] = [
|
||||
"be": "been", "have": "had", "do": "done", "go": "gone",
|
||||
"say": "said", "get": "gotten", "make": "made", "know": "known",
|
||||
"think": "thought", "take": "taken", "come": "come", "see": "seen",
|
||||
"give": "given", "tell": "told", "find": "found",
|
||||
"put": "put", "leave": "left", "bring": "brought", "begin": "begun",
|
||||
"keep": "kept", "hold": "held", "write": "written", "stand": "stood",
|
||||
"hear": "heard", "let": "let", "mean": "meant", "set": "set",
|
||||
"meet": "met", "run": "run", "pay": "paid", "sit": "sat",
|
||||
"speak": "spoken", "read": "read", "grow": "grown", "lose": "lost",
|
||||
"fall": "fallen", "feel": "felt", "cut": "cut", "sell": "sold",
|
||||
"drive": "driven", "buy": "bought", "wear": "worn", "choose": "chosen",
|
||||
"sleep": "slept", "eat": "eaten", "drink": "drunk", "swim": "swum",
|
||||
"fly": "flown", "break": "broken", "sing": "sung", "catch": "caught",
|
||||
"send": "sent", "build": "built", "spend": "spent", "win": "won",
|
||||
"fight": "fought", "throw": "thrown", "teach": "taught", "lead": "led",
|
||||
"understand": "understood", "draw": "drawn", "ride": "ridden",
|
||||
"rise": "risen", "shake": "shaken", "forget": "forgotten",
|
||||
"shoot": "shot", "wake": "woken", "bite": "bitten", "hide": "hidden",
|
||||
"lay": "laid", "lie": "lain", "strike": "struck", "hang": "hung",
|
||||
"blow": "blown", "dig": "dug", "feed": "fed", "forgive": "forgiven",
|
||||
"freeze": "frozen", "hurt": "hurt", "light": "lit", "shut": "shut",
|
||||
"steal": "stolen", "stick": "stuck", "sweep": "swept",
|
||||
"swing": "swung", "tear": "torn",
|
||||
]
|
||||
}
|
||||
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
25
Conjuga/SharedModels/Sources/SharedModels/SavedSong.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class SavedSong {
|
||||
public var id: String = ""
|
||||
public var title: String = ""
|
||||
public var artist: String = ""
|
||||
public var lyricsES: String = ""
|
||||
public var lyricsEN: String = ""
|
||||
public var albumArtURL: String = ""
|
||||
public var appleMusicURL: String = ""
|
||||
public var savedDate: Date = Date()
|
||||
|
||||
public init(title: String, artist: String, lyricsES: String, lyricsEN: String, albumArtURL: String = "", appleMusicURL: String = "") {
|
||||
self.id = UUID().uuidString
|
||||
self.title = title
|
||||
self.artist = artist
|
||||
self.lyricsES = lyricsES
|
||||
self.lyricsEN = lyricsEN
|
||||
self.albumArtURL = albumArtURL
|
||||
self.appleMusicURL = appleMusicURL
|
||||
self.savedDate = Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure logic for the Complete the Sentence quiz type.
|
||||
///
|
||||
/// Given a `VocabCard` with example sentences, the engine determines whether a
|
||||
/// blankable question can be produced and builds the `Question` used by the UI.
|
||||
/// No SwiftUI dependency — exists in SharedModels so it can be unit-tested in
|
||||
/// isolation and reused by other surfaces.
|
||||
public struct SentenceQuizEngine {
|
||||
|
||||
public struct Question: Equatable, Sendable {
|
||||
public let sentenceES: String
|
||||
public let sentenceEN: String
|
||||
/// The exact substring in `sentenceES` that was blanked (original casing preserved).
|
||||
public let blankWord: String
|
||||
/// `sentenceES` with `blankWord` replaced by a visible blank marker.
|
||||
public let displayTemplate: String
|
||||
/// Index into the card's `examplesES` that this question was built from.
|
||||
public let exampleIndex: Int
|
||||
|
||||
public init(sentenceES: String, sentenceEN: String, blankWord: String, displayTemplate: String, exampleIndex: Int) {
|
||||
self.sentenceES = sentenceES
|
||||
self.sentenceEN = sentenceEN
|
||||
self.blankWord = blankWord
|
||||
self.displayTemplate = displayTemplate
|
||||
self.exampleIndex = exampleIndex
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker string substituted into `displayTemplate` in place of the blank word.
|
||||
public static let blankMarker = "_____"
|
||||
|
||||
/// True when the card has at least one example sentence where a blank can be determined,
|
||||
/// either via a stored `examplesBlanks` entry or by substring-matching `card.front`.
|
||||
public static func hasValidSentence(for card: VocabCard) -> Bool {
|
||||
guard !card.examplesES.isEmpty else { return false }
|
||||
for i in card.examplesES.indices {
|
||||
if isBlankResolvable(card: card, exampleIndex: i) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns the set of example indices that can produce a valid blank.
|
||||
public static func resolvableIndices(for card: VocabCard) -> [Int] {
|
||||
card.examplesES.indices.filter { isBlankResolvable(card: card, exampleIndex: $0) }
|
||||
}
|
||||
|
||||
/// Builds a question from the card by picking a random resolvable example.
|
||||
/// Returns nil if no example qualifies.
|
||||
public static func buildQuestion(for card: VocabCard) -> Question? {
|
||||
let candidates = resolvableIndices(for: card)
|
||||
guard let pick = candidates.randomElement() else { return nil }
|
||||
return buildQuestion(for: card, exampleIndex: pick)
|
||||
}
|
||||
|
||||
/// Deterministic variant — builds a question from a specific example index.
|
||||
/// Returns nil if that example doesn't contain a resolvable blank.
|
||||
public static func buildQuestion(for card: VocabCard, exampleIndex: Int) -> Question? {
|
||||
guard exampleIndex >= 0, exampleIndex < card.examplesES.count else { return nil }
|
||||
let sentence = card.examplesES[exampleIndex]
|
||||
guard sentence.split(separator: " ").count >= minimumWordCount else { return nil }
|
||||
let sentenceEN = exampleIndex < card.examplesEN.count ? card.examplesEN[exampleIndex] : ""
|
||||
|
||||
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
|
||||
|
||||
// Prefer the stored blank if present and actually appears in the sentence.
|
||||
if !storedBlank.isEmpty, let range = sentence.range(of: storedBlank, options: .caseInsensitive) {
|
||||
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
|
||||
}
|
||||
|
||||
// Fall back to substring match on card.front.
|
||||
if !card.front.isEmpty, let range = sentence.range(of: card.front, options: .caseInsensitive) {
|
||||
return makeQuestion(sentence: sentence, sentenceEN: sentenceEN, range: range, exampleIndex: exampleIndex)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Minimum number of whitespace-separated tokens for an example to count as
|
||||
/// a real sentence (filters out phonetic glosses like "discutir(dees-koo-teer)").
|
||||
public static let minimumWordCount = 4
|
||||
|
||||
private static func isBlankResolvable(card: VocabCard, exampleIndex: Int) -> Bool {
|
||||
let sentence = card.examplesES[exampleIndex]
|
||||
guard sentence.split(separator: " ").count >= minimumWordCount else { return false }
|
||||
let storedBlank = exampleIndex < card.examplesBlanks.count ? card.examplesBlanks[exampleIndex] : ""
|
||||
if !storedBlank.isEmpty, sentence.range(of: storedBlank, options: .caseInsensitive) != nil {
|
||||
return true
|
||||
}
|
||||
if !card.front.isEmpty, sentence.range(of: card.front, options: .caseInsensitive) != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func makeQuestion(sentence: String, sentenceEN: String, range: Range<String.Index>, exampleIndex: Int) -> Question {
|
||||
let blankWord = String(sentence[range])
|
||||
var template = sentence
|
||||
template.replaceSubrange(range, with: blankMarker)
|
||||
return Question(
|
||||
sentenceES: sentence,
|
||||
sentenceEN: sentenceEN,
|
||||
blankWord: blankWord,
|
||||
displayTemplate: template,
|
||||
exampleIndex: exampleIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
67
Conjuga/SharedModels/Sources/SharedModels/Story.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
public final class Story {
|
||||
public var id: String = ""
|
||||
public var title: String = ""
|
||||
public var bodyES: String = ""
|
||||
public var bodyEN: String = ""
|
||||
public var level: String = ""
|
||||
public var wordAnnotations: String = "[]"
|
||||
public var quizQuestions: String = "[]"
|
||||
public var createdDate: Date = Date()
|
||||
|
||||
public init(title: String, bodyES: String, bodyEN: String, level: String, wordAnnotations: String, quizQuestions: String) {
|
||||
self.id = UUID().uuidString
|
||||
self.title = title
|
||||
self.bodyES = bodyES
|
||||
self.bodyEN = bodyEN
|
||||
self.level = level
|
||||
self.wordAnnotations = wordAnnotations
|
||||
self.quizQuestions = quizQuestions
|
||||
self.createdDate = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Helpers
|
||||
|
||||
public struct WordAnnotation: Codable, Identifiable, Hashable {
|
||||
public var id: String { word }
|
||||
public let word: String
|
||||
public let baseForm: String
|
||||
public let english: String
|
||||
public let partOfSpeech: String
|
||||
|
||||
public init(word: String, baseForm: String, english: String, partOfSpeech: String) {
|
||||
self.word = word
|
||||
self.baseForm = baseForm
|
||||
self.english = english
|
||||
self.partOfSpeech = partOfSpeech
|
||||
}
|
||||
}
|
||||
|
||||
public struct QuizQuestion: Codable, Identifiable, Hashable {
|
||||
public var id: String { question }
|
||||
public let question: String
|
||||
public let options: [String]
|
||||
public let correctIndex: Int
|
||||
|
||||
public init(question: String, options: [String], correctIndex: Int) {
|
||||
self.question = question
|
||||
self.options = options
|
||||
self.correctIndex = correctIndex
|
||||
}
|
||||
}
|
||||
|
||||
extension Story {
|
||||
public var decodedAnnotations: [WordAnnotation] {
|
||||
guard let data = wordAnnotations.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([WordAnnotation].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public var decodedQuestions: [QuizQuestion] {
|
||||
guard let data = quizQuestions.data(using: .utf8) else { return [] }
|
||||
return (try? JSONDecoder().decode([QuizQuestion].self, from: data)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public final class VocabCard {
|
||||
public var deckId: String = ""
|
||||
public var examplesES: [String] = []
|
||||
public var examplesEN: [String] = []
|
||||
/// Per-example blank word for Complete the Sentence quiz. Index-aligned with `examplesES`.
|
||||
/// Empty string at a given index means "fall back to substring-matching card.front".
|
||||
public var examplesBlanks: [String] = []
|
||||
|
||||
public var deck: CourseDeck?
|
||||
|
||||
@@ -18,11 +21,12 @@ public final class VocabCard {
|
||||
public var dueDate: Date = Date()
|
||||
public var lastReviewDate: Date?
|
||||
|
||||
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = []) {
|
||||
public init(front: String, back: String, deckId: String, examplesES: [String] = [], examplesEN: [String] = [], examplesBlanks: [String] = []) {
|
||||
self.front = front
|
||||
self.back = back
|
||||
self.deckId = deckId
|
||||
self.examplesES = examplesES
|
||||
self.examplesEN = examplesEN
|
||||
self.examplesBlanks = examplesBlanks
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import SharedModels
|
||||
|
||||
/// Invariants that the shipped `course_data.json` must satisfy for the
|
||||
/// Complete the Sentence quiz to work for every card in every course.
|
||||
///
|
||||
/// These tests read the repo's `course_data.json` from a fixed relative path.
|
||||
/// They act as the pass/fail oracle for the content gap-fill work: they fail
|
||||
/// before the gap-fill pass is complete and pass once every card has at least
|
||||
/// three examples and at least one of them yields a resolvable blank.
|
||||
@Suite("Content coverage — course_data.json")
|
||||
struct ContentCoverageTests {
|
||||
|
||||
// Repo-relative path from this test file to the bundled data file.
|
||||
// SharedModels/Tests/SharedModelsTests/ContentCoverageTests.swift
|
||||
// → ../../../../Conjuga/course_data.json
|
||||
private static let courseDataPath: String = {
|
||||
let here = URL(fileURLWithPath: #filePath)
|
||||
return here
|
||||
.deletingLastPathComponent() // SharedModelsTests
|
||||
.deletingLastPathComponent() // Tests
|
||||
.deletingLastPathComponent() // SharedModels
|
||||
.deletingLastPathComponent() // Conjuga (repo package parent)
|
||||
.appendingPathComponent("Conjuga/course_data.json")
|
||||
.path
|
||||
}()
|
||||
|
||||
struct CardRef {
|
||||
let courseName: String
|
||||
let weekNumber: Int
|
||||
let deckTitle: String
|
||||
let front: String
|
||||
let back: String
|
||||
let examples: [[String: String]]
|
||||
}
|
||||
|
||||
/// Load every card in course_data.json.
|
||||
static func loadAllCards() throws -> [CardRef] {
|
||||
let url = URL(fileURLWithPath: courseDataPath)
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let courses = json["courses"] as? [[String: Any]] else {
|
||||
Issue.record("course_data.json is not in the expected shape")
|
||||
return []
|
||||
}
|
||||
|
||||
var cards: [CardRef] = []
|
||||
for course in courses {
|
||||
let cname = course["course"] as? String ?? "<unknown>"
|
||||
let weeks = course["weeks"] as? [[String: Any]] ?? []
|
||||
for week in weeks {
|
||||
let wnum = week["week"] as? Int ?? -1
|
||||
let decks = week["decks"] as? [[String: Any]] ?? []
|
||||
for deck in decks {
|
||||
let title = deck["title"] as? String ?? "<unknown>"
|
||||
let rawCards = deck["cards"] as? [[String: Any]] ?? []
|
||||
for raw in rawCards {
|
||||
let front = raw["front"] as? String ?? ""
|
||||
let back = raw["back"] as? String ?? ""
|
||||
let examples = (raw["examples"] as? [[String: String]]) ?? []
|
||||
cards.append(CardRef(
|
||||
courseName: cname,
|
||||
weekNumber: wnum,
|
||||
deckTitle: title,
|
||||
front: front,
|
||||
back: back,
|
||||
examples: examples
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
private static func vocabCard(from ref: CardRef) -> VocabCard {
|
||||
var exES: [String] = []
|
||||
var exEN: [String] = []
|
||||
var exBlanks: [String] = []
|
||||
for ex in ref.examples {
|
||||
if let es = ex["es"] {
|
||||
exES.append(es)
|
||||
exEN.append(ex["en"] ?? "")
|
||||
exBlanks.append(ex["blank"] ?? "")
|
||||
}
|
||||
}
|
||||
return VocabCard(
|
||||
front: ref.front,
|
||||
back: ref.back,
|
||||
deckId: "\(ref.courseName)_w\(ref.weekNumber)_\(ref.deckTitle)",
|
||||
examplesES: exES,
|
||||
examplesEN: exEN,
|
||||
examplesBlanks: exBlanks
|
||||
)
|
||||
}
|
||||
|
||||
@Test("course_data.json exists and parses")
|
||||
func fileExists() throws {
|
||||
let cards = try Self.loadAllCards()
|
||||
#expect(cards.count > 0, "Expected at least one card in course_data.json")
|
||||
}
|
||||
|
||||
@Test("Every card has at least three example sentences")
|
||||
func everyCardHasThreeExamples() throws {
|
||||
let cards = try Self.loadAllCards()
|
||||
var failures: [String] = []
|
||||
for ref in cards {
|
||||
if ref.examples.count < 3 {
|
||||
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)' has \(ref.examples.count) examples")
|
||||
}
|
||||
}
|
||||
if !failures.isEmpty {
|
||||
let head = Array(failures.prefix(10)).joined(separator: "\n")
|
||||
Issue.record("\(failures.count) cards have fewer than 3 examples. First 10:\n\(head)")
|
||||
}
|
||||
#expect(failures.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Every card yields a resolvable SentenceQuizEngine question")
|
||||
func everyCardHasBlankableSentence() throws {
|
||||
let cards = try Self.loadAllCards()
|
||||
var failures: [String] = []
|
||||
for ref in cards {
|
||||
let card = Self.vocabCard(from: ref)
|
||||
if !SentenceQuizEngine.hasValidSentence(for: card) {
|
||||
failures.append("[\(ref.courseName) / w\(ref.weekNumber) / \(ref.deckTitle)] '\(ref.front)'")
|
||||
}
|
||||
}
|
||||
if !failures.isEmpty {
|
||||
let head = Array(failures.prefix(15)).joined(separator: "\n")
|
||||
Issue.record("\(failures.count) cards have no resolvable sentence for Complete the Sentence. First 15:\n\(head)")
|
||||
}
|
||||
#expect(failures.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Every generated question has a non-empty blank word and display template")
|
||||
func questionIntegrity() throws {
|
||||
let cards = try Self.loadAllCards()
|
||||
var failures: [String] = []
|
||||
for ref in cards {
|
||||
let card = Self.vocabCard(from: ref)
|
||||
// Try to build a question from each resolvable index deterministically
|
||||
for idx in SentenceQuizEngine.resolvableIndices(for: card) {
|
||||
guard let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: idx) else {
|
||||
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) returned nil despite being resolvable")
|
||||
continue
|
||||
}
|
||||
if q.blankWord.isEmpty {
|
||||
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) has empty blankWord")
|
||||
}
|
||||
if !q.displayTemplate.contains(SentenceQuizEngine.blankMarker) {
|
||||
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template missing blank marker")
|
||||
}
|
||||
if q.displayTemplate == q.sentenceES {
|
||||
failures.append("[\(ref.courseName) / '\(ref.front)'] index \(idx) display template unchanged from sentence")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failures.isEmpty {
|
||||
let head = Array(failures.prefix(10)).joined(separator: "\n")
|
||||
Issue.record("\(failures.count) question integrity failures. First 10:\n\(head)")
|
||||
}
|
||||
#expect(failures.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import Testing
|
||||
@testable import SharedModels
|
||||
|
||||
@Suite("EnglishConjugator")
|
||||
struct EnglishConjugatorTests {
|
||||
|
||||
// MARK: - haber (to have) — irregular English verb
|
||||
|
||||
@Test("haber present: I have / you have / he/she has")
|
||||
func haberPresent() {
|
||||
#expect(t("to have", "ind_presente", 0) == "I have")
|
||||
#expect(t("to have", "ind_presente", 1) == "you have")
|
||||
#expect(t("to have", "ind_presente", 2) == "he/she has")
|
||||
#expect(t("to have", "ind_presente", 3) == "we have")
|
||||
#expect(t("to have", "ind_presente", 5) == "they have")
|
||||
}
|
||||
|
||||
@Test("haber preterite: I had")
|
||||
func haberPreterite() {
|
||||
#expect(t("to have", "ind_preterito", 0) == "I had")
|
||||
#expect(t("to have", "ind_preterito", 2) == "he/she had")
|
||||
}
|
||||
|
||||
@Test("haber future: I will have")
|
||||
func haberFuture() {
|
||||
#expect(t("to have", "ind_futuro", 0) == "I will have")
|
||||
#expect(t("to have", "ind_futuro", 3) == "we will have")
|
||||
}
|
||||
|
||||
@Test("haber conditional: I would have")
|
||||
func haberConditional() {
|
||||
#expect(t("to have", "cond_presente", 0) == "I would have")
|
||||
}
|
||||
|
||||
@Test("haber present perfect: I have had / he/she has had")
|
||||
func haberPresentPerfect() {
|
||||
#expect(t("to have", "ind_perfecto", 0) == "I have had")
|
||||
#expect(t("to have", "ind_perfecto", 2) == "he/she has had")
|
||||
}
|
||||
|
||||
// MARK: - ir (to go) — irregular English verb
|
||||
|
||||
@Test("ir present: I go / he/she goes")
|
||||
func irPresent() {
|
||||
#expect(t("to go", "ind_presente", 0) == "I go")
|
||||
#expect(t("to go", "ind_presente", 2) == "he/she goes")
|
||||
#expect(t("to go", "ind_presente", 5) == "they go")
|
||||
}
|
||||
|
||||
@Test("ir preterite: I went")
|
||||
func irPreterite() {
|
||||
#expect(t("to go", "ind_preterito", 0) == "I went")
|
||||
#expect(t("to go", "ind_preterito", 2) == "he/she went")
|
||||
}
|
||||
|
||||
@Test("ir imperfect: I used to go")
|
||||
func irImperfect() {
|
||||
#expect(t("to go", "ind_imperfecto", 0) == "I used to go")
|
||||
}
|
||||
|
||||
@Test("ir present perfect: I have gone")
|
||||
func irPresentPerfect() {
|
||||
#expect(t("to go", "ind_perfecto", 0) == "I have gone")
|
||||
#expect(t("to go", "ind_perfecto", 2) == "he/she has gone")
|
||||
}
|
||||
|
||||
// MARK: - ser (to be) — most irregular English verb
|
||||
|
||||
@Test("ser present: he/she is")
|
||||
func serPresent() {
|
||||
#expect(t("to be", "ind_presente", 2) == "he/she is")
|
||||
}
|
||||
|
||||
@Test("ser preterite: I was/were")
|
||||
func serPreterite() {
|
||||
#expect(t("to be", "ind_preterito", 0) == "I was/were")
|
||||
}
|
||||
|
||||
@Test("ser present perfect: I have been")
|
||||
func serPresentPerfect() {
|
||||
#expect(t("to be", "ind_perfecto", 0) == "I have been")
|
||||
}
|
||||
|
||||
// MARK: - hablar (to speak)
|
||||
|
||||
@Test("hablar present: I speak / he/she speaks")
|
||||
func hablarPresent() {
|
||||
#expect(t("to speak", "ind_presente", 0) == "I speak")
|
||||
#expect(t("to speak", "ind_presente", 2) == "he/she speaks")
|
||||
}
|
||||
|
||||
@Test("hablar preterite: I spoke")
|
||||
func hablarPreterite() {
|
||||
#expect(t("to speak", "ind_preterito", 0) == "I spoke")
|
||||
}
|
||||
|
||||
@Test("hablar present perfect: I have spoken")
|
||||
func hablarPresentPerfect() {
|
||||
#expect(t("to speak", "ind_perfecto", 0) == "I have spoken")
|
||||
}
|
||||
|
||||
// MARK: - comer (to eat)
|
||||
|
||||
@Test("comer preterite: I ate")
|
||||
func comerPreterite() {
|
||||
#expect(t("to eat", "ind_preterito", 0) == "I ate")
|
||||
}
|
||||
|
||||
@Test("comer present perfect: I have eaten")
|
||||
func comerPresentPerfect() {
|
||||
#expect(t("to eat", "ind_perfecto", 0) == "I have eaten")
|
||||
}
|
||||
|
||||
// MARK: - vivir (to live) — regular English verb
|
||||
|
||||
@Test("vivir present: I live / he/she lives")
|
||||
func vivirPresent() {
|
||||
#expect(t("to live", "ind_presente", 0) == "I live")
|
||||
#expect(t("to live", "ind_presente", 2) == "he/she lives")
|
||||
}
|
||||
|
||||
@Test("vivir preterite: I lived")
|
||||
func vivirPreterite() {
|
||||
#expect(t("to live", "ind_preterito", 0) == "I lived")
|
||||
}
|
||||
|
||||
// MARK: - abatir (to knock down) — multi-word verb
|
||||
|
||||
@Test("abatir present: I knock down / he/she knocks down")
|
||||
func abatirPresent() {
|
||||
#expect(t("to knock down", "ind_presente", 0) == "I knock down")
|
||||
#expect(t("to knock down", "ind_presente", 2) == "he/she knocks down")
|
||||
}
|
||||
|
||||
@Test("abatir conditional: I would knock down")
|
||||
func abatirConditional() {
|
||||
#expect(t("to knock down", "cond_presente", 0) == "I would knock down")
|
||||
#expect(t("to knock down", "cond_presente", 2) == "he/she would knock down")
|
||||
}
|
||||
|
||||
@Test("abatir preterite: I knocked down")
|
||||
func abatirPreterite() {
|
||||
#expect(t("to knock down", "ind_preterito", 0) == "I knocked down")
|
||||
}
|
||||
|
||||
// MARK: - Conditional
|
||||
|
||||
@Test("conditional: I would speak")
|
||||
func conditional() {
|
||||
#expect(t("to speak", "cond_presente", 0) == "I would speak")
|
||||
#expect(t("to speak", "cond_presente", 2) == "he/she would speak")
|
||||
}
|
||||
|
||||
@Test("conditional perfect: I would have gone")
|
||||
func conditionalPerfect() {
|
||||
#expect(t("to go", "cond_perfecto", 0) == "I would have gone")
|
||||
}
|
||||
|
||||
// MARK: - Subjunctive
|
||||
|
||||
@Test("present subjunctive: that I speak")
|
||||
func presentSubjunctive() {
|
||||
#expect(t("to speak", "subj_presente", 0) == "that I speak")
|
||||
#expect(t("to speak", "subj_presente", 2) == "that he/she speak")
|
||||
}
|
||||
|
||||
@Test("imperfect subjunctive (ra): that I would speak")
|
||||
func imperfectSubjunctive1() {
|
||||
#expect(t("to speak", "subj_imperfecto_1", 0) == "that I would speak")
|
||||
}
|
||||
|
||||
@Test("imperfect subjunctive (se): that I would speak")
|
||||
func imperfectSubjunctive2() {
|
||||
#expect(t("to speak", "subj_imperfecto_2", 0) == "that I would speak")
|
||||
}
|
||||
|
||||
@Test("subjunctive perfect: that I have spoken")
|
||||
func subjunctivePerfect() {
|
||||
#expect(t("to speak", "subj_perfecto", 0) == "that I have spoken")
|
||||
#expect(t("to speak", "subj_perfecto", 2) == "that he/she has spoken")
|
||||
}
|
||||
|
||||
@Test("subjunctive pluperfect: that I had gone")
|
||||
func subjunctivePluperfect() {
|
||||
#expect(t("to go", "subj_pluscuamperfecto_1", 0) == "that I had gone")
|
||||
#expect(t("to go", "subj_pluscuamperfecto_2", 0) == "that I had gone")
|
||||
}
|
||||
|
||||
@Test("subjunctive future: that I will speak")
|
||||
func subjunctiveFuture() {
|
||||
#expect(t("to speak", "subj_futuro", 0) == "that I will speak")
|
||||
}
|
||||
|
||||
@Test("subjunctive future perfect: that I will have spoken")
|
||||
func subjunctiveFuturePerfect() {
|
||||
#expect(t("to speak", "subj_futuro_perfecto", 0) == "that I will have spoken")
|
||||
}
|
||||
|
||||
// MARK: - Imperative
|
||||
|
||||
@Test("imperative affirmative")
|
||||
func imperativeAffirmative() {
|
||||
#expect(t("to speak", "imp_afirmativo", 1) == "speak!")
|
||||
#expect(t("to speak", "imp_afirmativo", 3) == "let's speak!")
|
||||
}
|
||||
|
||||
@Test("imperative negative")
|
||||
func imperativeNegative() {
|
||||
#expect(t("to speak", "imp_negativo", 1) == "don't speak")
|
||||
}
|
||||
|
||||
// MARK: - Compound indicative tenses
|
||||
|
||||
@Test("pluperfect: I had spoken")
|
||||
func pluperfect() {
|
||||
#expect(t("to speak", "ind_pluscuamperfecto", 0) == "I had spoken")
|
||||
#expect(t("to go", "ind_pluscuamperfecto", 0) == "I had gone")
|
||||
}
|
||||
|
||||
@Test("future perfect: I will have spoken")
|
||||
func futurePerfect() {
|
||||
#expect(t("to speak", "ind_futuro_perfecto", 0) == "I will have spoken")
|
||||
}
|
||||
|
||||
@Test("preterite anterior: I had spoken (same as pluperfect in English)")
|
||||
func preteriteAnterior() {
|
||||
#expect(t("to speak", "ind_preterito_anterior", 0) == "I had spoken")
|
||||
}
|
||||
|
||||
// MARK: - Edge cases
|
||||
|
||||
@Test("empty english returns empty string")
|
||||
func emptyEnglish() {
|
||||
#expect(t("", "ind_presente", 0) == "")
|
||||
#expect(t("to ", "ind_presente", 0) == "")
|
||||
}
|
||||
|
||||
@Test("unknown tense falls back to pronoun + base")
|
||||
func unknownTense() {
|
||||
#expect(t("to speak", "some_future_tense", 0) == "I speak")
|
||||
}
|
||||
|
||||
@Test("3rd person present: study → studies")
|
||||
func thirdPersonYRule() {
|
||||
#expect(t("to study", "ind_presente", 2) == "he/she studies")
|
||||
}
|
||||
|
||||
@Test("3rd person present: play → plays")
|
||||
func thirdPersonVowelY() {
|
||||
#expect(t("to play", "ind_presente", 2) == "he/she plays")
|
||||
}
|
||||
|
||||
@Test("3rd person present: watch → watches")
|
||||
func thirdPersonChRule() {
|
||||
#expect(t("to watch", "ind_presente", 2) == "he/she watches")
|
||||
}
|
||||
|
||||
@Test("past regular: carry → carried")
|
||||
func pastYRule() {
|
||||
#expect(t("to carry", "ind_preterito", 0) == "I carried")
|
||||
}
|
||||
|
||||
// MARK: - Helper
|
||||
|
||||
private func t(_ english: String, _ tenseId: String, _ personIndex: Int) -> String {
|
||||
EnglishConjugator.translate(english: english, tenseId: tenseId, personIndex: personIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import Testing
|
||||
@testable import SharedModels
|
||||
|
||||
@Suite("SentenceQuizEngine")
|
||||
struct SentenceQuizEngineTests {
|
||||
|
||||
// MARK: - hasValidSentence
|
||||
|
||||
@Test("No examples returns false")
|
||||
func noExamples() {
|
||||
let card = VocabCard(front: "comer", back: "to eat", deckId: "d", examplesES: [], examplesEN: [])
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
|
||||
}
|
||||
|
||||
@Test("Example containing target word returns true via substring fallback")
|
||||
func substringMatch() {
|
||||
let card = VocabCard(
|
||||
front: "manzana",
|
||||
back: "apple",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como una manzana roja."],
|
||||
examplesEN: ["I eat a red apple."]
|
||||
)
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||
}
|
||||
|
||||
@Test("Example whose stored blank appears returns true even if target word is missing")
|
||||
func storedBlankMatch() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como manzanas todos los días."],
|
||||
examplesEN: ["I eat apples every day."],
|
||||
examplesBlanks: ["como"]
|
||||
)
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||
}
|
||||
|
||||
@Test("Example with neither stored blank nor substring match returns false for that example")
|
||||
func neitherMatches() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Ella prepara la cena."],
|
||||
examplesEN: ["She prepares dinner."]
|
||||
)
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card) == false)
|
||||
}
|
||||
|
||||
@Test("At least one resolvable example across many makes the card valid")
|
||||
func oneOfManyResolves() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: [
|
||||
"Ella prepara la cena.",
|
||||
"Los niños van al parque.",
|
||||
"Quiero comer algo ahora."
|
||||
],
|
||||
examplesEN: ["", "", ""]
|
||||
)
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
|
||||
}
|
||||
|
||||
@Test("Phonetic glosses are rejected (too few words)")
|
||||
func phoneticGlossRejected() {
|
||||
let card = VocabCard(
|
||||
front: "discutir",
|
||||
back: "to discuss",
|
||||
deckId: "d",
|
||||
examplesES: [
|
||||
"discutir(dees-koo-teer)",
|
||||
"INTRANSITIVE VERB",
|
||||
"Los amigos van a discutir el tema."
|
||||
],
|
||||
examplesEN: ["", "", "The friends are going to discuss the topic."]
|
||||
)
|
||||
// Only index 2 is a real sentence (≥4 words AND contains the target)
|
||||
#expect(SentenceQuizEngine.hasValidSentence(for: card))
|
||||
#expect(SentenceQuizEngine.resolvableIndices(for: card) == [2])
|
||||
// Phonetic entry at index 0 returns nil
|
||||
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0) == nil)
|
||||
}
|
||||
|
||||
// MARK: - buildQuestion (deterministic)
|
||||
|
||||
@Test("Builds question from substring match, preserves original casing")
|
||||
func buildFromSubstring() {
|
||||
let card = VocabCard(
|
||||
front: "manzana",
|
||||
back: "apple",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como una manzana roja."],
|
||||
examplesEN: ["I eat a red apple."]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question != nil)
|
||||
#expect(question?.sentenceES == "Yo como una manzana roja.")
|
||||
#expect(question?.sentenceEN == "I eat a red apple.")
|
||||
#expect(question?.blankWord == "manzana")
|
||||
#expect(question?.displayTemplate == "Yo como una _____ roja.")
|
||||
#expect(question?.exampleIndex == 0)
|
||||
}
|
||||
|
||||
@Test("Builds question from stored blank when provided")
|
||||
func buildFromStoredBlank() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como manzanas todos los días."],
|
||||
examplesEN: ["I eat apples every day."],
|
||||
examplesBlanks: ["como"]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "como")
|
||||
#expect(question?.displayTemplate == "Yo _____ manzanas todos los días.")
|
||||
}
|
||||
|
||||
@Test("Stored blank takes precedence over substring match")
|
||||
func storedBlankWins() {
|
||||
// Card teaches "manzana" (would substring-match), but the stored blank is the verb "como"
|
||||
let card = VocabCard(
|
||||
front: "manzana",
|
||||
back: "apple",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como una manzana."],
|
||||
examplesEN: ["I eat an apple."],
|
||||
examplesBlanks: ["como"]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "como")
|
||||
#expect(question?.displayTemplate == "Yo _____ una manzana.")
|
||||
}
|
||||
|
||||
@Test("Falls back to substring match when stored blank is empty")
|
||||
func fallbackWhenStoredBlankEmpty() {
|
||||
let card = VocabCard(
|
||||
front: "manzana",
|
||||
back: "apple",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como una manzana."],
|
||||
examplesEN: ["I eat an apple."],
|
||||
examplesBlanks: [""]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "manzana")
|
||||
}
|
||||
|
||||
@Test("Falls back to substring match when stored blank doesn't actually appear in the sentence")
|
||||
func fallbackWhenStoredBlankMissing() {
|
||||
let card = VocabCard(
|
||||
front: "manzana",
|
||||
back: "apple",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como una manzana."],
|
||||
examplesEN: ["I eat an apple."],
|
||||
examplesBlanks: ["nonexistent"]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "manzana")
|
||||
}
|
||||
|
||||
@Test("Preserves original capitalization when blanking (substring is case-insensitive)")
|
||||
func preservesCapitalization() {
|
||||
let card = VocabCard(
|
||||
front: "hola",
|
||||
back: "hello",
|
||||
deckId: "d",
|
||||
examplesES: ["Hola amiga, ¿cómo estás hoy?"],
|
||||
examplesEN: ["Hello friend, how are you today?"]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "Hola")
|
||||
#expect(question?.displayTemplate == "_____ amiga, ¿cómo estás hoy?")
|
||||
}
|
||||
|
||||
@Test("Blanks phrase cards when target front contains spaces")
|
||||
func phraseCardBlank() {
|
||||
let card = VocabCard(
|
||||
front: "¿cómo estás?",
|
||||
back: "how are you?",
|
||||
deckId: "d",
|
||||
examplesES: ["Hola amiga, ¿cómo estás? Estoy bien."],
|
||||
examplesEN: ["Hi friend, how are you? I am well."]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question?.blankWord == "¿cómo estás?")
|
||||
#expect(question?.displayTemplate == "Hola amiga, _____ Estoy bien.")
|
||||
}
|
||||
|
||||
@Test("Returns nil when the example has no resolvable blank")
|
||||
func unresolvableExampleReturnsNil() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Ella prepara la cena."],
|
||||
examplesEN: ["She prepares dinner."]
|
||||
)
|
||||
let question = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(question == nil)
|
||||
}
|
||||
|
||||
@Test("Returns nil when example index is out of range")
|
||||
func outOfRangeIndexReturnsNil() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como."],
|
||||
examplesEN: [""]
|
||||
)
|
||||
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 5) == nil)
|
||||
#expect(SentenceQuizEngine.buildQuestion(for: card, exampleIndex: -1) == nil)
|
||||
}
|
||||
|
||||
// MARK: - buildQuestion (random)
|
||||
|
||||
@Test("Random buildQuestion always picks a resolvable example")
|
||||
func randomPickIsResolvable() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: [
|
||||
"Ella prepara la cena.", // unresolvable (no "comer")
|
||||
"Los niños van al parque.", // unresolvable
|
||||
"Quiero comer algo rico ahora.", // resolvable (substring, ≥4 words)
|
||||
"El perro come su comida diaria." // unresolvable — "come" but not "comer"
|
||||
],
|
||||
examplesEN: ["", "", "", ""]
|
||||
)
|
||||
// Only index 2 is resolvable (contains "comer" literally and has ≥4 words)
|
||||
for _ in 0..<25 {
|
||||
let q = SentenceQuizEngine.buildQuestion(for: card)
|
||||
#expect(q?.exampleIndex == 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Random buildQuestion returns nil when no examples resolve")
|
||||
func randomNilWhenNothingResolves() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Ella prepara la cena."],
|
||||
examplesEN: [""]
|
||||
)
|
||||
#expect(SentenceQuizEngine.buildQuestion(for: card) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Array alignment edge cases
|
||||
|
||||
@Test("examplesBlanks shorter than examplesES is handled gracefully")
|
||||
func blanksArrayShorterThanExamples() {
|
||||
let card = VocabCard(
|
||||
front: "comer",
|
||||
back: "to eat",
|
||||
deckId: "d",
|
||||
examplesES: ["Yo como mucho pan.", "Tú comes en casa."],
|
||||
examplesEN: ["I eat a lot of bread.", "You eat at home."],
|
||||
examplesBlanks: ["como"] // only covers index 0
|
||||
)
|
||||
// Index 0: stored blank match
|
||||
let q0 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(q0?.blankWord == "como")
|
||||
// Index 1: no stored blank, "comer" doesn't appear literally → unresolvable
|
||||
let q1 = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 1)
|
||||
#expect(q1 == nil)
|
||||
}
|
||||
|
||||
@Test("Display template uses the engine's blank marker constant")
|
||||
func blankMarkerConstant() {
|
||||
let card = VocabCard(
|
||||
front: "perro",
|
||||
back: "dog",
|
||||
deckId: "d",
|
||||
examplesES: ["El perro ladra todo el día."],
|
||||
examplesEN: ["The dog barks all day."]
|
||||
)
|
||||
let q = SentenceQuizEngine.buildQuestion(for: card, exampleIndex: 0)
|
||||
#expect(q?.displayTemplate.contains(SentenceQuizEngine.blankMarker) == true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user