Move reference-data models to SharedModels to fix widget-triggered data loss
Root cause: the widget was opening the shared local.store with a 2-entity schema (VocabCard, CourseDeck), causing SwiftData to destructively migrate the file and drop the 4 entities the widget didn't know about (Verb, VerbForm, IrregularSpan, TenseGuide). The main app would then re-seed on next launch, and the cycle repeated forever. Fix: move Verb, VerbForm, IrregularSpan, TenseGuide from the app target into SharedModels so both the main app and the widget use the exact same types from the same module. Both now declare all 6 local entities in their ModelContainer, producing identical schema hashes and eliminating the destructive migration. Other changes bundled in this commit (accumulated during debugging): - Split ModelContainer into localContainer + cloudContainer (no more CloudKit + non-CloudKit configs in one container) - Add SharedStore.localStoreURL() helper and a global reference for bypass-environment fetches - One-time store reset mechanism to wipe stale schema metadata from previous broken iterations - Bootstrap/maintenance split so only seeding gates the UI; dedup and cloud repair run in the background - Sync status toast that shows "Syncing" while background maintenance runs (network-aware, auto-dismisses) - Background app refresh task to keep the widget word-of-day fresh - Speaker icon on VerbDetailView for TTS - Grammar notes navigation fix (nested NavigationStack was breaking detail pane on iPhone) - Word-of-day widget swaps front/back when the deck is reversed so the Spanish word always shows in bold - StoreInspector diagnostic helper for raw SQLite table inspection - Add Conjuga scheme explicitly to project.yml so xcodegen doesn't drop it Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 63;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
|
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
|
||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
|
||||||
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
|
||||||
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
|
||||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
|
||||||
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
|
||||||
@@ -33,23 +34,18 @@
|
|||||||
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
|
||||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
|
||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
|
||||||
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165B15630F4560F5891D9763 /* Verb.swift */; };
|
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
|
||||||
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
|
||||||
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1705F97FA0D59E996529 /* VerbForm.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 */; };
|
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
|
||||||
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FB1479EA5779A109BC517D /* IrregularSpan.swift */; };
|
|
||||||
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
|
||||||
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */; };
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
|
||||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||||
A11A11111111111111111111 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11B11111111111111111111 /* ReviewStore.swift */; };
|
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||||
A22A22222222222222222222 /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22B22222222222222222222 /* ReferenceStore.swift */; };
|
|
||||||
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B33333333333333333333 /* CourseReviewStore.swift */; };
|
|
||||||
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44B44444444444444444444 /* PracticeSessionService.swift */; };
|
|
||||||
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55B55555555555555555555 /* StartupCoordinator.swift */; };
|
|
||||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
|
||||||
@@ -62,11 +58,14 @@
|
|||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
|
||||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
||||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||||
|
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||||
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
|
||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
|
||||||
|
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
|
||||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
|
||||||
@@ -105,17 +104,17 @@
|
|||||||
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
|
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
|
||||||
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
|
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
|
||||||
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
|
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
|
||||||
165B15630F4560F5891D9763 /* Verb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Verb.swift; sourceTree = "<group>"; };
|
|
||||||
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
||||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||||
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
||||||
|
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
||||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
||||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
||||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||||
21FB1479EA5779A109BC517D /* IrregularSpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularSpan.swift; sourceTree = "<group>"; };
|
|
||||||
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
|
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
|
||||||
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
|
||||||
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
|
||||||
@@ -139,37 +138,36 @@
|
|||||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.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>"; };
|
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>"; };
|
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||||
|
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
||||||
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
||||||
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
||||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
||||||
8B6C1705F97FA0D59E996529 /* VerbForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbForm.swift; sourceTree = "<group>"; };
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
||||||
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
|
||||||
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
|
||||||
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
|
||||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
||||||
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
|
||||||
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
B11B11111111111111111111 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
|
||||||
B22B22222222222222222222 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
|
|
||||||
B33B33333333333333333333 /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
|
||||||
B44B44444444444444444444 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
|
|
||||||
B55B55555555555555555555 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
|
|
||||||
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
|
||||||
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
|
||||||
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
|
||||||
|
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
|
||||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||||
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||||
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
|
||||||
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
|
||||||
|
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
|
||||||
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseGuide.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -230,15 +228,17 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||||
B33B33333333333333333333 /* CourseReviewStore.swift */,
|
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||||
B44B44444444444444444444 /* PracticeSessionService.swift */,
|
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||||
B22B22222222222222222222 /* ReferenceStore.swift */,
|
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||||
B11B11111111111111111111 /* ReviewStore.swift */,
|
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||||
B55B55555555555555555555 /* StartupCoordinator.swift */,
|
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||||
|
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||||
|
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
@@ -257,16 +257,12 @@
|
|||||||
children = (
|
children = (
|
||||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||||
21FB1479EA5779A109BC517D /* IrregularSpan.swift */,
|
|
||||||
626873572466403C0288090D /* QuizType.swift */,
|
626873572466403C0288090D /* QuizType.swift */,
|
||||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
||||||
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */,
|
|
||||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||||
165B15630F4560F5891D9763 /* Verb.swift */,
|
|
||||||
8B6C1705F97FA0D59E996529 /* VerbForm.swift */,
|
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -278,6 +274,7 @@
|
|||||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */,
|
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */,
|
||||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */,
|
80D974250C396589656B8443 /* HandwritingCanvas.swift */,
|
||||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */,
|
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */,
|
||||||
|
1C4B5204F6B8647C816814F0 /* SyncToast.swift */,
|
||||||
102F0E136CDFF8CED710210F /* TensePill.swift */,
|
102F0E136CDFF8CED710210F /* TensePill.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
@@ -472,6 +469,7 @@
|
|||||||
packageReferences = (
|
packageReferences = (
|
||||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||||
);
|
);
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
@@ -504,6 +502,7 @@
|
|||||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
||||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||||
|
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
||||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
||||||
@@ -519,36 +518,34 @@
|
|||||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||||
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */,
|
|
||||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||||
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */,
|
|
||||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
||||||
|
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||||
A22A22222222222222222222 /* ReferenceStore.swift in Sources */,
|
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||||
A11A11111111111111111111 /* ReviewStore.swift in Sources */,
|
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */,
|
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */,
|
||||||
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
|
||||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
|
||||||
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */,
|
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
|
||||||
|
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
|
||||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
|
||||||
|
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||||
|
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||||
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */,
|
|
||||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||||
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */,
|
|
||||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||||
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */,
|
|
||||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||||
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */,
|
|
||||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
|
||||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "96127FACA68AE541F5C0F8BC"
|
||||||
|
BuildableName = "Conjuga.app"
|
||||||
|
BlueprintName = "Conjuga"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F73909B4044081DB8F6272AF"
|
||||||
|
BuildableName = "ConjugaWidgetExtension.appex"
|
||||||
|
BlueprintName = "ConjugaWidgetExtension"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "96127FACA68AE541F5C0F8BC"
|
||||||
|
BuildableName = "Conjuga.app"
|
||||||
|
BlueprintName = "Conjuga"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "96127FACA68AE541F5C0F8BC"
|
||||||
|
BuildableName = "Conjuga.app"
|
||||||
|
BlueprintName = "Conjuga"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "96127FACA68AE541F5C0F8BC"
|
||||||
|
BuildableName = "Conjuga.app"
|
||||||
|
BlueprintName = "Conjuga"
|
||||||
|
ReferencedContainer = "container:Conjuga.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -2,26 +2,65 @@ import SwiftUI
|
|||||||
import SharedModels
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private enum CloudPreviewContainer {
|
||||||
|
static let value: ModelContainer = {
|
||||||
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||||
|
return try! ModelContainer(
|
||||||
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
|
TestResult.self, DailyLog.self,
|
||||||
|
configurations: configuration
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias CloudModelContextProvider = @MainActor @Sendable () -> ModelContext
|
||||||
|
private let appRefreshTaskIdentifier = "com.conjuga.app.refresh"
|
||||||
|
|
||||||
|
private struct CloudModelContextProviderKey: EnvironmentKey {
|
||||||
|
static let defaultValue: CloudModelContextProvider = {
|
||||||
|
CloudPreviewContainer.value.mainContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var cloudModelContextProvider: CloudModelContextProvider {
|
||||||
|
get { self[CloudModelContextProviderKey.self] }
|
||||||
|
set { self[CloudModelContextProviderKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct ConjugaApp: App {
|
struct ConjugaApp: App {
|
||||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var isReady = false
|
@State private var isReady = false
|
||||||
|
@State private var syncMonitor = SyncStatusMonitor()
|
||||||
|
|
||||||
let container: ModelContainer
|
let localContainer: ModelContainer
|
||||||
|
let cloudContainer: ModelContainer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
guard let localURL = SharedStore.localStoreURL() else {
|
||||||
|
fatalError("App group 'group.com.conjuga.app' is not accessible. Check entitlements and provisioning profile.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time force-reset of the local store to clear stale schema metadata
|
||||||
|
// accumulated from previous container configurations.
|
||||||
|
Self.performOneTimeLocalStoreResetIfNeeded(at: localURL)
|
||||||
|
|
||||||
|
// DIAGNOSTIC: what's in the store file BEFORE we open it via SwiftData?
|
||||||
|
StoreInspector.dump(at: localURL, label: "before-open")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let localConfig = ModelConfiguration(
|
localContainer = try Self.makeValidatedLocalContainer(at: localURL)
|
||||||
"local",
|
SharedStore.localContainer = localContainer
|
||||||
schema: Schema([
|
|
||||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
// DIAGNOSTIC: what's in the store file AFTER SwiftData opened it?
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
StoreInspector.dump(at: localURL, label: "after-open")
|
||||||
]),
|
print("[ConjugaApp] localContainer identity: \(ObjectIdentifier(localContainer))")
|
||||||
groupContainer: .none,
|
|
||||||
cloudKitDatabase: .none
|
|
||||||
)
|
|
||||||
|
|
||||||
let cloudConfig = ModelConfiguration(
|
let cloudConfig = ModelConfiguration(
|
||||||
"cloud",
|
"cloud",
|
||||||
@@ -31,13 +70,10 @@ struct ConjugaApp: App {
|
|||||||
]),
|
]),
|
||||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||||
)
|
)
|
||||||
|
cloudContainer = try ModelContainer(
|
||||||
container = try ModelContainer(
|
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
|
||||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
|
||||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
|
||||||
TestResult.self, DailyLog.self,
|
TestResult.self, DailyLog.self,
|
||||||
configurations: localConfig, cloudConfig
|
configurations: cloudConfig
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Failed to create ModelContainer: \(error)")
|
fatalError("Failed to create ModelContainer: \(error)")
|
||||||
@@ -60,18 +96,147 @@ struct ConjugaApp: App {
|
|||||||
OnboardingView()
|
OnboardingView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if syncMonitor.shouldShowToast {
|
||||||
|
SyncToast()
|
||||||
|
.padding(.bottom, 100)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
|
||||||
|
.environment(syncMonitor)
|
||||||
|
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
|
||||||
.task {
|
.task {
|
||||||
await StartupCoordinator.run(container: container)
|
if let url = SharedStore.localStoreURL() {
|
||||||
WidgetDataService.update(context: container.mainContext)
|
StoreInspector.dump(at: url, label: "before-bootstrap")
|
||||||
|
}
|
||||||
|
await StartupCoordinator.bootstrap(localContainer: localContainer)
|
||||||
|
if let url = SharedStore.localStoreURL() {
|
||||||
|
StoreInspector.dump(at: url, label: "after-bootstrap")
|
||||||
|
}
|
||||||
isReady = true
|
isReady = true
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
syncMonitor.beginSync()
|
||||||
|
await StartupCoordinator.runMaintenance(
|
||||||
|
localContainer: localContainer,
|
||||||
|
cloudContainer: cloudContainer
|
||||||
|
)
|
||||||
|
WidgetDataService.update(
|
||||||
|
localContainer: localContainer,
|
||||||
|
cloudContainer: cloudContainer
|
||||||
|
)
|
||||||
|
syncMonitor.endSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .background {
|
if newPhase == .background {
|
||||||
let context = container.mainContext
|
WidgetDataService.update(
|
||||||
WidgetDataService.update(context: context)
|
localContainer: localContainer,
|
||||||
|
cloudContainer: cloudContainer
|
||||||
|
)
|
||||||
|
Self.scheduleAppRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(container)
|
.modelContainer(localContainer)
|
||||||
|
.backgroundTask(.appRefresh(appRefreshTaskIdentifier)) {
|
||||||
|
Self.scheduleAppRefresh()
|
||||||
|
await refreshWidgetData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func refreshWidgetData() async {
|
||||||
|
WidgetDataService.update(
|
||||||
|
localContainer: localContainer,
|
||||||
|
cloudContainer: cloudContainer
|
||||||
|
)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func scheduleAppRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: appRefreshTaskIdentifier)
|
||||||
|
// Minimum delay — system decides actual run time based on usage patterns.
|
||||||
|
// We want the widget refreshed before the user typically opens the app.
|
||||||
|
request.earliestBeginDate = Calendar.current.startOfDay(
|
||||||
|
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("Failed to schedule app refresh: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeValidatedLocalContainer(at url: URL) throws -> ModelContainer {
|
||||||
|
let container = try makeLocalContainer(at: url)
|
||||||
|
if localStoreIsUsable(container: container) {
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteStoreFiles(at: url)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
|
||||||
|
print("Reset corrupted local reference store")
|
||||||
|
|
||||||
|
return try makeLocalContainer(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
|
||||||
|
let localConfig = ModelConfiguration(
|
||||||
|
"local",
|
||||||
|
schema: Schema([
|
||||||
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
]),
|
||||||
|
url: url,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
|
return try ModelContainer(
|
||||||
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
configurations: localConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
|
||||||
|
let context = ModelContext(container)
|
||||||
|
do {
|
||||||
|
_ = try context.fetchCount(FetchDescriptor<Verb>())
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
print("Local reference store validation failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deleteStoreFiles(at url: URL) {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let candidateURL = URL(fileURLWithPath: url.path + suffix)
|
||||||
|
guard fileManager.fileExists(atPath: candidateURL.path) else { continue }
|
||||||
|
try? fileManager.removeItem(at: candidateURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-time nuclear reset of the local reference store.
|
||||||
|
/// 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 key = "localStoreResetVersion"
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
|
guard defaults.integer(forKey: key) < resetVersion else { return }
|
||||||
|
|
||||||
|
print("[ConjugaApp] Performing one-time local store reset (v\(resetVersion))")
|
||||||
|
deleteStoreFiles(at: url)
|
||||||
|
|
||||||
|
// Clear any version flags that gate seeding so everything re-seeds cleanly.
|
||||||
|
defaults.removeObject(forKey: "courseDataVersion")
|
||||||
|
defaults.removeObject(forKey: "courseProgressMigrationVersion")
|
||||||
|
|
||||||
|
defaults.set(resetVersion, forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,13 @@
|
|||||||
<dict/>
|
<dict/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.conjuga.app.refresh</string>
|
||||||
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct GrammarNote: Identifiable {
|
struct GrammarNote: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
/// Static conjugation ending tables for all 20 tenses, used in Guide views.
|
/// Static conjugation ending tables for all 20 tenses, used in Guide views.
|
||||||
/// Data sourced from the Spanish Verb Tenses chart and Conjuu ES conjugation rules.
|
/// Data sourced from the Spanish Verb Tenses chart and Conjuu ES conjugation rules.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import SwiftData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class TenseGuide {
|
|
||||||
var tenseId: String = ""
|
|
||||||
var title: String = ""
|
|
||||||
var body: String = ""
|
|
||||||
|
|
||||||
init(tenseId: String, title: String, body: String) {
|
|
||||||
self.tenseId = tenseId
|
|
||||||
self.title = title
|
|
||||||
self.body = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
import SharedModels
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import SwiftData
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class VerbForm {
|
|
||||||
var verbId: Int = 0
|
|
||||||
var tenseId: String = ""
|
|
||||||
var personIndex: Int = 0
|
|
||||||
var form: String = ""
|
|
||||||
var regularity: String = ""
|
|
||||||
|
|
||||||
var verb: Verb?
|
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade, inverse: \IrregularSpan.verbForm)
|
|
||||||
var spans: [IrregularSpan]?
|
|
||||||
|
|
||||||
init(verbId: Int, tenseId: String, personIndex: Int, form: String, regularity: String) {
|
|
||||||
self.verbId = verbId
|
|
||||||
self.tenseId = tenseId
|
|
||||||
self.personIndex = personIndex
|
|
||||||
self.form = form
|
|
||||||
self.regularity = regularity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum Badge: String, CaseIterable, Identifiable, Sendable {
|
enum Badge: String, CaseIterable, Identifiable, Sendable {
|
||||||
@@ -64,7 +65,11 @@ enum Badge: String, CaseIterable, Identifiable, Sendable {
|
|||||||
struct AchievementService: Sendable {
|
struct AchievementService: Sendable {
|
||||||
|
|
||||||
/// Check all badges and return any newly earned ones.
|
/// Check all badges and return any newly earned ones.
|
||||||
static func checkAchievements(progress: UserProgress, context: ModelContext) -> [Badge] {
|
static func checkAchievements(
|
||||||
|
progress: UserProgress,
|
||||||
|
reviewContext: ModelContext,
|
||||||
|
referenceContext: ModelContext
|
||||||
|
) -> [Badge] {
|
||||||
var newBadges: [Badge] = []
|
var newBadges: [Badge] = []
|
||||||
|
|
||||||
for badge in Badge.allCases {
|
for badge in Badge.allCases {
|
||||||
@@ -81,15 +86,25 @@ struct AchievementService: Sendable {
|
|||||||
case .streak30:
|
case .streak30:
|
||||||
earned = progress.currentStreak >= 30
|
earned = progress.currentStreak >= 30
|
||||||
case .verbs25:
|
case .verbs25:
|
||||||
earned = uniqueVerbsReviewed(context: context) >= 25
|
earned = uniqueVerbsReviewed(context: reviewContext) >= 25
|
||||||
case .verbs100:
|
case .verbs100:
|
||||||
earned = uniqueVerbsReviewed(context: context) >= 100
|
earned = uniqueVerbsReviewed(context: reviewContext) >= 100
|
||||||
case .presentMaster:
|
case .presentMaster:
|
||||||
earned = hasMasteredTense(TenseID.ind_presente.rawValue, level: VerbLevel.basic.rawValue, context: context)
|
earned = hasMasteredTense(
|
||||||
|
TenseID.ind_presente.rawValue,
|
||||||
|
level: VerbLevel.basic.rawValue,
|
||||||
|
reviewContext: reviewContext,
|
||||||
|
referenceContext: referenceContext
|
||||||
|
)
|
||||||
case .preteriteMaster:
|
case .preteriteMaster:
|
||||||
earned = hasMasteredTense(TenseID.ind_preterito.rawValue, level: VerbLevel.basic.rawValue, context: context)
|
earned = hasMasteredTense(
|
||||||
|
TenseID.ind_preterito.rawValue,
|
||||||
|
level: VerbLevel.basic.rawValue,
|
||||||
|
reviewContext: reviewContext,
|
||||||
|
referenceContext: referenceContext
|
||||||
|
)
|
||||||
case .allTenses:
|
case .allTenses:
|
||||||
earned = hasUsedAllTenses(context: context)
|
earned = hasUsedAllTenses(context: reviewContext)
|
||||||
case .daily50:
|
case .daily50:
|
||||||
earned = progress.todayCount >= 50
|
earned = progress.todayCount >= 50
|
||||||
}
|
}
|
||||||
@@ -114,8 +129,13 @@ struct AchievementService: Sendable {
|
|||||||
return uniqueVerbs.count
|
return uniqueVerbs.count
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hasMasteredTense(_ tenseId: String, level: String, context: ModelContext) -> Bool {
|
private static func hasMasteredTense(
|
||||||
let verbIds = Set(ReferenceStore(context: context).fetchVerbs(selectedLevel: level).map(\.id))
|
_ tenseId: String,
|
||||||
|
level: String,
|
||||||
|
reviewContext: ModelContext,
|
||||||
|
referenceContext: ModelContext
|
||||||
|
) -> Bool {
|
||||||
|
let verbIds = Set(ReferenceStore(context: referenceContext).fetchVerbs(selectedLevel: level).map(\.id))
|
||||||
guard !verbIds.isEmpty else { return false }
|
guard !verbIds.isEmpty else { return false }
|
||||||
|
|
||||||
let cardDescriptor = FetchDescriptor<ReviewCard>(
|
let cardDescriptor = FetchDescriptor<ReviewCard>(
|
||||||
@@ -123,7 +143,7 @@ struct AchievementService: Sendable {
|
|||||||
card.tenseId == tenseId && card.interval >= 21
|
card.tenseId == tenseId && card.interval >= 21
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let cards = (try? context.fetch(cardDescriptor)) ?? []
|
let cards = (try? reviewContext.fetch(cardDescriptor)) ?? []
|
||||||
let masteredVerbIds = Set(cards.map(\.verbId))
|
let masteredVerbIds = Set(cards.map(\.verbId))
|
||||||
|
|
||||||
return verbIds.isSubset(of: masteredVerbIds)
|
return verbIds.isSubset(of: masteredVerbIds)
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ actor DataLoader {
|
|||||||
static func seedIfNeeded(container: ModelContainer) async {
|
static func seedIfNeeded(container: ModelContainer) async {
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
var descriptor = FetchDescriptor<Verb>()
|
let count: Int
|
||||||
descriptor.fetchLimit = 1
|
do {
|
||||||
let count = (try? context.fetchCount(descriptor)) ?? 0
|
count = try context.fetchCount(FetchDescriptor<Verb>())
|
||||||
|
print("[DataLoader] seedIfNeeded: existing verb count = \(count)")
|
||||||
|
} catch {
|
||||||
|
print("[DataLoader] ⚠️ seedIfNeeded fetchCount threw: \(error)")
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
if count > 0 { return }
|
if count > 0 { return }
|
||||||
|
|
||||||
print("Seeding database...")
|
print("Seeding database...")
|
||||||
@@ -104,10 +109,14 @@ actor DataLoader {
|
|||||||
print("Inserted \(spans.count) irregular spans")
|
print("Inserted \(spans.count) irregular spans")
|
||||||
}
|
}
|
||||||
|
|
||||||
try? context.save()
|
do {
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
print("[DataLoader] 🔥 Final verb save error: \(error)")
|
||||||
|
}
|
||||||
print("Verb seeding complete")
|
print("Verb seeding complete")
|
||||||
|
|
||||||
// Seed course data
|
// Seed course data (uses the same mainContext so @Query sees it)
|
||||||
seedCourseData(context: context)
|
seedCourseData(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,16 +144,20 @@ actor DataLoader {
|
|||||||
print("Course data re-seeded to version \(currentVersion)")
|
print("Course data re-seeded to version \(currentVersion)")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func migrateCourseProgressIfNeeded(container: ModelContainer) async {
|
static func migrateCourseProgressIfNeeded(
|
||||||
let migrationVersion = 1
|
localContainer: ModelContainer,
|
||||||
|
cloudContainer: ModelContainer
|
||||||
|
) async {
|
||||||
|
let migrationVersion = 2
|
||||||
let key = "courseProgressMigrationVersion"
|
let key = "courseProgressMigrationVersion"
|
||||||
let shared = UserDefaults.standard
|
let shared = UserDefaults.standard
|
||||||
|
|
||||||
if shared.integer(forKey: key) >= migrationVersion { return }
|
if shared.integer(forKey: key) >= migrationVersion { return }
|
||||||
|
|
||||||
let context = ModelContext(container)
|
let localContext = ModelContext(localContainer)
|
||||||
|
let cloudContext = ModelContext(cloudContainer)
|
||||||
let descriptor = FetchDescriptor<VocabCard>()
|
let descriptor = FetchDescriptor<VocabCard>()
|
||||||
let allCards = (try? context.fetch(descriptor)) ?? []
|
let allCards = (try? localContext.fetch(descriptor)) ?? []
|
||||||
var migratedCount = 0
|
var migratedCount = 0
|
||||||
|
|
||||||
for card in allCards where hasLegacyCourseProgress(card) {
|
for card in allCards where hasLegacyCourseProgress(card) {
|
||||||
@@ -154,7 +167,7 @@ actor DataLoader {
|
|||||||
deckId: card.deckId,
|
deckId: card.deckId,
|
||||||
front: card.front,
|
front: card.front,
|
||||||
back: card.back,
|
back: card.back,
|
||||||
context: context
|
context: cloudContext
|
||||||
)
|
)
|
||||||
|
|
||||||
if let reviewDate = reviewCard.lastReviewDate,
|
if let reviewDate = reviewCard.lastReviewDate,
|
||||||
@@ -172,7 +185,7 @@ actor DataLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if migratedCount > 0 {
|
if migratedCount > 0 {
|
||||||
try? context.save()
|
try? cloudContext.save()
|
||||||
print("Migrated \(migratedCount) course progress cards to cloud store")
|
print("Migrated \(migratedCount) course progress cards to cloud store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct PracticeSettings: Sendable {
|
struct PracticeSettings: Sendable {
|
||||||
@@ -33,16 +34,18 @@ struct FullTablePrompt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct PracticeSessionService {
|
struct PracticeSessionService {
|
||||||
let context: ModelContext
|
let localContext: ModelContext
|
||||||
|
let cloudContext: ModelContext
|
||||||
private let referenceStore: ReferenceStore
|
private let referenceStore: ReferenceStore
|
||||||
|
|
||||||
init(context: ModelContext) {
|
init(localContext: ModelContext, cloudContext: ModelContext) {
|
||||||
self.context = context
|
self.localContext = localContext
|
||||||
self.referenceStore = ReferenceStore(context: context)
|
self.cloudContext = cloudContext
|
||||||
|
self.referenceStore = ReferenceStore(context: localContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func settings() -> PracticeSettings {
|
func settings() -> PracticeSettings {
|
||||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: context))
|
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
|
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
|
||||||
@@ -94,7 +97,8 @@ struct PracticeSessionService {
|
|||||||
tenseId: tenseId,
|
tenseId: tenseId,
|
||||||
personIndex: personIndex,
|
personIndex: personIndex,
|
||||||
quality: quality,
|
quality: quality,
|
||||||
context: context
|
context: cloudContext,
|
||||||
|
referenceContext: localContext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +107,8 @@ struct PracticeSessionService {
|
|||||||
verbId: verbId,
|
verbId: verbId,
|
||||||
tenseId: tenseId,
|
tenseId: tenseId,
|
||||||
results: results,
|
results: results,
|
||||||
context: context
|
context: cloudContext,
|
||||||
|
referenceContext: localContext
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +155,7 @@ struct PracticeSessionService {
|
|||||||
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
|
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
|
||||||
)
|
)
|
||||||
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
||||||
let cards = (try? context.fetch(descriptor)) ?? []
|
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||||
|
|
||||||
return cards.first { card in
|
return cards.first { card in
|
||||||
allowedVerbIds.contains(card.verbId) &&
|
allowedVerbIds.contains(card.verbId) &&
|
||||||
@@ -167,7 +172,7 @@ struct PracticeSessionService {
|
|||||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||||
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
|
sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
|
||||||
)
|
)
|
||||||
let cards = ((try? context.fetch(descriptor)) ?? []).filter { card in
|
let cards = ((try? cloudContext.fetch(descriptor)) ?? []).filter { card in
|
||||||
allowedVerbIds.contains(card.verbId) &&
|
allowedVerbIds.contains(card.verbId) &&
|
||||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
||||||
(settings.showVosotros || card.personIndex != 4)
|
(settings.showVosotros || card.personIndex != 4)
|
||||||
@@ -203,7 +208,7 @@ struct PracticeSessionService {
|
|||||||
)
|
)
|
||||||
descriptor.fetchLimit = 500
|
descriptor.fetchLimit = 500
|
||||||
|
|
||||||
let spans = ((try? context.fetch(descriptor)) ?? []).filter { span in
|
let spans = ((try? localContext.fetch(descriptor)) ?? []).filter { span in
|
||||||
allowedVerbIds.contains(span.verbId) &&
|
allowedVerbIds.contains(span.verbId) &&
|
||||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
|
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
|
||||||
(settings.showVosotros || span.personIndex != 4)
|
(settings.showVosotros || span.personIndex != 4)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct ReferenceStore {
|
struct ReferenceStore {
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ struct ReviewStore {
|
|||||||
tenseId: String,
|
tenseId: String,
|
||||||
personIndex: Int,
|
personIndex: Int,
|
||||||
quality: ReviewQuality,
|
quality: ReviewQuality,
|
||||||
context: ModelContext
|
context: ModelContext,
|
||||||
|
referenceContext: ModelContext
|
||||||
) -> [Badge] {
|
) -> [Badge] {
|
||||||
let card = fetchOrCreateReviewCard(
|
let card = fetchOrCreateReviewCard(
|
||||||
verbId: verbId,
|
verbId: verbId,
|
||||||
@@ -128,7 +129,11 @@ struct ReviewStore {
|
|||||||
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
|
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
let badges = AchievementService.checkAchievements(progress: progress, context: context)
|
let badges = AchievementService.checkAchievements(
|
||||||
|
progress: progress,
|
||||||
|
reviewContext: context,
|
||||||
|
referenceContext: referenceContext
|
||||||
|
)
|
||||||
try? context.save()
|
try? context.save()
|
||||||
return badges
|
return badges
|
||||||
}
|
}
|
||||||
@@ -137,7 +142,8 @@ struct ReviewStore {
|
|||||||
verbId: Int,
|
verbId: Int,
|
||||||
tenseId: String,
|
tenseId: String,
|
||||||
results: [Int: Bool],
|
results: [Int: Bool],
|
||||||
context: ModelContext
|
context: ModelContext,
|
||||||
|
referenceContext: ModelContext
|
||||||
) -> [Badge] {
|
) -> [Badge] {
|
||||||
for (personIndex, isCorrect) in results {
|
for (personIndex, isCorrect) in results {
|
||||||
let card = fetchOrCreateReviewCard(
|
let card = fetchOrCreateReviewCard(
|
||||||
@@ -155,7 +161,11 @@ struct ReviewStore {
|
|||||||
correctIncrement: allCorrect ? 1 : 0,
|
correctIncrement: allCorrect ? 1 : 0,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
let badges = AchievementService.checkAchievements(progress: progress, context: context)
|
let badges = AchievementService.checkAchievements(
|
||||||
|
progress: progress,
|
||||||
|
reviewContext: context,
|
||||||
|
referenceContext: referenceContext
|
||||||
|
)
|
||||||
try? context.save()
|
try? context.save()
|
||||||
return badges
|
return badges
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,24 @@ import SharedModels
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum StartupCoordinator {
|
enum StartupCoordinator {
|
||||||
|
/// First-launch work that must complete before the UI can be shown.
|
||||||
|
/// Both calls are self-gating: they return immediately if the work is already done.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func run(container: ModelContainer) async {
|
static func bootstrap(localContainer: ModelContainer) async {
|
||||||
await DataLoader.seedIfNeeded(container: container)
|
await DataLoader.seedIfNeeded(container: localContainer)
|
||||||
await DataLoader.refreshCourseDataIfNeeded(container: container)
|
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||||
await DataLoader.migrateCourseProgressIfNeeded(container: container)
|
}
|
||||||
|
|
||||||
let context = container.mainContext
|
/// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
|
||||||
|
/// Safe to run in the background after the UI is visible.
|
||||||
|
@MainActor
|
||||||
|
static func runMaintenance(localContainer: ModelContainer, cloudContainer: ModelContainer) async {
|
||||||
|
await DataLoader.migrateCourseProgressIfNeeded(
|
||||||
|
localContainer: localContainer,
|
||||||
|
cloudContainer: cloudContainer
|
||||||
|
)
|
||||||
|
|
||||||
|
let context = cloudContainer.mainContext
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
|
||||||
progress.migrateLegacyStorageIfNeeded()
|
progress.migrateLegacyStorageIfNeeded()
|
||||||
if progress.enabledTenseIDs.isEmpty {
|
if progress.enabledTenseIDs.isEmpty {
|
||||||
|
|||||||
64
Conjuga/Conjuga/Services/StoreInspector.swift
Normal file
64
Conjuga/Conjuga/Services/StoreInspector.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite3
|
||||||
|
|
||||||
|
/// Read-only SQLite inspector for diagnosing SwiftData store state.
|
||||||
|
/// Does NOT modify the file — just reads `sqlite_master` and a couple of counts.
|
||||||
|
enum StoreInspector {
|
||||||
|
static func dump(at url: URL, label: String) {
|
||||||
|
let path = url.path
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else {
|
||||||
|
print("[StoreInspector:\(label)] file does not exist at \(path)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var db: OpaquePointer?
|
||||||
|
defer { if let db = db { sqlite3_close(db) } }
|
||||||
|
|
||||||
|
let openResult = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil)
|
||||||
|
guard openResult == SQLITE_OK else {
|
||||||
|
print("[StoreInspector:\(label)] open failed code=\(openResult)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// List tables
|
||||||
|
let tables = queryStrings(db: db, sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||||
|
let hasZVERB = tables.contains("ZVERB")
|
||||||
|
let hasZVERBFORM = tables.contains("ZVERBFORM")
|
||||||
|
let hasZTENSEGUIDE = tables.contains("ZTENSEGUIDE")
|
||||||
|
let hasZVOCABCARD = tables.contains("ZVOCABCARD")
|
||||||
|
|
||||||
|
var summary = "[StoreInspector:\(label)] \(tables.count) tables"
|
||||||
|
summary += " | ZVERB=\(hasZVERB ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERB") : -1)"
|
||||||
|
summary += " ZVERBFORM=\(hasZVERBFORM ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVERBFORM") : -1)"
|
||||||
|
summary += " ZTENSEGUIDE=\(hasZTENSEGUIDE ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZTENSEGUIDE") : -1)"
|
||||||
|
summary += " ZVOCABCARD=\(hasZVOCABCARD ? queryInt(db: db, sql: "SELECT COUNT(*) FROM ZVOCABCARD") : -1)"
|
||||||
|
print(summary)
|
||||||
|
|
||||||
|
// Log all Z-tables (SwiftData entity tables start with Z, minus Core Data system tables)
|
||||||
|
let zTables = tables.filter { $0.hasPrefix("Z") && !$0.hasPrefix("Z_") }
|
||||||
|
if !zTables.isEmpty {
|
||||||
|
print("[StoreInspector:\(label)] entity tables: \(zTables.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func queryStrings(db: OpaquePointer?, sql: String) -> [String] {
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
defer { if let stmt = stmt { sqlite3_finalize(stmt) } }
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||||
|
var results: [String] = []
|
||||||
|
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||||
|
if let cstr = sqlite3_column_text(stmt, 0) {
|
||||||
|
results.append(String(cString: cstr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func queryInt(db: OpaquePointer?, sql: String) -> Int {
|
||||||
|
var stmt: OpaquePointer?
|
||||||
|
defer { if let stmt = stmt { sqlite3_finalize(stmt) } }
|
||||||
|
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return -1 }
|
||||||
|
guard sqlite3_step(stmt) == SQLITE_ROW else { return -1 }
|
||||||
|
return Int(sqlite3_column_int(stmt, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Conjuga/Conjuga/Services/SyncStatusMonitor.swift
Normal file
42
Conjuga/Conjuga/Services/SyncStatusMonitor.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Tracks whether background maintenance/cloud sync is running and whether the
|
||||||
|
/// device has network connectivity. The sync toast is visible only when both
|
||||||
|
/// are true.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class SyncStatusMonitor {
|
||||||
|
private(set) var isSyncing = false
|
||||||
|
private(set) var isNetworkAvailable = true
|
||||||
|
|
||||||
|
private let pathMonitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "com.conjuga.app.sync-monitor")
|
||||||
|
|
||||||
|
init() {
|
||||||
|
pathMonitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
let available = (path.status == .satisfied)
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.isNetworkAvailable = available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathMonitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
pathMonitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldShowToast: Bool {
|
||||||
|
isSyncing && isNetworkAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginSync() {
|
||||||
|
isSyncing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func endSync() {
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,28 +9,35 @@ struct WidgetDataService {
|
|||||||
static let suiteName = "group.com.conjuga.app"
|
static let suiteName = "group.com.conjuga.app"
|
||||||
static let dataKey = "widgetData"
|
static let dataKey = "widgetData"
|
||||||
|
|
||||||
/// Write current app state to shared storage for widgets to read.
|
static func update(localContainer: ModelContainer, cloudContainer: ModelContainer) {
|
||||||
static func update(context: ModelContext) {
|
let localContext = ModelContext(localContainer)
|
||||||
|
let cloudContext = ModelContext(cloudContainer)
|
||||||
|
update(localContext: localContext, cloudContext: cloudContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func update(localContext: ModelContext, cloudContext: ModelContext) {
|
||||||
guard let shared = UserDefaults(suiteName: suiteName) else { return }
|
guard let shared = UserDefaults(suiteName: suiteName) else { return }
|
||||||
|
|
||||||
// Fetch user progress
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
|
|
||||||
|
|
||||||
// Count due review cards
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let dueDescriptor = FetchDescriptor<ReviewCard>(
|
let dueDescriptor = FetchDescriptor<ReviewCard>(
|
||||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
|
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
|
||||||
)
|
)
|
||||||
let dueCount = (try? context.fetchCount(dueDescriptor)) ?? 0
|
let dueCount = (try? cloudContext.fetchCount(dueDescriptor)) ?? 0
|
||||||
|
|
||||||
var wordOfDay: WordOfDay?
|
var wordOfDay: WordOfDay?
|
||||||
let wordOffset = shared.integer(forKey: "wordOffset")
|
let wordOffset = shared.integer(forKey: "wordOffset")
|
||||||
if let card = CourseCardStore.fetchWordOfDayCard(for: now, wordOffset: wordOffset, context: context) {
|
if let card = CourseCardStore.fetchWordOfDayCard(
|
||||||
|
for: now,
|
||||||
|
wordOffset: wordOffset,
|
||||||
|
context: localContext
|
||||||
|
) {
|
||||||
let deckId = card.deckId
|
let deckId = card.deckId
|
||||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||||
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
||||||
)
|
)
|
||||||
let deck = (try? context.fetch(deckDescriptor))?.first
|
let deck = (try? localContext.fetch(deckDescriptor))?.first
|
||||||
wordOfDay = WordOfDay(
|
wordOfDay = WordOfDay(
|
||||||
spanish: card.front,
|
spanish: card.front,
|
||||||
english: card.back,
|
english: card.back,
|
||||||
@@ -38,13 +45,10 @@ struct WidgetDataService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest test result
|
|
||||||
let testDescriptor = FetchDescriptor<TestResult>(
|
let testDescriptor = FetchDescriptor<TestResult>(
|
||||||
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||||
)
|
)
|
||||||
let latestTest = (try? context.fetch(testDescriptor))?.first
|
let latestTest = (try? cloudContext.fetch(testDescriptor))?.first
|
||||||
|
|
||||||
// Determine current week (from most studied decks or latest test)
|
|
||||||
let currentWeek = latestTest?.weekNumber ?? 1
|
let currentWeek = latestTest?.weekNumber ?? 1
|
||||||
|
|
||||||
let previousData = shared.data(forKey: dataKey)
|
let previousData = shared.data(forKey: dataKey)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum PracticeMode: String, CaseIterable, Identifiable, Sendable {
|
enum PracticeMode: String, CaseIterable, Identifiable, Sendable {
|
||||||
@@ -86,7 +87,7 @@ final class PracticeViewModel {
|
|||||||
|
|
||||||
// MARK: - Load next card
|
// MARK: - Load next card
|
||||||
|
|
||||||
func loadNextCard(context: ModelContext) {
|
func loadNextCard(localContext: ModelContext, cloudContext: ModelContext) {
|
||||||
isAnswerRevealed = false
|
isAnswerRevealed = false
|
||||||
userAnswer = ""
|
userAnswer = ""
|
||||||
isCorrect = nil
|
isCorrect = nil
|
||||||
@@ -94,7 +95,7 @@ final class PracticeViewModel {
|
|||||||
currentSpans = []
|
currentSpans = []
|
||||||
hasCards = true
|
hasCards = true
|
||||||
isLoading = true
|
isLoading = true
|
||||||
let service = PracticeSessionService(context: context)
|
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||||
guard let cardLoad = service.nextCard(for: focusMode) else {
|
guard let cardLoad = service.nextCard(for: focusMode) else {
|
||||||
clearCurrentCard()
|
clearCurrentCard()
|
||||||
hasCards = false
|
hasCards = false
|
||||||
@@ -102,7 +103,7 @@ final class PracticeViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyCardLoad(cardLoad, context: context)
|
applyCardLoad(cardLoad, localContext: localContext)
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +135,9 @@ final class PracticeViewModel {
|
|||||||
|
|
||||||
// MARK: - SRS rating
|
// MARK: - SRS rating
|
||||||
|
|
||||||
func rateAnswer(quality: ReviewQuality, context: ModelContext) {
|
func rateAnswer(quality: ReviewQuality, localContext: ModelContext, cloudContext: ModelContext) {
|
||||||
guard let form = currentForm else { return }
|
guard let form = currentForm else { return }
|
||||||
let service = PracticeSessionService(context: context)
|
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||||
newBadges = service.rate(
|
newBadges = service.rate(
|
||||||
verbId: form.verbId,
|
verbId: form.verbId,
|
||||||
tenseId: form.tenseId,
|
tenseId: form.tenseId,
|
||||||
@@ -208,7 +209,7 @@ final class PracticeViewModel {
|
|||||||
|
|
||||||
// MARK: - Private helpers
|
// MARK: - Private helpers
|
||||||
|
|
||||||
private func applyCardLoad(_ cardLoad: PracticeCardLoad, context: ModelContext) {
|
private func applyCardLoad(_ cardLoad: PracticeCardLoad, localContext: ModelContext) {
|
||||||
currentVerb = cardLoad.verb
|
currentVerb = cardLoad.verb
|
||||||
currentForm = cardLoad.form
|
currentForm = cardLoad.form
|
||||||
currentSpans = cardLoad.spans
|
currentSpans = cardLoad.spans
|
||||||
@@ -216,7 +217,7 @@ final class PracticeViewModel {
|
|||||||
currentPerson = cardLoad.person
|
currentPerson = cardLoad.person
|
||||||
|
|
||||||
if practiceMode == .multipleChoice {
|
if practiceMode == .multipleChoice {
|
||||||
prepareMultipleChoice(for: cardLoad.form, context: context)
|
prepareMultipleChoice(for: cardLoad.form, context: localContext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct IrregularHighlightText: View {
|
struct IrregularHighlightText: View {
|
||||||
let form: String
|
let form: String
|
||||||
|
|||||||
23
Conjuga/Conjuga/Views/Components/SyncToast.swift
Normal file
23
Conjuga/Conjuga/Views/Components/SyncToast.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Compact pill shown at the bottom of the screen while background sync runs.
|
||||||
|
struct SyncToast: View {
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("Syncing")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.glassEffect(in: .capsule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemBackground)
|
||||||
|
SyncToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// Reusable tappable tense pill that shows a tense info sheet when tapped.
|
/// Reusable tappable tense pill that shows a tense info sheet when tapped.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct CourseQuizView: View {
|
|||||||
let weekNumber: Int
|
let weekNumber: Int
|
||||||
let isFocusMode: Bool
|
let isFocusMode: Bool
|
||||||
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ struct CourseQuizView: View {
|
|||||||
@FocusState private var isTypingFocused: Bool
|
@FocusState private var isTypingFocused: Bool
|
||||||
|
|
||||||
private var isComplete: Bool { currentIndex >= shuffledCards.count }
|
private var isComplete: Bool { currentIndex >= shuffledCards.count }
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var currentCard: VocabCard? {
|
private var currentCard: VocabCard? {
|
||||||
guard currentIndex < shuffledCards.count else { return nil }
|
guard currentIndex < shuffledCards.count else { return nil }
|
||||||
@@ -513,8 +514,8 @@ struct CourseQuizView: View {
|
|||||||
correctCount: correctCount,
|
correctCount: correctCount,
|
||||||
missedItems: missedItems
|
missedItems: missedItems
|
||||||
)
|
)
|
||||||
modelContext.insert(result)
|
cloudModelContext.insert(result)
|
||||||
try? modelContext.save()
|
try? cloudModelContext.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import SharedModels
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct CourseView: View {
|
struct CourseView: View {
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
||||||
@Query private var testResults: [TestResult]
|
|
||||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||||
|
@State private var testResults: [TestResult] = []
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var courseNames: [String] {
|
private var courseNames: [String] {
|
||||||
let names = Set(decks.map(\.courseName))
|
let names = Set(decks.map(\.courseName))
|
||||||
@@ -105,6 +108,7 @@ struct CourseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||||
|
.onAppear(perform: loadTestResults)
|
||||||
.navigationDestination(for: CourseDeck.self) { deck in
|
.navigationDestination(for: CourseDeck.self) { deck in
|
||||||
DeckStudyView(deck: deck)
|
DeckStudyView(deck: deck)
|
||||||
}
|
}
|
||||||
@@ -113,6 +117,10 @@ struct CourseView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadTestResults() {
|
||||||
|
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deck Row
|
// MARK: - Deck Row
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ struct VocabFlashcardView: View {
|
|||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@State private var currentIndex = 0
|
@State private var currentIndex = 0
|
||||||
@State private var isRevealed = false
|
@State private var isRevealed = false
|
||||||
@State private var sessionCorrect = 0
|
@State private var sessionCorrect = 0
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var currentCard: VocabCard? {
|
private var currentCard: VocabCard? {
|
||||||
guard currentIndex < cards.count else { return nil }
|
guard currentIndex < cards.count else { return nil }
|
||||||
return cards[currentIndex]
|
return cards[currentIndex]
|
||||||
@@ -180,7 +182,7 @@ struct VocabFlashcardView: View {
|
|||||||
|
|
||||||
private func rateAndAdvance(quality: ReviewQuality) {
|
private func rateAndAdvance(quality: ReviewQuality) {
|
||||||
guard let card = currentCard else { return }
|
guard let card = currentCard else { return }
|
||||||
CourseReviewStore(context: modelContext).rate(card: card, quality: quality)
|
CourseReviewStore(context: cloudModelContext).rate(card: card, quality: quality)
|
||||||
|
|
||||||
if quality.rawValue >= 3 { sessionCorrect += 1 }
|
if quality.rawValue >= 3 { sessionCorrect += 1 }
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ import SwiftData
|
|||||||
struct WeekTestView: View {
|
struct WeekTestView: View {
|
||||||
let weekNumber: Int
|
let weekNumber: Int
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query private var allResults: [TestResult]
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Query private var allDecks: [CourseDeck]
|
@Query private var allDecks: [CourseDeck]
|
||||||
|
|
||||||
@State private var loadedWeekCards: [VocabCard] = []
|
@State private var loadedWeekCards: [VocabCard] = []
|
||||||
|
@State private var weekResults: [TestResult] = []
|
||||||
|
|
||||||
private var weekResults: [TestResult] {
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
allResults
|
|
||||||
.filter { $0.weekNumber == weekNumber }
|
|
||||||
.sorted { $0.dateTaken > $1.dateTaken }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekCards: [VocabCard] {
|
private var weekCards: [VocabCard] {
|
||||||
loadedWeekCards
|
loadedWeekCards
|
||||||
@@ -202,7 +199,10 @@ struct WeekTestView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Week \(weekNumber) Test")
|
.navigationTitle("Week \(weekNumber) Test")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onAppear { loadCards() }
|
.onAppear {
|
||||||
|
loadResults()
|
||||||
|
loadCards()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadCards() {
|
private func loadCards() {
|
||||||
@@ -220,6 +220,14 @@ struct WeekTestView: View {
|
|||||||
loadedWeekCards = cards
|
loadedWeekCards = cards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadResults() {
|
||||||
|
let descriptor = FetchDescriptor<TestResult>(
|
||||||
|
predicate: #Predicate<TestResult> { $0.weekNumber == weekNumber },
|
||||||
|
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||||
|
)
|
||||||
|
weekResults = (try? cloudModelContext.fetch(descriptor)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
private func scoreColor(_ percent: Int) -> Color {
|
private func scoreColor(_ percent: Int) -> Color {
|
||||||
if percent >= 90 { return .green }
|
if percent >= 90 { return .green }
|
||||||
if percent >= 70 { return .orange }
|
if percent >= 70 { return .orange }
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import SwiftData
|
|||||||
import Charts
|
import Charts
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@Query private var progress: [UserProgress]
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Query(sort: \DailyLog.dateString, order: .reverse) private var dailyLogs: [DailyLog]
|
@State private var userProgress: UserProgress?
|
||||||
@Query private var testResults: [TestResult]
|
@State private var dailyLogs: [DailyLog] = []
|
||||||
@Query private var reviewCards: [ReviewCard]
|
@State private var testResults: [TestResult] = []
|
||||||
|
@State private var reviewCards: [ReviewCard] = []
|
||||||
|
|
||||||
private var userProgress: UserProgress? { progress.first }
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -32,6 +33,7 @@ struct DashboardView: View {
|
|||||||
.adaptiveContainer(maxWidth: 800)
|
.adaptiveContainer(maxWidth: 800)
|
||||||
}
|
}
|
||||||
.navigationTitle("Dashboard")
|
.navigationTitle("Dashboard")
|
||||||
|
.onAppear(perform: loadData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +168,17 @@ struct DashboardView: View {
|
|||||||
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
|
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
|
||||||
return Int(total / Double(logsWithData.count) * 100)
|
return Int(total / Double(logsWithData.count) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadData() {
|
||||||
|
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
|
let dailyDescriptor = FetchDescriptor<DailyLog>(
|
||||||
|
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
|
||||||
|
)
|
||||||
|
dailyLogs = (try? cloudModelContext.fetch(dailyDescriptor)) ?? []
|
||||||
|
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||||
|
reviewCards = (try? cloudModelContext.fetch(FetchDescriptor<ReviewCard>())) ?? []
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stat Card
|
// MARK: - Stat Card
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct GuideView: View {
|
struct GuideView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -38,6 +39,7 @@ struct GuideView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Guide")
|
.navigationTitle("Guide")
|
||||||
|
.task { loadGuides() }
|
||||||
.onAppear(perform: loadGuides)
|
.onAppear(perform: loadGuides)
|
||||||
.onChange(of: selectedTab) { _, _ in
|
.onChange(of: selectedTab) { _, _ in
|
||||||
selectedGuide = nil
|
selectedGuide = nil
|
||||||
@@ -78,7 +80,14 @@ struct GuideView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadGuides() {
|
private func loadGuides() {
|
||||||
guides = ReferenceStore(context: modelContext).fetchGuides()
|
// Hit the shared local container directly, bypassing @Environment.
|
||||||
|
guard let container = SharedStore.localContainer else {
|
||||||
|
print("[GuideView] ⚠️ SharedStore.localContainer is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let context = ModelContext(container)
|
||||||
|
guides = ReferenceStore(context: context).fetchGuides()
|
||||||
|
print("[GuideView] loaded \(guides.count) tense guides (container: \(ObjectIdentifier(container)))")
|
||||||
if selectedGuide == nil {
|
if selectedGuide == nil {
|
||||||
selectedGuide = guides.first
|
selectedGuide = guides.first
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
@State private var selectedLevel: VerbLevel = .basic
|
@State private var selectedLevel: VerbLevel = .basic
|
||||||
|
|
||||||
private let levels = VerbLevel.allCases
|
private let levels = VerbLevel.allCases
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $currentPage) {
|
TabView(selection: $currentPage) {
|
||||||
@@ -125,12 +127,12 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func completeOnboarding() {
|
private func completeOnboarding() {
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
progress.selectedVerbLevel = selectedLevel
|
progress.selectedVerbLevel = selectedLevel
|
||||||
if progress.enabledTenseIDs.isEmpty {
|
if progress.enabledTenseIDs.isEmpty {
|
||||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||||
}
|
}
|
||||||
try? modelContext.save()
|
try? cloudModelContext.save()
|
||||||
onboardingComplete = true
|
onboardingComplete = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct AnswerReviewView: View {
|
struct AnswerReviewView: View {
|
||||||
let form: VerbForm?
|
let form: VerbForm?
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct FlashcardView: View {
|
struct FlashcardView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
var viewModel: PracticeViewModel
|
var viewModel: PracticeViewModel
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
@@ -72,12 +76,22 @@ struct FlashcardView: View {
|
|||||||
spans: viewModel.currentSpans,
|
spans: viewModel.currentSpans,
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onRate: { quality in
|
onRate: { quality in
|
||||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
viewModel.rateAnswer(
|
||||||
viewModel.loadNextCard(context: modelContext)
|
quality: quality,
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
},
|
},
|
||||||
showAnswer: false,
|
showAnswer: false,
|
||||||
onNext: {
|
onNext: {
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
|
||||||
/// Practice mode where user fills in all 6 person conjugations for a verb + tense.
|
/// Practice mode where user fills in all 6 person conjugations for a verb + tense.
|
||||||
struct FullTableView: View {
|
struct FullTableView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
|
|
||||||
@State private var currentVerb: Verb?
|
@State private var currentVerb: Verb?
|
||||||
@@ -27,6 +29,7 @@ struct FullTableView: View {
|
|||||||
@FocusState private var focusedField: Int?
|
@FocusState private var focusedField: Int?
|
||||||
|
|
||||||
private let persons = TenseInfo.persons
|
private let persons = TenseInfo.persons
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
private var personsToShow: [(index: Int, label: String)] {
|
private var personsToShow: [(index: Int, label: String)] {
|
||||||
persons.enumerated().compactMap { index, label in
|
persons.enumerated().compactMap { index, label in
|
||||||
@@ -240,7 +243,7 @@ struct FullTableView: View {
|
|||||||
results = Array(repeating: nil, count: 6)
|
results = Array(repeating: nil, count: 6)
|
||||||
correctForms = []
|
correctForms = []
|
||||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||||
let service = PracticeSessionService(context: modelContext)
|
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||||
guard let prompt = service.randomFullTablePrompt() else {
|
guard let prompt = service.randomFullTablePrompt() else {
|
||||||
currentVerb = nil
|
currentVerb = nil
|
||||||
currentTense = nil
|
currentTense = nil
|
||||||
@@ -309,7 +312,7 @@ struct FullTableView: View {
|
|||||||
if allCorrect { sessionCorrect += 1 }
|
if allCorrect { sessionCorrect += 1 }
|
||||||
|
|
||||||
if let verb = currentVerb, let tense = currentTense {
|
if let verb = currentVerb, let tense = currentTense {
|
||||||
let service = PracticeSessionService(context: modelContext)
|
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||||
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
|
||||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||||
}
|
}
|
||||||
@@ -328,7 +331,7 @@ struct FullTableView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadSettings() {
|
private func loadSettings() {
|
||||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
showVosotros = progress.showVosotros
|
showVosotros = progress.showVosotros
|
||||||
autoFillStem = progress.autoFillStem
|
autoFillStem = progress.autoFillStem
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
|
||||||
struct HandwritingView: View {
|
struct HandwritingView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
var viewModel: PracticeViewModel
|
var viewModel: PracticeViewModel
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ struct HandwritingView: View {
|
|||||||
@State private var recognizedText = ""
|
@State private var recognizedText = ""
|
||||||
@State private var isRecognizing = false
|
@State private var isRecognizing = false
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -50,12 +54,22 @@ struct HandwritingView: View {
|
|||||||
spans: viewModel.currentSpans,
|
spans: viewModel.currentSpans,
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onRate: { quality in
|
onRate: { quality in
|
||||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
viewModel.rateAnswer(
|
||||||
viewModel.loadNextCard(context: modelContext)
|
quality: quality,
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
resetCanvas()
|
resetCanvas()
|
||||||
},
|
},
|
||||||
onNext: {
|
onNext: {
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
resetCanvas()
|
resetCanvas()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct MultipleChoiceView: View {
|
struct MultipleChoiceView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
var viewModel: PracticeViewModel
|
var viewModel: PracticeViewModel
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
@State private var selectedIndex: Int?
|
@State private var selectedIndex: Int?
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
@@ -41,13 +45,23 @@ struct MultipleChoiceView: View {
|
|||||||
spans: viewModel.currentSpans,
|
spans: viewModel.currentSpans,
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onRate: { quality in
|
onRate: { quality in
|
||||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
viewModel.rateAnswer(
|
||||||
|
quality: quality,
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
selectedIndex = nil
|
selectedIndex = nil
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onNext: {
|
onNext: {
|
||||||
selectedIndex = nil
|
selectedIndex = nil
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct PracticeHeaderView: View {
|
struct PracticeHeaderView: View {
|
||||||
let verb: Verb?
|
let verb: Verb?
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct PracticeView: View {
|
struct PracticeView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query private var progress: [UserProgress]
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@State private var viewModel = PracticeViewModel()
|
@State private var viewModel = PracticeViewModel()
|
||||||
@State private var speechService = SpeechService()
|
@State private var speechService = SpeechService()
|
||||||
@State private var isPracticing = false
|
@State private var isPracticing = false
|
||||||
|
@State private var userProgress: UserProgress?
|
||||||
|
|
||||||
private var userProgress: UserProgress? {
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
progress.first
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -23,6 +23,12 @@ struct PracticeView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Practice")
|
.navigationTitle("Practice")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear(perform: loadProgress)
|
||||||
|
.onChange(of: isPracticing) { _, practicing in
|
||||||
|
if !practicing {
|
||||||
|
loadProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if isPracticing {
|
if isPracticing {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -80,7 +86,10 @@ struct PracticeView: View {
|
|||||||
viewModel.focusMode = .none
|
viewModel.focusMode = .none
|
||||||
viewModel.sessionCorrect = 0
|
viewModel.sessionCorrect = 0
|
||||||
viewModel.sessionTotal = 0
|
viewModel.sessionTotal = 0
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
withAnimation {
|
withAnimation {
|
||||||
isPracticing = true
|
isPracticing = true
|
||||||
}
|
}
|
||||||
@@ -101,7 +110,10 @@ struct PracticeView: View {
|
|||||||
viewModel.focusMode = .weakVerbs
|
viewModel.focusMode = .weakVerbs
|
||||||
viewModel.sessionCorrect = 0
|
viewModel.sessionCorrect = 0
|
||||||
viewModel.sessionTotal = 0
|
viewModel.sessionTotal = 0
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
withAnimation { isPracticing = true }
|
withAnimation { isPracticing = true }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
@@ -311,9 +323,15 @@ extension PracticeView {
|
|||||||
viewModel.focusMode = .irregularity(filter)
|
viewModel.focusMode = .irregularity(filter)
|
||||||
viewModel.sessionCorrect = 0
|
viewModel.sessionCorrect = 0
|
||||||
viewModel.sessionTotal = 0
|
viewModel.sessionTotal = 0
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||||
withAnimation { isPracticing = true }
|
withAnimation { isPracticing = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadProgress() {
|
||||||
|
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
|
userProgress = progress
|
||||||
|
try? cloudModelContext.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct TypingView: View {
|
struct TypingView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@Bindable var viewModel: PracticeViewModel
|
@Bindable var viewModel: PracticeViewModel
|
||||||
let speechService: SpeechService
|
let speechService: SpeechService
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
|
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
@@ -78,12 +82,22 @@ struct TypingView: View {
|
|||||||
spans: viewModel.currentSpans,
|
spans: viewModel.currentSpans,
|
||||||
speechService: speechService,
|
speechService: speechService,
|
||||||
onRate: { quality in
|
onRate: { quality in
|
||||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
viewModel.rateAnswer(
|
||||||
viewModel.loadNextCard(context: modelContext)
|
quality: quality,
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
},
|
},
|
||||||
onNext: {
|
onNext: {
|
||||||
viewModel.loadNextCard(context: modelContext)
|
viewModel.loadNextCard(
|
||||||
|
localContext: modelContext,
|
||||||
|
cloudContext: cloudModelContext
|
||||||
|
)
|
||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||||
@State private var progress: UserProgress?
|
@State private var progress: UserProgress?
|
||||||
|
|
||||||
@State private var dailyGoal: Double = 50
|
@State private var dailyGoal: Double = 50
|
||||||
@@ -11,6 +12,7 @@ struct SettingsView: View {
|
|||||||
@State private var selectedLevel: VerbLevel = .basic
|
@State private var selectedLevel: VerbLevel = .basic
|
||||||
|
|
||||||
private let levels = VerbLevel.allCases
|
private let levels = VerbLevel.allCases
|
||||||
|
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -83,7 +85,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadProgress() {
|
private func loadProgress() {
|
||||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||||
progress = resolved
|
progress = resolved
|
||||||
dailyGoal = Double(resolved.dailyGoal)
|
dailyGoal = Double(resolved.dailyGoal)
|
||||||
showVosotros = resolved.showVosotros
|
showVosotros = resolved.showVosotros
|
||||||
@@ -92,7 +94,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveProgress() {
|
private func saveProgress() {
|
||||||
try? modelContext.save()
|
try? cloudModelContext.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SharedModels
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct VerbDetailView: View {
|
struct VerbDetailView: View {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import SharedModels
|
||||||
|
|
||||||
struct VerbListView: View {
|
struct VerbListView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \Verb.infinitive) private var verbs: [Verb]
|
@State private var verbs: [Verb] = []
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var selectedLevel: String?
|
@State private var selectedLevel: String?
|
||||||
@State private var selectedVerb: Verb?
|
@State private var selectedVerb: Verb?
|
||||||
@@ -46,6 +47,8 @@ struct VerbListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task { loadVerbs() }
|
||||||
|
.onAppear { loadVerbs() }
|
||||||
} detail: {
|
} detail: {
|
||||||
if let verb = selectedVerb {
|
if let verb = selectedVerb {
|
||||||
VerbDetailView(verb: verb)
|
VerbDetailView(verb: verb)
|
||||||
@@ -54,6 +57,20 @@ struct VerbListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func loadVerbs() {
|
||||||
|
// Hit the shared local container directly, bypassing @Environment.
|
||||||
|
guard let container = SharedStore.localContainer else {
|
||||||
|
print("[VerbListView] ⚠️ SharedStore.localContainer is nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let url = SharedStore.localStoreURL() {
|
||||||
|
StoreInspector.dump(at: url, label: "verb-list-load")
|
||||||
|
}
|
||||||
|
let context = ModelContext(container)
|
||||||
|
verbs = ReferenceStore(context: context).fetchVerbs()
|
||||||
|
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VerbRowView: View {
|
struct VerbRowView: View {
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
import os
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "CombinedWidget")
|
|
||||||
|
|
||||||
struct CombinedEntry: TimelineEntry {
|
struct CombinedEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
@@ -24,13 +21,13 @@ struct CombinedProvider: TimelineProvider {
|
|||||||
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
|
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
let word = fetchWordOfDay(for: Date())
|
||||||
let data = WidgetDataReader.read()
|
let data = WidgetDataReader.read()
|
||||||
completion(CombinedEntry(date: Date(), word: word, data: data))
|
completion(CombinedEntry(date: Date(), word: word, data: data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
|
||||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
let word = fetchWordOfDay(for: Date())
|
||||||
let data = WidgetDataReader.read()
|
let data = WidgetDataReader.read()
|
||||||
let entry = CombinedEntry(date: Date(), word: word, data: data)
|
let entry = CombinedEntry(date: Date(), word: word, data: data)
|
||||||
|
|
||||||
@@ -42,23 +39,24 @@ struct CombinedProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||||
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
|
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||||
logger.info("Combined store path: \(localURL.path), exists: \(FileManager.default.fileExists(atPath: localURL.path))")
|
|
||||||
|
|
||||||
if !FileManager.default.fileExists(atPath: localURL.path) {
|
|
||||||
let dir = localURL.deletingLastPathComponent()
|
|
||||||
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
|
|
||||||
logger.error("local.store NOT FOUND. Contents: \(contents.joined(separator: ", "))")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// MUST declare all 6 local entities to match the main app's schema.
|
||||||
|
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||||
|
// on open, dropping the entities not listed here.
|
||||||
|
let config = ModelConfiguration(
|
||||||
|
"local",
|
||||||
|
schema: Schema([
|
||||||
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
]),
|
||||||
|
url: localURL,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
guard let container = try? ModelContainer(
|
guard let container = try? ModelContainer(
|
||||||
for: VocabCard.self, CourseDeck.self,
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
configurations: ModelConfiguration(
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
"local",
|
configurations: config
|
||||||
url: localURL,
|
|
||||||
cloudKitDatabase: .none
|
|
||||||
)
|
|
||||||
) else { return nil }
|
) else { return nil }
|
||||||
|
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
@@ -71,9 +69,20 @@ struct CombinedProvider: TimelineProvider {
|
|||||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||||
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
||||||
)
|
)
|
||||||
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
|
let deck = (try? context.fetch(deckDescriptor))?.first
|
||||||
|
let week = deck?.weekNumber ?? 1
|
||||||
|
|
||||||
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
|
// If the deck is reversed (English on front), swap so spanish is always Spanish.
|
||||||
|
let spanish: String
|
||||||
|
let english: String
|
||||||
|
if deck?.isReversed == true {
|
||||||
|
spanish = card.back
|
||||||
|
english = card.front
|
||||||
|
} else {
|
||||||
|
spanish = card.front
|
||||||
|
english = card.back
|
||||||
|
}
|
||||||
|
return WordOfDay(spanish: spanish, english: english, weekNumber: week)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import WidgetKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SharedModels
|
import SharedModels
|
||||||
import os
|
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "WordOfDay")
|
|
||||||
|
|
||||||
struct WordOfDayEntry: TimelineEntry {
|
struct WordOfDayEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
@@ -16,21 +13,16 @@ struct WordOfDayProvider: TimelineProvider {
|
|||||||
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
|
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)
|
|
||||||
|
|
||||||
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
|
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
|
||||||
if context.isPreview {
|
if context.isPreview {
|
||||||
completion(WordOfDayEntry(date: Date(), word: Self.previewWord))
|
completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
|
||||||
completion(WordOfDayEntry(date: Date(), word: word))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
|
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
|
||||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
let entry = WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))
|
||||||
let entry = WordOfDayEntry(date: Date(), word: word)
|
|
||||||
|
|
||||||
let tomorrow = Calendar.current.startOfDay(
|
let tomorrow = Calendar.current.startOfDay(
|
||||||
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||||
)
|
)
|
||||||
@@ -38,48 +30,49 @@ struct WordOfDayProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||||
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
|
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||||
logger.info("Store path: \(localURL.path)")
|
|
||||||
logger.info("Store exists: \(FileManager.default.fileExists(atPath: localURL.path))")
|
|
||||||
|
|
||||||
if !FileManager.default.fileExists(atPath: localURL.path) {
|
// MUST declare all 6 local entities to match the main app's schema.
|
||||||
let dir = localURL.deletingLastPathComponent()
|
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||||
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
|
// on open, dropping the entities not listed here.
|
||||||
logger.error("local.store NOT FOUND. App Support contents: \(contents.joined(separator: ", "))")
|
let config = ModelConfiguration(
|
||||||
|
"local",
|
||||||
|
schema: Schema([
|
||||||
|
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
]),
|
||||||
|
url: localURL,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
|
guard let container = try? ModelContainer(
|
||||||
|
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||||
|
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||||
|
configurations: config
|
||||||
|
) else { return nil }
|
||||||
|
|
||||||
|
let context = ModelContext(container)
|
||||||
|
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
|
||||||
|
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
let deckId = card.deckId
|
||||||
|
let descriptor = FetchDescriptor<CourseDeck>(
|
||||||
|
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
||||||
|
)
|
||||||
|
let deck = (try? context.fetch(descriptor))?.first
|
||||||
|
let week = deck?.weekNumber ?? 1
|
||||||
|
|
||||||
do {
|
// If the deck is reversed (English on front), swap so spanish is always Spanish.
|
||||||
let container = try ModelContainer(
|
let spanish: String
|
||||||
for: VocabCard.self, CourseDeck.self,
|
let english: String
|
||||||
configurations: ModelConfiguration(
|
if deck?.isReversed == true {
|
||||||
"local",
|
spanish = card.back
|
||||||
url: localURL,
|
english = card.front
|
||||||
cloudKitDatabase: .none
|
} else {
|
||||||
)
|
spanish = card.front
|
||||||
)
|
english = card.back
|
||||||
logger.info("ModelContainer opened OK")
|
|
||||||
|
|
||||||
let context = ModelContext(container)
|
|
||||||
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
|
|
||||||
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
|
|
||||||
logger.error("Store has 0 VocabCards")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Picked card: \(card.front) = \(card.back)")
|
|
||||||
|
|
||||||
let deckId = card.deckId
|
|
||||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
|
||||||
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
|
||||||
)
|
|
||||||
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
|
|
||||||
|
|
||||||
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
|
|
||||||
} catch {
|
|
||||||
logger.error("Failed: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return WordOfDay(spanish: spanish, english: english, weekNumber: week)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import SwiftData
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class IrregularSpan {
|
public final class IrregularSpan {
|
||||||
var verbId: Int = 0
|
public var verbId: Int = 0
|
||||||
var tenseId: String = ""
|
public var tenseId: String = ""
|
||||||
var personIndex: Int = 0
|
public var personIndex: Int = 0
|
||||||
var spanType: Int = 0
|
public var spanType: Int = 0
|
||||||
var pattern: Int = 0
|
public var pattern: Int = 0
|
||||||
var start: Int = 0
|
public var start: Int = 0
|
||||||
var end: Int = 0
|
public var end: Int = 0
|
||||||
|
|
||||||
var verbForm: VerbForm?
|
public var verbForm: VerbForm?
|
||||||
|
|
||||||
init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) {
|
public init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) {
|
||||||
self.verbId = verbId
|
self.verbId = verbId
|
||||||
self.tenseId = tenseId
|
self.tenseId = tenseId
|
||||||
self.personIndex = personIndex
|
self.personIndex = personIndex
|
||||||
@@ -23,7 +23,7 @@ final class IrregularSpan {
|
|||||||
self.end = end
|
self.end = end
|
||||||
}
|
}
|
||||||
|
|
||||||
var category: SpanCategory {
|
public var category: SpanCategory {
|
||||||
switch spanType {
|
switch spanType {
|
||||||
case 100..<200: return .spelling
|
case 100..<200: return .spelling
|
||||||
case 200..<300: return .stemChange
|
case 200..<300: return .stemChange
|
||||||
@@ -32,7 +32,7 @@ final class IrregularSpan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SpanCategory: String {
|
public enum SpanCategory: String, Sendable {
|
||||||
case spelling = "Spelling Change"
|
case spelling = "Spelling Change"
|
||||||
case stemChange = "Stem Change"
|
case stemChange = "Stem Change"
|
||||||
case uniqueIrregular = "Unique Irregular"
|
case uniqueIrregular = "Unique Irregular"
|
||||||
26
Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift
Normal file
26
Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
public enum SharedStore {
|
||||||
|
public static let appGroupID = "group.com.conjuga.app"
|
||||||
|
|
||||||
|
/// Resolves the local SwiftData store URL inside the shared app group container
|
||||||
|
/// at the canonical `Library/Application Support/local.store` path.
|
||||||
|
/// Returns nil if the app group isn't accessible (entitlement / profile issue).
|
||||||
|
public static func localStoreURL() -> URL? {
|
||||||
|
guard let groupURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let dir = groupURL.appendingPathComponent("Library/Application Support")
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir.appendingPathComponent("local.store")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global reference to the main app's local reference-data container.
|
||||||
|
/// Set by `ConjugaApp.init()` so any view can bypass `@Environment(\.modelContext)`
|
||||||
|
/// and hit the exact container used for seeding.
|
||||||
|
@MainActor
|
||||||
|
public static var localContainer: ModelContainer?
|
||||||
|
}
|
||||||
15
Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift
Normal file
15
Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class TenseGuide {
|
||||||
|
public var tenseId: String = ""
|
||||||
|
public var title: String = ""
|
||||||
|
public var body: String = ""
|
||||||
|
|
||||||
|
public init(tenseId: String, title: String, body: String) {
|
||||||
|
self.tenseId = tenseId
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum VerbLevel: String, CaseIterable, Sendable {
|
public enum VerbLevel: String, CaseIterable, Sendable {
|
||||||
case basic
|
case basic
|
||||||
case elementary
|
case elementary
|
||||||
case intermediate
|
case intermediate
|
||||||
case advanced
|
case advanced
|
||||||
case expert
|
case expert
|
||||||
|
|
||||||
var displayName: String { rawValue.capitalized }
|
public var displayName: String { rawValue.capitalized }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class Verb {
|
public final class Verb {
|
||||||
var id: Int = 0
|
public var id: Int = 0
|
||||||
var infinitive: String = ""
|
public var infinitive: String = ""
|
||||||
var english: String = ""
|
public var english: String = ""
|
||||||
var rank: Int = 0
|
public var rank: Int = 0
|
||||||
var ending: String = ""
|
public var ending: String = ""
|
||||||
var reflexive: Int = 0
|
public var reflexive: Int = 0
|
||||||
var level: String = ""
|
public var level: String = ""
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
|
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
|
||||||
var forms: [VerbForm]?
|
public var forms: [VerbForm]?
|
||||||
|
|
||||||
|
public init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
|
||||||
init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
|
|
||||||
self.id = id
|
self.id = id
|
||||||
self.infinitive = infinitive
|
self.infinitive = infinitive
|
||||||
self.english = english
|
self.english = english
|
||||||
@@ -36,14 +35,14 @@ final class Verb {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerbLevelGroup: String, CaseIterable, Sendable {
|
public enum VerbLevelGroup: String, CaseIterable, Sendable {
|
||||||
case basic = "basic"
|
case basic = "basic"
|
||||||
case elementary = "elementary"
|
case elementary = "elementary"
|
||||||
case intermediate = "intermediate"
|
case intermediate = "intermediate"
|
||||||
case advanced = "advanced"
|
case advanced = "advanced"
|
||||||
case expert = "expert"
|
case expert = "expert"
|
||||||
|
|
||||||
static func dataLevels(for selectedLevel: String) -> Set<String> {
|
public static func dataLevels(for selectedLevel: String) -> Set<String> {
|
||||||
switch selectedLevel {
|
switch selectedLevel {
|
||||||
case Self.basic.rawValue:
|
case Self.basic.rawValue:
|
||||||
return ["basic"]
|
return ["basic"]
|
||||||
@@ -60,7 +59,7 @@ enum VerbLevelGroup: String, CaseIterable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func matches(_ dataLevel: String, selectedLevel: String) -> Bool {
|
public static func matches(_ dataLevel: String, selectedLevel: String) -> Bool {
|
||||||
dataLevels(for: selectedLevel).contains(dataLevel)
|
dataLevels(for: selectedLevel).contains(dataLevel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
24
Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift
Normal file
24
Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Model
|
||||||
|
public final class VerbForm {
|
||||||
|
public var verbId: Int = 0
|
||||||
|
public var tenseId: String = ""
|
||||||
|
public var personIndex: Int = 0
|
||||||
|
public var form: String = ""
|
||||||
|
public var regularity: String = ""
|
||||||
|
|
||||||
|
public var verb: Verb?
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \IrregularSpan.verbForm)
|
||||||
|
public var spans: [IrregularSpan]?
|
||||||
|
|
||||||
|
public init(verbId: Int, tenseId: String, personIndex: Int, form: String, regularity: String) {
|
||||||
|
self.verbId = verbId
|
||||||
|
self.tenseId = tenseId
|
||||||
|
self.personIndex = personIndex
|
||||||
|
self.form = form
|
||||||
|
self.regularity = regularity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,26 @@ options:
|
|||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "26.0"
|
iOS: "26.0"
|
||||||
xcodeVersion: "26.0"
|
xcodeVersion: "26.0"
|
||||||
|
schemePathPrefix: ""
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
|
||||||
|
schemes:
|
||||||
|
Conjuga:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
Conjuga: all
|
||||||
|
ConjugaWidgetExtension: all
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
executable: Conjuga
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
profile:
|
||||||
|
config: Release
|
||||||
|
analyze:
|
||||||
|
config: Debug
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
SharedModels:
|
SharedModels:
|
||||||
@@ -23,14 +43,10 @@ targets:
|
|||||||
- path: Conjuga
|
- path: Conjuga
|
||||||
excludes:
|
excludes:
|
||||||
- "*.json"
|
- "*.json"
|
||||||
- PrebuiltStore
|
|
||||||
- path: Conjuga/conjuga_data.json
|
- path: Conjuga/conjuga_data.json
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: Conjuga/course_data.json
|
- path: Conjuga/course_data.json
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: Conjuga/PrebuiltStore
|
|
||||||
type: folder
|
|
||||||
buildPhase: resources
|
|
||||||
info:
|
info:
|
||||||
path: Conjuga/Info.plist
|
path: Conjuga/Info.plist
|
||||||
properties:
|
properties:
|
||||||
@@ -38,7 +54,10 @@ targets:
|
|||||||
LSApplicationCategoryType: public.app-category.education
|
LSApplicationCategoryType: public.app-category.education
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIBackgroundModes:
|
UIBackgroundModes:
|
||||||
|
- fetch
|
||||||
- remote-notification
|
- remote-notification
|
||||||
|
BGTaskSchedulerPermittedIdentifiers:
|
||||||
|
- com.conjuga.app.refresh
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
UISupportedInterfaceOrientations~ipad:
|
UISupportedInterfaceOrientations~ipad:
|
||||||
|
|||||||
Reference in New Issue
Block a user