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;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 63;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -19,6 +19,7 @@
|
||||
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
|
||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.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 */; };
|
||||
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.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 */; };
|
||||
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.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 */; };
|
||||
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 */; };
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.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 */; };
|
||||
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 */; };
|
||||
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
|
||||
A11A11111111111111111111 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11B11111111111111111111 /* ReviewStore.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 */; };
|
||||
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
|
||||
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
|
||||
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 */; };
|
||||
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
|
||||
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
|
||||
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
|
||||
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
|
||||
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 */; };
|
||||
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
|
||||
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
|
||||
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
|
||||
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
|
||||
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
|
||||
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
|
||||
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
|
||||
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
|
||||
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -139,37 +138,36 @@
|
||||
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
|
||||
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
|
||||
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
|
||||
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
|
||||
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
|
||||
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseGuide.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -230,15 +228,17 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
|
||||
B33B33333333333333333333 /* CourseReviewStore.swift */,
|
||||
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
|
||||
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
|
||||
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
|
||||
B44B44444444444444444444 /* PracticeSessionService.swift */,
|
||||
B22B22222222222222222222 /* ReferenceStore.swift */,
|
||||
B11B11111111111111111111 /* ReviewStore.swift */,
|
||||
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
|
||||
777C696A841803D5B775B678 /* ReferenceStore.swift */,
|
||||
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
|
||||
49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
|
||||
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
|
||||
B55B55555555555555555555 /* StartupCoordinator.swift */,
|
||||
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
|
||||
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
|
||||
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
|
||||
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
|
||||
);
|
||||
path = Services;
|
||||
@@ -257,16 +257,12 @@
|
||||
children = (
|
||||
0313D24F96E6A0039C34341F /* DailyLog.swift */,
|
||||
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
|
||||
21FB1479EA5779A109BC517D /* IrregularSpan.swift */,
|
||||
626873572466403C0288090D /* QuizType.swift */,
|
||||
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
|
||||
69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
|
||||
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */,
|
||||
3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
|
||||
DAFE27F29412021AEC57E728 /* TestResult.swift */,
|
||||
E536AD1180FE10576EAC884A /* UserProgress.swift */,
|
||||
165B15630F4560F5891D9763 /* Verb.swift */,
|
||||
8B6C1705F97FA0D59E996529 /* VerbForm.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -278,6 +274,7 @@
|
||||
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */,
|
||||
80D974250C396589656B8443 /* HandwritingCanvas.swift */,
|
||||
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */,
|
||||
1C4B5204F6B8647C816814F0 /* SyncToast.swift */,
|
||||
102F0E136CDFF8CED710210F /* TensePill.swift */,
|
||||
);
|
||||
path = Components;
|
||||
@@ -472,6 +469,7 @@
|
||||
packageReferences = (
|
||||
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
@@ -504,6 +502,7 @@
|
||||
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
|
||||
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
|
||||
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
|
||||
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
|
||||
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
|
||||
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
|
||||
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
|
||||
@@ -519,36 +518,34 @@
|
||||
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
|
||||
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
|
||||
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
|
||||
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */,
|
||||
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
|
||||
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
|
||||
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
|
||||
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */,
|
||||
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
|
||||
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
|
||||
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
|
||||
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
|
||||
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
|
||||
A22A22222222222222222222 /* ReferenceStore.swift in Sources */,
|
||||
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
|
||||
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
|
||||
A11A11111111111111111111 /* ReviewStore.swift in Sources */,
|
||||
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
|
||||
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
|
||||
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */,
|
||||
60E86BABE2735E2052B99DF3 /* SettingsView.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 */,
|
||||
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
|
||||
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
|
||||
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
|
||||
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */,
|
||||
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
|
||||
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
|
||||
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
|
||||
27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
|
||||
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
|
||||
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */,
|
||||
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
|
||||
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */,
|
||||
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
|
||||
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */,
|
||||
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
|
||||
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.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 SwiftData
|
||||
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
|
||||
struct ConjugaApp: App {
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var isReady = false
|
||||
@State private var syncMonitor = SyncStatusMonitor()
|
||||
|
||||
let container: ModelContainer
|
||||
let localContainer: ModelContainer
|
||||
let cloudContainer: ModelContainer
|
||||
|
||||
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 {
|
||||
let localConfig = ModelConfiguration(
|
||||
"local",
|
||||
schema: Schema([
|
||||
Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
]),
|
||||
groupContainer: .none,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
localContainer = try Self.makeValidatedLocalContainer(at: localURL)
|
||||
SharedStore.localContainer = localContainer
|
||||
|
||||
// DIAGNOSTIC: what's in the store file AFTER SwiftData opened it?
|
||||
StoreInspector.dump(at: localURL, label: "after-open")
|
||||
print("[ConjugaApp] localContainer identity: \(ObjectIdentifier(localContainer))")
|
||||
|
||||
let cloudConfig = ModelConfiguration(
|
||||
"cloud",
|
||||
@@ -31,13 +70,10 @@ struct ConjugaApp: App {
|
||||
]),
|
||||
cloudKitDatabase: .private("iCloud.com.conjuga.app")
|
||||
)
|
||||
|
||||
container = try ModelContainer(
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
cloudContainer = try ModelContainer(
|
||||
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
|
||||
TestResult.self, DailyLog.self,
|
||||
configurations: localConfig, cloudConfig
|
||||
configurations: cloudConfig
|
||||
)
|
||||
} catch {
|
||||
fatalError("Failed to create ModelContainer: \(error)")
|
||||
@@ -60,18 +96,147 @@ struct ConjugaApp: App {
|
||||
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 {
|
||||
await StartupCoordinator.run(container: container)
|
||||
WidgetDataService.update(context: container.mainContext)
|
||||
if let url = SharedStore.localStoreURL() {
|
||||
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
|
||||
|
||||
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
|
||||
if newPhase == .background {
|
||||
let context = container.mainContext
|
||||
WidgetDataService.update(context: context)
|
||||
WidgetDataService.update(
|
||||
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/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.conjuga.app.refresh</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
struct GrammarNote: Identifiable {
|
||||
let id: String
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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 SharedModels
|
||||
import Foundation
|
||||
|
||||
@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 SharedModels
|
||||
import SwiftData
|
||||
|
||||
enum Badge: String, CaseIterable, Identifiable, Sendable {
|
||||
@@ -64,7 +65,11 @@ enum Badge: String, CaseIterable, Identifiable, Sendable {
|
||||
struct AchievementService: Sendable {
|
||||
|
||||
/// 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] = []
|
||||
|
||||
for badge in Badge.allCases {
|
||||
@@ -81,15 +86,25 @@ struct AchievementService: Sendable {
|
||||
case .streak30:
|
||||
earned = progress.currentStreak >= 30
|
||||
case .verbs25:
|
||||
earned = uniqueVerbsReviewed(context: context) >= 25
|
||||
earned = uniqueVerbsReviewed(context: reviewContext) >= 25
|
||||
case .verbs100:
|
||||
earned = uniqueVerbsReviewed(context: context) >= 100
|
||||
earned = uniqueVerbsReviewed(context: reviewContext) >= 100
|
||||
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:
|
||||
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:
|
||||
earned = hasUsedAllTenses(context: context)
|
||||
earned = hasUsedAllTenses(context: reviewContext)
|
||||
case .daily50:
|
||||
earned = progress.todayCount >= 50
|
||||
}
|
||||
@@ -114,8 +129,13 @@ struct AchievementService: Sendable {
|
||||
return uniqueVerbs.count
|
||||
}
|
||||
|
||||
private static func hasMasteredTense(_ tenseId: String, level: String, context: ModelContext) -> Bool {
|
||||
let verbIds = Set(ReferenceStore(context: context).fetchVerbs(selectedLevel: level).map(\.id))
|
||||
private static func hasMasteredTense(
|
||||
_ 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 }
|
||||
|
||||
let cardDescriptor = FetchDescriptor<ReviewCard>(
|
||||
@@ -123,7 +143,7 @@ struct AchievementService: Sendable {
|
||||
card.tenseId == tenseId && card.interval >= 21
|
||||
}
|
||||
)
|
||||
let cards = (try? context.fetch(cardDescriptor)) ?? []
|
||||
let cards = (try? reviewContext.fetch(cardDescriptor)) ?? []
|
||||
let masteredVerbIds = Set(cards.map(\.verbId))
|
||||
|
||||
return verbIds.isSubset(of: masteredVerbIds)
|
||||
|
||||
@@ -6,9 +6,14 @@ actor DataLoader {
|
||||
static func seedIfNeeded(container: ModelContainer) async {
|
||||
let context = ModelContext(container)
|
||||
|
||||
var descriptor = FetchDescriptor<Verb>()
|
||||
descriptor.fetchLimit = 1
|
||||
let count = (try? context.fetchCount(descriptor)) ?? 0
|
||||
let count: Int
|
||||
do {
|
||||
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 }
|
||||
|
||||
print("Seeding database...")
|
||||
@@ -104,10 +109,14 @@ actor DataLoader {
|
||||
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")
|
||||
|
||||
// Seed course data
|
||||
// Seed course data (uses the same mainContext so @Query sees it)
|
||||
seedCourseData(context: context)
|
||||
}
|
||||
|
||||
@@ -135,16 +144,20 @@ actor DataLoader {
|
||||
print("Course data re-seeded to version \(currentVersion)")
|
||||
}
|
||||
|
||||
static func migrateCourseProgressIfNeeded(container: ModelContainer) async {
|
||||
let migrationVersion = 1
|
||||
static func migrateCourseProgressIfNeeded(
|
||||
localContainer: ModelContainer,
|
||||
cloudContainer: ModelContainer
|
||||
) async {
|
||||
let migrationVersion = 2
|
||||
let key = "courseProgressMigrationVersion"
|
||||
let shared = UserDefaults.standard
|
||||
|
||||
if shared.integer(forKey: key) >= migrationVersion { return }
|
||||
|
||||
let context = ModelContext(container)
|
||||
let localContext = ModelContext(localContainer)
|
||||
let cloudContext = ModelContext(cloudContainer)
|
||||
let descriptor = FetchDescriptor<VocabCard>()
|
||||
let allCards = (try? context.fetch(descriptor)) ?? []
|
||||
let allCards = (try? localContext.fetch(descriptor)) ?? []
|
||||
var migratedCount = 0
|
||||
|
||||
for card in allCards where hasLegacyCourseProgress(card) {
|
||||
@@ -154,7 +167,7 @@ actor DataLoader {
|
||||
deckId: card.deckId,
|
||||
front: card.front,
|
||||
back: card.back,
|
||||
context: context
|
||||
context: cloudContext
|
||||
)
|
||||
|
||||
if let reviewDate = reviewCard.lastReviewDate,
|
||||
@@ -172,7 +185,7 @@ actor DataLoader {
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
try? context.save()
|
||||
try? cloudContext.save()
|
||||
print("Migrated \(migratedCount) course progress cards to cloud store")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct PracticeSettings: Sendable {
|
||||
@@ -33,16 +34,18 @@ struct FullTablePrompt {
|
||||
}
|
||||
|
||||
struct PracticeSessionService {
|
||||
let context: ModelContext
|
||||
let localContext: ModelContext
|
||||
let cloudContext: ModelContext
|
||||
private let referenceStore: ReferenceStore
|
||||
|
||||
init(context: ModelContext) {
|
||||
self.context = context
|
||||
self.referenceStore = ReferenceStore(context: context)
|
||||
init(localContext: ModelContext, cloudContext: ModelContext) {
|
||||
self.localContext = localContext
|
||||
self.cloudContext = cloudContext
|
||||
self.referenceStore = ReferenceStore(context: localContext)
|
||||
}
|
||||
|
||||
func settings() -> PracticeSettings {
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: context))
|
||||
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
|
||||
}
|
||||
|
||||
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
|
||||
@@ -94,7 +97,8 @@ struct PracticeSessionService {
|
||||
tenseId: tenseId,
|
||||
personIndex: personIndex,
|
||||
quality: quality,
|
||||
context: context
|
||||
context: cloudContext,
|
||||
referenceContext: localContext
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,7 +107,8 @@ struct PracticeSessionService {
|
||||
verbId: verbId,
|
||||
tenseId: tenseId,
|
||||
results: results,
|
||||
context: context
|
||||
context: cloudContext,
|
||||
referenceContext: localContext
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,7 +155,7 @@ struct PracticeSessionService {
|
||||
sortBy: [SortDescriptor(\ReviewCard.dueDate)]
|
||||
)
|
||||
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
|
||||
let cards = (try? context.fetch(descriptor)) ?? []
|
||||
let cards = (try? cloudContext.fetch(descriptor)) ?? []
|
||||
|
||||
return cards.first { card in
|
||||
allowedVerbIds.contains(card.verbId) &&
|
||||
@@ -167,7 +172,7 @@ struct PracticeSessionService {
|
||||
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
|
||||
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) &&
|
||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
|
||||
(settings.showVosotros || card.personIndex != 4)
|
||||
@@ -203,7 +208,7 @@ struct PracticeSessionService {
|
||||
)
|
||||
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) &&
|
||||
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
|
||||
(settings.showVosotros || span.personIndex != 4)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct ReferenceStore {
|
||||
|
||||
@@ -113,7 +113,8 @@ struct ReviewStore {
|
||||
tenseId: String,
|
||||
personIndex: Int,
|
||||
quality: ReviewQuality,
|
||||
context: ModelContext
|
||||
context: ModelContext,
|
||||
referenceContext: ModelContext
|
||||
) -> [Badge] {
|
||||
let card = fetchOrCreateReviewCard(
|
||||
verbId: verbId,
|
||||
@@ -128,7 +129,11 @@ struct ReviewStore {
|
||||
correctIncrement: quality.rawValue >= 3 ? 1 : 0,
|
||||
context: context
|
||||
)
|
||||
let badges = AchievementService.checkAchievements(progress: progress, context: context)
|
||||
let badges = AchievementService.checkAchievements(
|
||||
progress: progress,
|
||||
reviewContext: context,
|
||||
referenceContext: referenceContext
|
||||
)
|
||||
try? context.save()
|
||||
return badges
|
||||
}
|
||||
@@ -137,7 +142,8 @@ struct ReviewStore {
|
||||
verbId: Int,
|
||||
tenseId: String,
|
||||
results: [Int: Bool],
|
||||
context: ModelContext
|
||||
context: ModelContext,
|
||||
referenceContext: ModelContext
|
||||
) -> [Badge] {
|
||||
for (personIndex, isCorrect) in results {
|
||||
let card = fetchOrCreateReviewCard(
|
||||
@@ -155,7 +161,11 @@ struct ReviewStore {
|
||||
correctIncrement: allCorrect ? 1 : 0,
|
||||
context: context
|
||||
)
|
||||
let badges = AchievementService.checkAchievements(progress: progress, context: context)
|
||||
let badges = AchievementService.checkAchievements(
|
||||
progress: progress,
|
||||
reviewContext: context,
|
||||
referenceContext: referenceContext
|
||||
)
|
||||
try? context.save()
|
||||
return badges
|
||||
}
|
||||
|
||||
@@ -3,13 +3,24 @@ import SharedModels
|
||||
import SwiftData
|
||||
|
||||
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
|
||||
static func run(container: ModelContainer) async {
|
||||
await DataLoader.seedIfNeeded(container: container)
|
||||
await DataLoader.refreshCourseDataIfNeeded(container: container)
|
||||
await DataLoader.migrateCourseProgressIfNeeded(container: container)
|
||||
static func bootstrap(localContainer: ModelContainer) async {
|
||||
await DataLoader.seedIfNeeded(container: localContainer)
|
||||
await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
|
||||
}
|
||||
|
||||
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)
|
||||
progress.migrateLegacyStorageIfNeeded()
|
||||
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 dataKey = "widgetData"
|
||||
|
||||
/// Write current app state to shared storage for widgets to read.
|
||||
static func update(context: ModelContext) {
|
||||
static func update(localContainer: ModelContainer, cloudContainer: ModelContainer) {
|
||||
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 }
|
||||
|
||||
// Fetch user progress
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
|
||||
|
||||
// Count due review cards
|
||||
let now = Date()
|
||||
let dueDescriptor = FetchDescriptor<ReviewCard>(
|
||||
predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
|
||||
)
|
||||
let dueCount = (try? context.fetchCount(dueDescriptor)) ?? 0
|
||||
let dueCount = (try? cloudContext.fetchCount(dueDescriptor)) ?? 0
|
||||
|
||||
var wordOfDay: WordOfDay?
|
||||
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 deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||
predicate: #Predicate<CourseDeck> { $0.id == deckId }
|
||||
)
|
||||
let deck = (try? context.fetch(deckDescriptor))?.first
|
||||
let deck = (try? localContext.fetch(deckDescriptor))?.first
|
||||
wordOfDay = WordOfDay(
|
||||
spanish: card.front,
|
||||
english: card.back,
|
||||
@@ -38,13 +45,10 @@ struct WidgetDataService {
|
||||
)
|
||||
}
|
||||
|
||||
// Latest test result
|
||||
let testDescriptor = FetchDescriptor<TestResult>(
|
||||
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
|
||||
)
|
||||
let latestTest = (try? context.fetch(testDescriptor))?.first
|
||||
|
||||
// Determine current week (from most studied decks or latest test)
|
||||
let latestTest = (try? cloudContext.fetch(testDescriptor))?.first
|
||||
let currentWeek = latestTest?.weekNumber ?? 1
|
||||
|
||||
let previousData = shared.data(forKey: dataKey)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
enum PracticeMode: String, CaseIterable, Identifiable, Sendable {
|
||||
@@ -86,7 +87,7 @@ final class PracticeViewModel {
|
||||
|
||||
// MARK: - Load next card
|
||||
|
||||
func loadNextCard(context: ModelContext) {
|
||||
func loadNextCard(localContext: ModelContext, cloudContext: ModelContext) {
|
||||
isAnswerRevealed = false
|
||||
userAnswer = ""
|
||||
isCorrect = nil
|
||||
@@ -94,7 +95,7 @@ final class PracticeViewModel {
|
||||
currentSpans = []
|
||||
hasCards = true
|
||||
isLoading = true
|
||||
let service = PracticeSessionService(context: context)
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
guard let cardLoad = service.nextCard(for: focusMode) else {
|
||||
clearCurrentCard()
|
||||
hasCards = false
|
||||
@@ -102,7 +103,7 @@ final class PracticeViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
applyCardLoad(cardLoad, context: context)
|
||||
applyCardLoad(cardLoad, localContext: localContext)
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -134,9 +135,9 @@ final class PracticeViewModel {
|
||||
|
||||
// MARK: - SRS rating
|
||||
|
||||
func rateAnswer(quality: ReviewQuality, context: ModelContext) {
|
||||
func rateAnswer(quality: ReviewQuality, localContext: ModelContext, cloudContext: ModelContext) {
|
||||
guard let form = currentForm else { return }
|
||||
let service = PracticeSessionService(context: context)
|
||||
let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
|
||||
newBadges = service.rate(
|
||||
verbId: form.verbId,
|
||||
tenseId: form.tenseId,
|
||||
@@ -208,7 +209,7 @@ final class PracticeViewModel {
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func applyCardLoad(_ cardLoad: PracticeCardLoad, context: ModelContext) {
|
||||
private func applyCardLoad(_ cardLoad: PracticeCardLoad, localContext: ModelContext) {
|
||||
currentVerb = cardLoad.verb
|
||||
currentForm = cardLoad.form
|
||||
currentSpans = cardLoad.spans
|
||||
@@ -216,7 +217,7 @@ final class PracticeViewModel {
|
||||
currentPerson = cardLoad.person
|
||||
|
||||
if practiceMode == .multipleChoice {
|
||||
prepareMultipleChoice(for: cardLoad.form, context: context)
|
||||
prepareMultipleChoice(for: cardLoad.form, context: localContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct IrregularHighlightText: View {
|
||||
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 SharedModels
|
||||
import SwiftData
|
||||
|
||||
/// Reusable tappable tense pill that shows a tense info sheet when tapped.
|
||||
|
||||
@@ -9,7 +9,7 @@ struct CourseQuizView: View {
|
||||
let weekNumber: Int
|
||||
let isFocusMode: Bool
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var speechService = SpeechService()
|
||||
|
||||
@@ -30,6 +30,7 @@ struct CourseQuizView: View {
|
||||
@FocusState private var isTypingFocused: Bool
|
||||
|
||||
private var isComplete: Bool { currentIndex >= shuffledCards.count }
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentCard: VocabCard? {
|
||||
guard currentIndex < shuffledCards.count else { return nil }
|
||||
@@ -513,8 +514,8 @@ struct CourseQuizView: View {
|
||||
correctCount: correctCount,
|
||||
missedItems: missedItems
|
||||
)
|
||||
modelContext.insert(result)
|
||||
try? modelContext.save()
|
||||
cloudModelContext.insert(result)
|
||||
try? cloudModelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct CourseView: View {
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
|
||||
@Query private var testResults: [TestResult]
|
||||
@AppStorage("selectedCourse") private var selectedCourse: String?
|
||||
@State private var testResults: [TestResult] = []
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var courseNames: [String] {
|
||||
let names = Set(decks.map(\.courseName))
|
||||
@@ -105,6 +108,7 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
|
||||
.onAppear(perform: loadTestResults)
|
||||
.navigationDestination(for: CourseDeck.self) { deck in
|
||||
DeckStudyView(deck: deck)
|
||||
}
|
||||
@@ -113,6 +117,10 @@ struct CourseView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTestResults() {
|
||||
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deck Row
|
||||
|
||||
@@ -7,11 +7,13 @@ struct VocabFlashcardView: View {
|
||||
let speechService: SpeechService
|
||||
let onDone: () -> Void
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var currentIndex = 0
|
||||
@State private var isRevealed = false
|
||||
@State private var sessionCorrect = 0
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var currentCard: VocabCard? {
|
||||
guard currentIndex < cards.count else { return nil }
|
||||
return cards[currentIndex]
|
||||
@@ -180,7 +182,7 @@ struct VocabFlashcardView: View {
|
||||
|
||||
private func rateAndAdvance(quality: ReviewQuality) {
|
||||
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 }
|
||||
|
||||
|
||||
@@ -5,16 +5,13 @@ import SwiftData
|
||||
struct WeekTestView: View {
|
||||
let weekNumber: Int
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var allResults: [TestResult]
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Query private var allDecks: [CourseDeck]
|
||||
|
||||
@State private var loadedWeekCards: [VocabCard] = []
|
||||
@State private var weekResults: [TestResult] = []
|
||||
|
||||
private var weekResults: [TestResult] {
|
||||
allResults
|
||||
.filter { $0.weekNumber == weekNumber }
|
||||
.sorted { $0.dateTaken > $1.dateTaken }
|
||||
}
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var weekCards: [VocabCard] {
|
||||
loadedWeekCards
|
||||
@@ -202,7 +199,10 @@ struct WeekTestView: View {
|
||||
}
|
||||
.navigationTitle("Week \(weekNumber) Test")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { loadCards() }
|
||||
.onAppear {
|
||||
loadResults()
|
||||
loadCards()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCards() {
|
||||
@@ -220,6 +220,14 @@ struct WeekTestView: View {
|
||||
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 {
|
||||
if percent >= 90 { return .green }
|
||||
if percent >= 70 { return .orange }
|
||||
|
||||
@@ -3,12 +3,13 @@ import SwiftData
|
||||
import Charts
|
||||
|
||||
struct DashboardView: View {
|
||||
@Query private var progress: [UserProgress]
|
||||
@Query(sort: \DailyLog.dateString, order: .reverse) private var dailyLogs: [DailyLog]
|
||||
@Query private var testResults: [TestResult]
|
||||
@Query private var reviewCards: [ReviewCard]
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var userProgress: UserProgress?
|
||||
@State private var dailyLogs: [DailyLog] = []
|
||||
@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 {
|
||||
NavigationStack {
|
||||
@@ -32,6 +33,7 @@ struct DashboardView: View {
|
||||
.adaptiveContainer(maxWidth: 800)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.onAppear(perform: loadData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +168,17 @@ struct DashboardView: View {
|
||||
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct GuideView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@@ -38,6 +39,7 @@ struct GuideView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Guide")
|
||||
.task { loadGuides() }
|
||||
.onAppear(perform: loadGuides)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
selectedGuide = nil
|
||||
@@ -78,7 +80,14 @@ struct GuideView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
selectedGuide = guides.first
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@AppStorage("onboardingComplete") private var onboardingComplete = false
|
||||
@State private var currentPage = 0
|
||||
@State private var selectedLevel: VerbLevel = .basic
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $currentPage) {
|
||||
@@ -125,12 +127,12 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func completeOnboarding() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress.selectedVerbLevel = selectedLevel
|
||||
if progress.enabledTenseIDs.isEmpty {
|
||||
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
|
||||
}
|
||||
try? modelContext.save()
|
||||
try? cloudModelContext.save()
|
||||
onboardingComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct AnswerReviewView: View {
|
||||
let form: VerbForm?
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct FlashcardView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
var viewModel: PracticeViewModel
|
||||
let speechService: SpeechService
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
@@ -72,12 +76,22 @@ struct FlashcardView: View {
|
||||
spans: viewModel.currentSpans,
|
||||
speechService: speechService,
|
||||
onRate: { quality in
|
||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.rateAnswer(
|
||||
quality: quality,
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
},
|
||||
showAnswer: false,
|
||||
onNext: {
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import PencilKit
|
||||
|
||||
/// Practice mode where user fills in all 6 person conjugations for a verb + tense.
|
||||
struct FullTableView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
let speechService: SpeechService
|
||||
|
||||
@State private var currentVerb: Verb?
|
||||
@@ -27,6 +29,7 @@ struct FullTableView: View {
|
||||
@FocusState private var focusedField: Int?
|
||||
|
||||
private let persons = TenseInfo.persons
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
private var personsToShow: [(index: Int, label: String)] {
|
||||
persons.enumerated().compactMap { index, label in
|
||||
@@ -240,7 +243,7 @@ struct FullTableView: View {
|
||||
results = Array(repeating: nil, count: 6)
|
||||
correctForms = []
|
||||
drawings = Array(repeating: PKDrawing(), count: 6)
|
||||
let service = PracticeSessionService(context: modelContext)
|
||||
let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
guard let prompt = service.randomFullTablePrompt() else {
|
||||
currentVerb = nil
|
||||
currentTense = nil
|
||||
@@ -309,7 +312,7 @@ struct FullTableView: View {
|
||||
if allCorrect { sessionCorrect += 1 }
|
||||
|
||||
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) })
|
||||
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
|
||||
}
|
||||
@@ -328,7 +331,7 @@ struct FullTableView: View {
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
showVosotros = progress.showVosotros
|
||||
autoFillStem = progress.autoFillStem
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
import PencilKit
|
||||
|
||||
struct HandwritingView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
var viewModel: PracticeViewModel
|
||||
let speechService: SpeechService
|
||||
|
||||
@@ -11,6 +13,8 @@ struct HandwritingView: View {
|
||||
@State private var recognizedText = ""
|
||||
@State private var isRecognizing = false
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
@@ -50,12 +54,22 @@ struct HandwritingView: View {
|
||||
spans: viewModel.currentSpans,
|
||||
speechService: speechService,
|
||||
onRate: { quality in
|
||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.rateAnswer(
|
||||
quality: quality,
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
resetCanvas()
|
||||
},
|
||||
onNext: {
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
resetCanvas()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct MultipleChoiceView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
var viewModel: PracticeViewModel
|
||||
let speechService: SpeechService
|
||||
@State private var selectedIndex: Int?
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
@@ -41,13 +45,23 @@ struct MultipleChoiceView: View {
|
||||
spans: viewModel.currentSpans,
|
||||
speechService: speechService,
|
||||
onRate: { quality in
|
||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
||||
viewModel.rateAnswer(
|
||||
quality: quality,
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
selectedIndex = nil
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
},
|
||||
onNext: {
|
||||
selectedIndex = nil
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
}
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
|
||||
struct PracticeHeaderView: View {
|
||||
let verb: Verb?
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct PracticeView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var progress: [UserProgress]
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var viewModel = PracticeViewModel()
|
||||
@State private var speechService = SpeechService()
|
||||
@State private var isPracticing = false
|
||||
@State private var userProgress: UserProgress?
|
||||
|
||||
private var userProgress: UserProgress? {
|
||||
progress.first
|
||||
}
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -23,6 +23,12 @@ struct PracticeView: View {
|
||||
}
|
||||
.navigationTitle("Practice")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: loadProgress)
|
||||
.onChange(of: isPracticing) { _, practicing in
|
||||
if !practicing {
|
||||
loadProgress()
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
if isPracticing {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -80,7 +86,10 @@ struct PracticeView: View {
|
||||
viewModel.focusMode = .none
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation {
|
||||
isPracticing = true
|
||||
}
|
||||
@@ -101,7 +110,10 @@ struct PracticeView: View {
|
||||
viewModel.focusMode = .weakVerbs
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
withAnimation { isPracticing = true }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
@@ -311,9 +323,15 @@ extension PracticeView {
|
||||
viewModel.focusMode = .irregularity(filter)
|
||||
viewModel.sessionCorrect = 0
|
||||
viewModel.sessionTotal = 0
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
|
||||
withAnimation { isPracticing = true }
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
userProgress = progress
|
||||
try? cloudModelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct TypingView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@Bindable var viewModel: PracticeViewModel
|
||||
let speechService: SpeechService
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
@@ -78,12 +82,22 @@ struct TypingView: View {
|
||||
spans: viewModel.currentSpans,
|
||||
speechService: speechService,
|
||||
onRate: { quality in
|
||||
viewModel.rateAnswer(quality: quality, context: modelContext)
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.rateAnswer(
|
||||
quality: quality,
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
isTextFieldFocused = true
|
||||
},
|
||||
onNext: {
|
||||
viewModel.loadNextCard(context: modelContext)
|
||||
viewModel.loadNextCard(
|
||||
localContext: modelContext,
|
||||
cloudContext: cloudModelContext
|
||||
)
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
|
||||
@State private var progress: UserProgress?
|
||||
|
||||
@State private var dailyGoal: Double = 50
|
||||
@@ -11,6 +12,7 @@ struct SettingsView: View {
|
||||
@State private var selectedLevel: VerbLevel = .basic
|
||||
|
||||
private let levels = VerbLevel.allCases
|
||||
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -83,7 +85,7 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
private func loadProgress() {
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: modelContext)
|
||||
let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
|
||||
progress = resolved
|
||||
dailyGoal = Double(resolved.dailyGoal)
|
||||
showVosotros = resolved.showVosotros
|
||||
@@ -92,7 +94,7 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
private func saveProgress() {
|
||||
try? modelContext.save()
|
||||
try? cloudModelContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SharedModels
|
||||
import SwiftData
|
||||
|
||||
struct VerbDetailView: View {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
|
||||
struct VerbListView: View {
|
||||
@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 selectedLevel: String?
|
||||
@State private var selectedVerb: Verb?
|
||||
@@ -46,6 +47,8 @@ struct VerbListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { loadVerbs() }
|
||||
.onAppear { loadVerbs() }
|
||||
} detail: {
|
||||
if let verb = selectedVerb {
|
||||
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 {
|
||||
|
||||
@@ -2,9 +2,6 @@ import WidgetKit
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import SharedModels
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "CombinedWidget")
|
||||
|
||||
struct CombinedEntry: TimelineEntry {
|
||||
let date: Date
|
||||
@@ -24,13 +21,13 @@ struct CombinedProvider: TimelineProvider {
|
||||
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
|
||||
return
|
||||
}
|
||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
||||
let word = fetchWordOfDay(for: Date())
|
||||
let data = WidgetDataReader.read()
|
||||
completion(CombinedEntry(date: Date(), word: word, data: data))
|
||||
}
|
||||
|
||||
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 entry = CombinedEntry(date: Date(), word: word, data: data)
|
||||
|
||||
@@ -42,23 +39,24 @@ struct CombinedProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
|
||||
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
|
||||
}
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
// MUST declare all 6 local entities to match the main app's schema.
|
||||
// Declaring a subset would cause SwiftData to destructively migrate the store
|
||||
// on open, dropping the entities not listed here.
|
||||
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: VocabCard.self, CourseDeck.self,
|
||||
configurations: ModelConfiguration(
|
||||
"local",
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
for: Verb.self, VerbForm.self, IrregularSpan.self,
|
||||
TenseGuide.self, CourseDeck.self, VocabCard.self,
|
||||
configurations: config
|
||||
) else { return nil }
|
||||
|
||||
let context = ModelContext(container)
|
||||
@@ -71,9 +69,20 @@ struct CombinedProvider: TimelineProvider {
|
||||
let deckDescriptor = FetchDescriptor<CourseDeck>(
|
||||
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 SwiftData
|
||||
import SharedModels
|
||||
import os
|
||||
|
||||
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "WordOfDay")
|
||||
|
||||
struct WordOfDayEntry: TimelineEntry {
|
||||
let date: Date
|
||||
@@ -16,21 +13,16 @@ struct WordOfDayProvider: TimelineProvider {
|
||||
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) {
|
||||
if context.isPreview {
|
||||
completion(WordOfDayEntry(date: Date(), word: Self.previewWord))
|
||||
completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)))
|
||||
return
|
||||
}
|
||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
||||
completion(WordOfDayEntry(date: Date(), word: word))
|
||||
completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
|
||||
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord
|
||||
let entry = WordOfDayEntry(date: Date(), word: word)
|
||||
|
||||
let entry = WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))
|
||||
let tomorrow = Calendar.current.startOfDay(
|
||||
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||
)
|
||||
@@ -38,48 +30,49 @@ struct WordOfDayProvider: TimelineProvider {
|
||||
}
|
||||
|
||||
private func fetchWordOfDay(for date: Date) -> WordOfDay? {
|
||||
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store")
|
||||
logger.info("Store path: \(localURL.path)")
|
||||
logger.info("Store exists: \(FileManager.default.fileExists(atPath: localURL.path))")
|
||||
guard let localURL = SharedStore.localStoreURL() else { return nil }
|
||||
|
||||
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. App Support contents: \(contents.joined(separator: ", "))")
|
||||
// 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(
|
||||
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
|
||||
}
|
||||
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 {
|
||||
let container = try ModelContainer(
|
||||
for: VocabCard.self, CourseDeck.self,
|
||||
configurations: ModelConfiguration(
|
||||
"local",
|
||||
url: localURL,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
)
|
||||
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
|
||||
// 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,18 +2,18 @@ import SwiftData
|
||||
import Foundation
|
||||
|
||||
@Model
|
||||
final class IrregularSpan {
|
||||
var verbId: Int = 0
|
||||
var tenseId: String = ""
|
||||
var personIndex: Int = 0
|
||||
var spanType: Int = 0
|
||||
var pattern: Int = 0
|
||||
var start: Int = 0
|
||||
var end: Int = 0
|
||||
public final class IrregularSpan {
|
||||
public var verbId: Int = 0
|
||||
public var tenseId: String = ""
|
||||
public var personIndex: Int = 0
|
||||
public var spanType: Int = 0
|
||||
public var pattern: Int = 0
|
||||
public var start: 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.tenseId = tenseId
|
||||
self.personIndex = personIndex
|
||||
@@ -23,7 +23,7 @@ final class IrregularSpan {
|
||||
self.end = end
|
||||
}
|
||||
|
||||
var category: SpanCategory {
|
||||
public var category: SpanCategory {
|
||||
switch spanType {
|
||||
case 100..<200: return .spelling
|
||||
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 stemChange = "Stem Change"
|
||||
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 Foundation
|
||||
|
||||
enum VerbLevel: String, CaseIterable, Sendable {
|
||||
public enum VerbLevel: String, CaseIterable, Sendable {
|
||||
case basic
|
||||
case elementary
|
||||
case intermediate
|
||||
case advanced
|
||||
case expert
|
||||
|
||||
var displayName: String { rawValue.capitalized }
|
||||
public var displayName: String { rawValue.capitalized }
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Verb {
|
||||
var id: Int = 0
|
||||
var infinitive: String = ""
|
||||
var english: String = ""
|
||||
var rank: Int = 0
|
||||
var ending: String = ""
|
||||
var reflexive: Int = 0
|
||||
var level: String = ""
|
||||
public final class Verb {
|
||||
public var id: Int = 0
|
||||
public var infinitive: String = ""
|
||||
public var english: String = ""
|
||||
public var rank: Int = 0
|
||||
public var ending: String = ""
|
||||
public var reflexive: Int = 0
|
||||
public var level: String = ""
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
|
||||
var forms: [VerbForm]?
|
||||
public var forms: [VerbForm]?
|
||||
|
||||
|
||||
init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
|
||||
public init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
|
||||
self.id = id
|
||||
self.infinitive = infinitive
|
||||
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 elementary = "elementary"
|
||||
case intermediate = "intermediate"
|
||||
case advanced = "advanced"
|
||||
case expert = "expert"
|
||||
|
||||
static func dataLevels(for selectedLevel: String) -> Set<String> {
|
||||
public static func dataLevels(for selectedLevel: String) -> Set<String> {
|
||||
switch selectedLevel {
|
||||
case Self.basic.rawValue:
|
||||
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)
|
||||
}
|
||||
}
|
||||
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:
|
||||
iOS: "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:
|
||||
SharedModels:
|
||||
@@ -23,14 +43,10 @@ targets:
|
||||
- path: Conjuga
|
||||
excludes:
|
||||
- "*.json"
|
||||
- PrebuiltStore
|
||||
- path: Conjuga/conjuga_data.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/course_data.json
|
||||
buildPhase: resources
|
||||
- path: Conjuga/PrebuiltStore
|
||||
type: folder
|
||||
buildPhase: resources
|
||||
info:
|
||||
path: Conjuga/Info.plist
|
||||
properties:
|
||||
@@ -38,7 +54,10 @@ targets:
|
||||
LSApplicationCategoryType: public.app-category.education
|
||||
UILaunchScreen: {}
|
||||
UIBackgroundModes:
|
||||
- fetch
|
||||
- remote-notification
|
||||
BGTaskSchedulerPermittedIdentifiers:
|
||||
- com.conjuga.app.refresh
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
UISupportedInterfaceOrientations~ipad:
|
||||
|
||||
Reference in New Issue
Block a user