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:
Trey t
2026-04-10 13:51:02 -05:00
parent 4f30200544
commit fd5861c48d
48 changed files with 969 additions and 306 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 63; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -19,6 +19,7 @@
2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; }; 2B5B2D63DC9C290F66890A4A /* course_data.json in Resources */ = {isa = PBXBuildFile; fileRef = BC273716CD14A99EFF8206CA /* course_data.json */; };
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; }; 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */; };
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; }; 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F842EB5E566C74658D918BB /* HandwritingView.swift */; };
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */; };
35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; }; 35A0F6E7124D989312721F7D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */; };
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; }; 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */; };
377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; }; 377C4AA000CE9A0D8CC43DA9 /* GrammarNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */; };
@@ -33,23 +34,18 @@
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165B15630F4560F5891D9763 /* Verb.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6C1705F97FA0D59E996529 /* VerbForm.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; }; 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; }; 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; }; 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FB1479EA5779A109BC517D /* IrregularSpan.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; }; 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */; }; 8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */; };
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; }; 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E536AD1180FE10576EAC884A /* UserProgress.swift */; };
97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; }; 97EFCF6724CE59DC4F0274FD /* AchievementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */; };
A11A11111111111111111111 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11B11111111111111111111 /* ReviewStore.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
A22A22222222222222222222 /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22B22222222222222222222 /* ReferenceStore.swift */; };
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B33B33333333333333333333 /* CourseReviewStore.swift */; };
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44B44444444444444444444 /* PracticeSessionService.swift */; };
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55B55555555555555555555 /* StartupCoordinator.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; }; AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; }; BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
@@ -62,11 +58,14 @@
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; }; CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */; };
CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; }; CF9E48ADF0501FB79F3DDB7B /* conjuga_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */; };
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; }; D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E3AD244327CBF24B7A2752 /* SpeechService.swift */; };
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4B5204F6B8647C816814F0 /* SyncToast.swift */; };
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; }; D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; }; D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; }; D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; }; E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */; };
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; }; E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D570252DA3DCDD9217C71863 /* WidgetDataService.swift */; };
ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; }; ED0401D05A7C2B4C55057A88 /* DailyProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */; };
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; }; F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833516C5D57F164C8660A479 /* CourseView.swift */; };
@@ -105,17 +104,17 @@
102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; }; 102F0E136CDFF8CED710210F /* TensePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TensePill.swift; sourceTree = "<group>"; };
10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = "<group>"; };
143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = "<group>"; };
165B15630F4560F5891D9763 /* Verb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Verb.swift; sourceTree = "<group>"; };
16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16C1F74196C3C5628953BE3F /* Conjuga.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Conjuga.app; sourceTree = BUILT_PRODUCTS_DIR; };
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; }; 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressRing.swift; sourceTree = "<group>"; };
18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; }; 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = "<group>"; };
195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; }; 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = "<group>"; };
1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; }; 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = "<group>"; };
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; }; 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = "<group>"; };
1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = "<group>"; };
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; }; 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = "<group>"; };
1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; }; 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = "<group>"; };
1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = "<group>"; };
21FB1479EA5779A109BC517D /* IrregularSpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularSpan.swift; sourceTree = "<group>"; };
2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = "<group>"; };
2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = "<group>"; };
30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = "<group>"; };
@@ -139,37 +138,36 @@
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; }; 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; }; 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; }; 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; }; 80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = "<group>"; };
833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; }; 833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = "<group>"; };
83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; }; 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = "<group>"; };
8B6C1705F97FA0D59E996529 /* VerbForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbForm.swift; sourceTree = "<group>"; }; 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = "<group>"; };
8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = "<group>"; };
8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = "<group>"; };
9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ConjugaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; }; 9E1FB35614B709E6B1D1D017 /* Conjuga.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Conjuga.entitlements; sourceTree = "<group>"; };
A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = "<group>"; };
AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
B11B11111111111111111111 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
B22B22222222222222222222 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = "<group>"; };
B33B33333333333333333333 /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
B44B44444444444444444444 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = "<group>"; };
B55B55555555555555555555 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = "<group>"; };
BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = "<group>"; };
BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; }; C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = SharedModels; sourceTree = SOURCE_ROOT; }; CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = "<group>"; };
CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SharedModels; path = SharedModels; sourceTree = SOURCE_ROOT; };
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; }; D570252DA3DCDD9217C71863 /* WidgetDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataService.swift; sourceTree = "<group>"; };
DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = "<group>"; };
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = "<group>"; };
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = "<group>"; };
DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = "<group>"; };
E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = "<group>"; };
E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = "<group>"; };
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseGuide.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -230,15 +228,17 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */, 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */,
B33B33333333333333333333 /* CourseReviewStore.swift */, DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */, DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
B44B44444444444444444444 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
B22B22222222222222222222 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
B11B11111111111111111111 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
49E3AD244327CBF24B7A2752 /* SpeechService.swift */, 49E3AD244327CBF24B7A2752 /* SpeechService.swift */,
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */, 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */,
B55B55555555555555555555 /* StartupCoordinator.swift */, A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */,
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */,
18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */,
D570252DA3DCDD9217C71863 /* WidgetDataService.swift */, D570252DA3DCDD9217C71863 /* WidgetDataService.swift */,
); );
path = Services; path = Services;
@@ -257,16 +257,12 @@
children = ( children = (
0313D24F96E6A0039C34341F /* DailyLog.swift */, 0313D24F96E6A0039C34341F /* DailyLog.swift */,
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */, 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */,
21FB1479EA5779A109BC517D /* IrregularSpan.swift */,
626873572466403C0288090D /* QuizType.swift */, 626873572466403C0288090D /* QuizType.swift */,
0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */, 0A8A63F750065CA4EF36B4D3 /* ReviewCard.swift */,
69D98E1564C6538056D81200 /* TenseEndingTable.swift */, 69D98E1564C6538056D81200 /* TenseEndingTable.swift */,
F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */,
3BC3247457109FC6BF00D85B /* TenseInfo.swift */, 3BC3247457109FC6BF00D85B /* TenseInfo.swift */,
DAFE27F29412021AEC57E728 /* TestResult.swift */, DAFE27F29412021AEC57E728 /* TestResult.swift */,
E536AD1180FE10576EAC884A /* UserProgress.swift */, E536AD1180FE10576EAC884A /* UserProgress.swift */,
165B15630F4560F5891D9763 /* Verb.swift */,
8B6C1705F97FA0D59E996529 /* VerbForm.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -278,6 +274,7 @@
180F9D59828C36B44A5E384F /* DailyProgressRing.swift */, 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */,
80D974250C396589656B8443 /* HandwritingCanvas.swift */, 80D974250C396589656B8443 /* HandwritingCanvas.swift */,
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */, 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */,
1C4B5204F6B8647C816814F0 /* SyncToast.swift */,
102F0E136CDFF8CED710210F /* TensePill.swift */, 102F0E136CDFF8CED710210F /* TensePill.swift */,
); );
path = Components; path = Components;
@@ -472,6 +469,7 @@
packageReferences = ( packageReferences = (
548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */, 548B46ED3C40F5F28A5ADCC6 /* XCLocalSwiftPackageReference "SharedModels" */,
); );
preferredProjectObjectVersion = 77;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
@@ -504,6 +502,7 @@
CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */, CAC69045B74249F121643E88 /* AnswerReviewView.swift in Sources */,
C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */, C2B3D97F119EFCE97E3CB1CE /* ConjugaApp.swift in Sources */,
C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */, C3851F960C1162239DC2F935 /* CourseQuizView.swift in Sources */,
8C43F09F52EA9B537EA27E43 /* CourseReviewStore.swift in Sources */,
F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */, F0D0778207F144D6AC3D39C3 /* CourseView.swift in Sources */,
1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */, 1C2636790E70B6BC7FFCC904 /* DailyLog.swift in Sources */,
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */, BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */,
@@ -519,36 +518,34 @@
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */, E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */, 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */, 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
8324BD7600EF7E33941EF327 /* IrregularSpan.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */, C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */, 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */, 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,
A44A44444444444444444444 /* PracticeSessionService.swift in Sources */,
2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */, 2C7ABAB4D88E3E3B0EAD1EF7 /* PracticeHeaderView.swift in Sources */,
352A5BAA6E406AA5850653A4 /* PracticeSessionService.swift in Sources */,
1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */, 1A230C01A045F0C095BFBD35 /* PracticeView.swift in Sources */,
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */, C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */,
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */, 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */,
A22A22222222222222222222 /* ReferenceStore.swift in Sources */, DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */,
FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */, FC7873F97017532C215DAD34 /* ReviewCard.swift in Sources */,
A11A11111111111111111111 /* ReviewStore.swift in Sources */, 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */,
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */, 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */,
39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */, 39D0666B293DC265CF87B9DD /* SentenceBuilderView.swift in Sources */,
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */, 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */,
D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */, D3FFE73A5AD27F1759F50727 /* SpeechService.swift in Sources */,
A55A55555555555555555555 /* StartupCoordinator.swift in Sources */, 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */,
E814A9CF1067313F74B509C6 /* StoreInspector.swift in Sources */,
36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */, 36F92EBAEB0E5F2B010401EF /* StreakCalendarView.swift in Sources */,
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */,
D40B4E919DE379C50265CA9F /* SyncToast.swift in Sources */,
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */, AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */,
8FA34F10023A38DA67EE48F5 /* TenseGuide.swift in Sources */,
0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */, 0A89DCC82BE11605CB866DEF /* TenseInfo.swift in Sources */,
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */, 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */,
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */, D7456B289D135CEB3A15122B /* TestResult.swift in Sources */,
27BA7FA9356467846A07697D /* TypingView.swift in Sources */, 27BA7FA9356467846A07697D /* TypingView.swift in Sources */,
943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */, 943A94A8C71919F3EFC0E8FA /* UserProgress.swift in Sources */,
62037CE76C9915230CE7DD2D /* Verb.swift in Sources */,
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */, 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */,
7004FF1EE74DBD15853CFE5C /* VerbForm.swift in Sources */,
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */, 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */,
A33A33333333333333333333 /* CourseReviewStore.swift in Sources */,
4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */, 4005E258FDF03C8B3A0D53BD /* VocabFlashcardView.swift in Sources */,
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */, 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */,
E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */, E99473B7DF9BCAE150E9D1E1 /* WidgetDataService.swift in Sources */,

View File

@@ -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>

View File

@@ -2,26 +2,65 @@ import SwiftUI
import SharedModels import SharedModels
import SwiftData import SwiftData
import WidgetKit import WidgetKit
import BackgroundTasks
@MainActor
private enum CloudPreviewContainer {
static let value: ModelContainer = {
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
return try! ModelContainer(
for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self,
configurations: configuration
)
}()
}
typealias CloudModelContextProvider = @MainActor @Sendable () -> ModelContext
private let appRefreshTaskIdentifier = "com.conjuga.app.refresh"
private struct CloudModelContextProviderKey: EnvironmentKey {
static let defaultValue: CloudModelContextProvider = {
CloudPreviewContainer.value.mainContext
}
}
extension EnvironmentValues {
var cloudModelContextProvider: CloudModelContextProvider {
get { self[CloudModelContextProviderKey.self] }
set { self[CloudModelContextProviderKey.self] = newValue }
}
}
@main @main
struct ConjugaApp: App { struct ConjugaApp: App {
@AppStorage("onboardingComplete") private var onboardingComplete = false @AppStorage("onboardingComplete") private var onboardingComplete = false
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var isReady = false @State private var isReady = false
@State private var syncMonitor = SyncStatusMonitor()
let container: ModelContainer let localContainer: ModelContainer
let cloudContainer: ModelContainer
init() { init() {
guard let localURL = SharedStore.localStoreURL() else {
fatalError("App group 'group.com.conjuga.app' is not accessible. Check entitlements and provisioning profile.")
}
// One-time force-reset of the local store to clear stale schema metadata
// accumulated from previous container configurations.
Self.performOneTimeLocalStoreResetIfNeeded(at: localURL)
// DIAGNOSTIC: what's in the store file BEFORE we open it via SwiftData?
StoreInspector.dump(at: localURL, label: "before-open")
do { do {
let localConfig = ModelConfiguration( localContainer = try Self.makeValidatedLocalContainer(at: localURL)
"local", SharedStore.localContainer = localContainer
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self, // DIAGNOSTIC: what's in the store file AFTER SwiftData opened it?
TenseGuide.self, CourseDeck.self, VocabCard.self, StoreInspector.dump(at: localURL, label: "after-open")
]), print("[ConjugaApp] localContainer identity: \(ObjectIdentifier(localContainer))")
groupContainer: .none,
cloudKitDatabase: .none
)
let cloudConfig = ModelConfiguration( let cloudConfig = ModelConfiguration(
"cloud", "cloud",
@@ -31,13 +70,10 @@ struct ConjugaApp: App {
]), ]),
cloudKitDatabase: .private("iCloud.com.conjuga.app") cloudKitDatabase: .private("iCloud.com.conjuga.app")
) )
cloudContainer = try ModelContainer(
container = try ModelContainer( for: ReviewCard.self, CourseReviewCard.self, UserProgress.self,
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
ReviewCard.self, CourseReviewCard.self, UserProgress.self,
TestResult.self, DailyLog.self, TestResult.self, DailyLog.self,
configurations: localConfig, cloudConfig configurations: cloudConfig
) )
} catch { } catch {
fatalError("Failed to create ModelContainer: \(error)") fatalError("Failed to create ModelContainer: \(error)")
@@ -60,18 +96,147 @@ struct ConjugaApp: App {
OnboardingView() OnboardingView()
} }
} }
.overlay(alignment: .bottom) {
if syncMonitor.shouldShowToast {
SyncToast()
.padding(.bottom, 100)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.35), value: syncMonitor.shouldShowToast)
.environment(syncMonitor)
.environment(\.cloudModelContextProvider, { cloudContainer.mainContext })
.task { .task {
await StartupCoordinator.run(container: container) if let url = SharedStore.localStoreURL() {
WidgetDataService.update(context: container.mainContext) StoreInspector.dump(at: url, label: "before-bootstrap")
}
await StartupCoordinator.bootstrap(localContainer: localContainer)
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "after-bootstrap")
}
isReady = true isReady = true
Task { @MainActor in
syncMonitor.beginSync()
await StartupCoordinator.runMaintenance(
localContainer: localContainer,
cloudContainer: cloudContainer
)
WidgetDataService.update(
localContainer: localContainer,
cloudContainer: cloudContainer
)
syncMonitor.endSync()
}
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
if newPhase == .background { if newPhase == .background {
let context = container.mainContext WidgetDataService.update(
WidgetDataService.update(context: context) localContainer: localContainer,
cloudContainer: cloudContainer
)
Self.scheduleAppRefresh()
} }
} }
} }
.modelContainer(container) .modelContainer(localContainer)
.backgroundTask(.appRefresh(appRefreshTaskIdentifier)) {
Self.scheduleAppRefresh()
await refreshWidgetData()
}
}
@MainActor
private func refreshWidgetData() async {
WidgetDataService.update(
localContainer: localContainer,
cloudContainer: cloudContainer
)
WidgetCenter.shared.reloadAllTimelines()
}
nonisolated static func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: appRefreshTaskIdentifier)
// Minimum delay system decides actual run time based on usage patterns.
// We want the widget refreshed before the user typically opens the app.
request.earliestBeginDate = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("Failed to schedule app refresh: \(error)")
}
}
private static func makeValidatedLocalContainer(at url: URL) throws -> ModelContainer {
let container = try makeLocalContainer(at: url)
if localStoreIsUsable(container: container) {
return container
}
deleteStoreFiles(at: url)
UserDefaults.standard.removeObject(forKey: "courseDataVersion")
print("Reset corrupted local reference store")
return try makeLocalContainer(at: url)
}
private static func makeLocalContainer(at url: URL) throws -> ModelContainer {
let localConfig = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: url,
cloudKitDatabase: .none
)
return try ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: localConfig
)
}
private static func localStoreIsUsable(container: ModelContainer) -> Bool {
let context = ModelContext(container)
do {
_ = try context.fetchCount(FetchDescriptor<Verb>())
return true
} catch {
print("Local reference store validation failed: \(error)")
return false
}
}
private static func deleteStoreFiles(at url: URL) {
let fileManager = FileManager.default
for suffix in ["", "-wal", "-shm"] {
let candidateURL = URL(fileURLWithPath: url.path + suffix)
guard fileManager.fileExists(atPath: candidateURL.path) else { continue }
try? fileManager.removeItem(at: candidateURL)
}
}
/// One-time nuclear reset of the local reference store.
/// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 2 // bump: widget schema moved to SharedModels
let key = "localStoreResetVersion"
let defaults = UserDefaults.standard
guard defaults.integer(forKey: key) < resetVersion else { return }
print("[ConjugaApp] Performing one-time local store reset (v\(resetVersion))")
deleteStoreFiles(at: url)
// Clear any version flags that gate seeding so everything re-seeds cleanly.
defaults.removeObject(forKey: "courseDataVersion")
defaults.removeObject(forKey: "courseProgressMigrationVersion")
defaults.set(resetVersion, forKey: key)
} }
} }

View File

@@ -26,8 +26,13 @@
<dict/> <dict/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.conjuga.app.refresh</string>
</array>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
struct GrammarNote: Identifiable { struct GrammarNote: Identifiable {
let id: String let id: String

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
/// Static conjugation ending tables for all 20 tenses, used in Guide views. /// Static conjugation ending tables for all 20 tenses, used in Guide views.
/// Data sourced from the Spanish Verb Tenses chart and Conjuu ES conjugation rules. /// Data sourced from the Spanish Verb Tenses chart and Conjuu ES conjugation rules.

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
import SwiftData import SwiftData
import SharedModels
import Foundation import Foundation
@Model @Model

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
import SwiftData import SwiftData
enum Badge: String, CaseIterable, Identifiable, Sendable { enum Badge: String, CaseIterable, Identifiable, Sendable {
@@ -64,7 +65,11 @@ enum Badge: String, CaseIterable, Identifiable, Sendable {
struct AchievementService: Sendable { struct AchievementService: Sendable {
/// Check all badges and return any newly earned ones. /// Check all badges and return any newly earned ones.
static func checkAchievements(progress: UserProgress, context: ModelContext) -> [Badge] { static func checkAchievements(
progress: UserProgress,
reviewContext: ModelContext,
referenceContext: ModelContext
) -> [Badge] {
var newBadges: [Badge] = [] var newBadges: [Badge] = []
for badge in Badge.allCases { for badge in Badge.allCases {
@@ -81,15 +86,25 @@ struct AchievementService: Sendable {
case .streak30: case .streak30:
earned = progress.currentStreak >= 30 earned = progress.currentStreak >= 30
case .verbs25: case .verbs25:
earned = uniqueVerbsReviewed(context: context) >= 25 earned = uniqueVerbsReviewed(context: reviewContext) >= 25
case .verbs100: case .verbs100:
earned = uniqueVerbsReviewed(context: context) >= 100 earned = uniqueVerbsReviewed(context: reviewContext) >= 100
case .presentMaster: case .presentMaster:
earned = hasMasteredTense(TenseID.ind_presente.rawValue, level: VerbLevel.basic.rawValue, context: context) earned = hasMasteredTense(
TenseID.ind_presente.rawValue,
level: VerbLevel.basic.rawValue,
reviewContext: reviewContext,
referenceContext: referenceContext
)
case .preteriteMaster: case .preteriteMaster:
earned = hasMasteredTense(TenseID.ind_preterito.rawValue, level: VerbLevel.basic.rawValue, context: context) earned = hasMasteredTense(
TenseID.ind_preterito.rawValue,
level: VerbLevel.basic.rawValue,
reviewContext: reviewContext,
referenceContext: referenceContext
)
case .allTenses: case .allTenses:
earned = hasUsedAllTenses(context: context) earned = hasUsedAllTenses(context: reviewContext)
case .daily50: case .daily50:
earned = progress.todayCount >= 50 earned = progress.todayCount >= 50
} }
@@ -114,8 +129,13 @@ struct AchievementService: Sendable {
return uniqueVerbs.count return uniqueVerbs.count
} }
private static func hasMasteredTense(_ tenseId: String, level: String, context: ModelContext) -> Bool { private static func hasMasteredTense(
let verbIds = Set(ReferenceStore(context: context).fetchVerbs(selectedLevel: level).map(\.id)) _ tenseId: String,
level: String,
reviewContext: ModelContext,
referenceContext: ModelContext
) -> Bool {
let verbIds = Set(ReferenceStore(context: referenceContext).fetchVerbs(selectedLevel: level).map(\.id))
guard !verbIds.isEmpty else { return false } guard !verbIds.isEmpty else { return false }
let cardDescriptor = FetchDescriptor<ReviewCard>( let cardDescriptor = FetchDescriptor<ReviewCard>(
@@ -123,7 +143,7 @@ struct AchievementService: Sendable {
card.tenseId == tenseId && card.interval >= 21 card.tenseId == tenseId && card.interval >= 21
} }
) )
let cards = (try? context.fetch(cardDescriptor)) ?? [] let cards = (try? reviewContext.fetch(cardDescriptor)) ?? []
let masteredVerbIds = Set(cards.map(\.verbId)) let masteredVerbIds = Set(cards.map(\.verbId))
return verbIds.isSubset(of: masteredVerbIds) return verbIds.isSubset(of: masteredVerbIds)

View File

@@ -6,9 +6,14 @@ actor DataLoader {
static func seedIfNeeded(container: ModelContainer) async { static func seedIfNeeded(container: ModelContainer) async {
let context = ModelContext(container) let context = ModelContext(container)
var descriptor = FetchDescriptor<Verb>() let count: Int
descriptor.fetchLimit = 1 do {
let count = (try? context.fetchCount(descriptor)) ?? 0 count = try context.fetchCount(FetchDescriptor<Verb>())
print("[DataLoader] seedIfNeeded: existing verb count = \(count)")
} catch {
print("[DataLoader] ⚠️ seedIfNeeded fetchCount threw: \(error)")
count = 0
}
if count > 0 { return } if count > 0 { return }
print("Seeding database...") print("Seeding database...")
@@ -104,10 +109,14 @@ actor DataLoader {
print("Inserted \(spans.count) irregular spans") print("Inserted \(spans.count) irregular spans")
} }
try? context.save() do {
try context.save()
} catch {
print("[DataLoader] 🔥 Final verb save error: \(error)")
}
print("Verb seeding complete") print("Verb seeding complete")
// Seed course data // Seed course data (uses the same mainContext so @Query sees it)
seedCourseData(context: context) seedCourseData(context: context)
} }
@@ -135,16 +144,20 @@ actor DataLoader {
print("Course data re-seeded to version \(currentVersion)") print("Course data re-seeded to version \(currentVersion)")
} }
static func migrateCourseProgressIfNeeded(container: ModelContainer) async { static func migrateCourseProgressIfNeeded(
let migrationVersion = 1 localContainer: ModelContainer,
cloudContainer: ModelContainer
) async {
let migrationVersion = 2
let key = "courseProgressMigrationVersion" let key = "courseProgressMigrationVersion"
let shared = UserDefaults.standard let shared = UserDefaults.standard
if shared.integer(forKey: key) >= migrationVersion { return } if shared.integer(forKey: key) >= migrationVersion { return }
let context = ModelContext(container) let localContext = ModelContext(localContainer)
let cloudContext = ModelContext(cloudContainer)
let descriptor = FetchDescriptor<VocabCard>() let descriptor = FetchDescriptor<VocabCard>()
let allCards = (try? context.fetch(descriptor)) ?? [] let allCards = (try? localContext.fetch(descriptor)) ?? []
var migratedCount = 0 var migratedCount = 0
for card in allCards where hasLegacyCourseProgress(card) { for card in allCards where hasLegacyCourseProgress(card) {
@@ -154,7 +167,7 @@ actor DataLoader {
deckId: card.deckId, deckId: card.deckId,
front: card.front, front: card.front,
back: card.back, back: card.back,
context: context context: cloudContext
) )
if let reviewDate = reviewCard.lastReviewDate, if let reviewDate = reviewCard.lastReviewDate,
@@ -172,7 +185,7 @@ actor DataLoader {
} }
if migratedCount > 0 { if migratedCount > 0 {
try? context.save() try? cloudContext.save()
print("Migrated \(migratedCount) course progress cards to cloud store") print("Migrated \(migratedCount) course progress cards to cloud store")
} }

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
import SwiftData import SwiftData
struct PracticeSettings: Sendable { struct PracticeSettings: Sendable {
@@ -33,16 +34,18 @@ struct FullTablePrompt {
} }
struct PracticeSessionService { struct PracticeSessionService {
let context: ModelContext let localContext: ModelContext
let cloudContext: ModelContext
private let referenceStore: ReferenceStore private let referenceStore: ReferenceStore
init(context: ModelContext) { init(localContext: ModelContext, cloudContext: ModelContext) {
self.context = context self.localContext = localContext
self.referenceStore = ReferenceStore(context: context) self.cloudContext = cloudContext
self.referenceStore = ReferenceStore(context: localContext)
} }
func settings() -> PracticeSettings { func settings() -> PracticeSettings {
PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: context)) PracticeSettings(progress: ReviewStore.fetchOrCreateUserProgress(context: cloudContext))
} }
func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? { func nextCard(for focusMode: FocusMode) -> PracticeCardLoad? {
@@ -94,7 +97,8 @@ struct PracticeSessionService {
tenseId: tenseId, tenseId: tenseId,
personIndex: personIndex, personIndex: personIndex,
quality: quality, quality: quality,
context: context context: cloudContext,
referenceContext: localContext
) )
} }
@@ -103,7 +107,8 @@ struct PracticeSessionService {
verbId: verbId, verbId: verbId,
tenseId: tenseId, tenseId: tenseId,
results: results, results: results,
context: context context: cloudContext,
referenceContext: localContext
) )
} }
@@ -150,7 +155,7 @@ struct PracticeSessionService {
sortBy: [SortDescriptor(\ReviewCard.dueDate)] sortBy: [SortDescriptor(\ReviewCard.dueDate)]
) )
descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50 descriptor.fetchLimit = settings.enabledTenses.isEmpty ? 10 : 50
let cards = (try? context.fetch(descriptor)) ?? [] let cards = (try? cloudContext.fetch(descriptor)) ?? []
return cards.first { card in return cards.first { card in
allowedVerbIds.contains(card.verbId) && allowedVerbIds.contains(card.verbId) &&
@@ -167,7 +172,7 @@ struct PracticeSessionService {
predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 }, predicate: #Predicate<ReviewCard> { $0.easeFactor < 2.0 && $0.repetitions > 0 },
sortBy: [SortDescriptor(\ReviewCard.easeFactor)] sortBy: [SortDescriptor(\ReviewCard.easeFactor)]
) )
let cards = ((try? context.fetch(descriptor)) ?? []).filter { card in let cards = ((try? cloudContext.fetch(descriptor)) ?? []).filter { card in
allowedVerbIds.contains(card.verbId) && allowedVerbIds.contains(card.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) && (settings.enabledTenses.isEmpty || settings.enabledTenses.contains(card.tenseId)) &&
(settings.showVosotros || card.personIndex != 4) (settings.showVosotros || card.personIndex != 4)
@@ -203,7 +208,7 @@ struct PracticeSessionService {
) )
descriptor.fetchLimit = 500 descriptor.fetchLimit = 500
let spans = ((try? context.fetch(descriptor)) ?? []).filter { span in let spans = ((try? localContext.fetch(descriptor)) ?? []).filter { span in
allowedVerbIds.contains(span.verbId) && allowedVerbIds.contains(span.verbId) &&
(settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) && (settings.enabledTenses.isEmpty || settings.enabledTenses.contains(span.tenseId)) &&
(settings.showVosotros || span.personIndex != 4) (settings.showVosotros || span.personIndex != 4)

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
import SwiftData import SwiftData
struct ReferenceStore { struct ReferenceStore {

View File

@@ -113,7 +113,8 @@ struct ReviewStore {
tenseId: String, tenseId: String,
personIndex: Int, personIndex: Int,
quality: ReviewQuality, quality: ReviewQuality,
context: ModelContext context: ModelContext,
referenceContext: ModelContext
) -> [Badge] { ) -> [Badge] {
let card = fetchOrCreateReviewCard( let card = fetchOrCreateReviewCard(
verbId: verbId, verbId: verbId,
@@ -128,7 +129,11 @@ struct ReviewStore {
correctIncrement: quality.rawValue >= 3 ? 1 : 0, correctIncrement: quality.rawValue >= 3 ? 1 : 0,
context: context context: context
) )
let badges = AchievementService.checkAchievements(progress: progress, context: context) let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save() try? context.save()
return badges return badges
} }
@@ -137,7 +142,8 @@ struct ReviewStore {
verbId: Int, verbId: Int,
tenseId: String, tenseId: String,
results: [Int: Bool], results: [Int: Bool],
context: ModelContext context: ModelContext,
referenceContext: ModelContext
) -> [Badge] { ) -> [Badge] {
for (personIndex, isCorrect) in results { for (personIndex, isCorrect) in results {
let card = fetchOrCreateReviewCard( let card = fetchOrCreateReviewCard(
@@ -155,7 +161,11 @@ struct ReviewStore {
correctIncrement: allCorrect ? 1 : 0, correctIncrement: allCorrect ? 1 : 0,
context: context context: context
) )
let badges = AchievementService.checkAchievements(progress: progress, context: context) let badges = AchievementService.checkAchievements(
progress: progress,
reviewContext: context,
referenceContext: referenceContext
)
try? context.save() try? context.save()
return badges return badges
} }

View File

@@ -3,13 +3,24 @@ import SharedModels
import SwiftData import SwiftData
enum StartupCoordinator { enum StartupCoordinator {
/// First-launch work that must complete before the UI can be shown.
/// Both calls are self-gating: they return immediately if the work is already done.
@MainActor @MainActor
static func run(container: ModelContainer) async { static func bootstrap(localContainer: ModelContainer) async {
await DataLoader.seedIfNeeded(container: container) await DataLoader.seedIfNeeded(container: localContainer)
await DataLoader.refreshCourseDataIfNeeded(container: container) await DataLoader.refreshCourseDataIfNeeded(container: localContainer)
await DataLoader.migrateCourseProgressIfNeeded(container: container) }
let context = container.mainContext /// Recurring maintenance: legacy migrations, identity repair, cloud dedup.
/// Safe to run in the background after the UI is visible.
@MainActor
static func runMaintenance(localContainer: ModelContainer, cloudContainer: ModelContainer) async {
await DataLoader.migrateCourseProgressIfNeeded(
localContainer: localContainer,
cloudContainer: cloudContainer
)
let context = cloudContainer.mainContext
let progress = ReviewStore.fetchOrCreateUserProgress(context: context) let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
progress.migrateLegacyStorageIfNeeded() progress.migrateLegacyStorageIfNeeded()
if progress.enabledTenseIDs.isEmpty { if progress.enabledTenseIDs.isEmpty {

View 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))
}
}

View 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
}
}

View File

@@ -9,28 +9,35 @@ struct WidgetDataService {
static let suiteName = "group.com.conjuga.app" static let suiteName = "group.com.conjuga.app"
static let dataKey = "widgetData" static let dataKey = "widgetData"
/// Write current app state to shared storage for widgets to read. static func update(localContainer: ModelContainer, cloudContainer: ModelContainer) {
static func update(context: ModelContext) { let localContext = ModelContext(localContainer)
let cloudContext = ModelContext(cloudContainer)
update(localContext: localContext, cloudContext: cloudContext)
}
static func update(localContext: ModelContext, cloudContext: ModelContext) {
guard let shared = UserDefaults(suiteName: suiteName) else { return } guard let shared = UserDefaults(suiteName: suiteName) else { return }
// Fetch user progress let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudContext)
let progress = ReviewStore.fetchOrCreateUserProgress(context: context)
// Count due review cards
let now = Date() let now = Date()
let dueDescriptor = FetchDescriptor<ReviewCard>( let dueDescriptor = FetchDescriptor<ReviewCard>(
predicate: #Predicate<ReviewCard> { $0.dueDate <= now } predicate: #Predicate<ReviewCard> { $0.dueDate <= now }
) )
let dueCount = (try? context.fetchCount(dueDescriptor)) ?? 0 let dueCount = (try? cloudContext.fetchCount(dueDescriptor)) ?? 0
var wordOfDay: WordOfDay? var wordOfDay: WordOfDay?
let wordOffset = shared.integer(forKey: "wordOffset") let wordOffset = shared.integer(forKey: "wordOffset")
if let card = CourseCardStore.fetchWordOfDayCard(for: now, wordOffset: wordOffset, context: context) { if let card = CourseCardStore.fetchWordOfDayCard(
for: now,
wordOffset: wordOffset,
context: localContext
) {
let deckId = card.deckId let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>( let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId } predicate: #Predicate<CourseDeck> { $0.id == deckId }
) )
let deck = (try? context.fetch(deckDescriptor))?.first let deck = (try? localContext.fetch(deckDescriptor))?.first
wordOfDay = WordOfDay( wordOfDay = WordOfDay(
spanish: card.front, spanish: card.front,
english: card.back, english: card.back,
@@ -38,13 +45,10 @@ struct WidgetDataService {
) )
} }
// Latest test result
let testDescriptor = FetchDescriptor<TestResult>( let testDescriptor = FetchDescriptor<TestResult>(
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)] sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
) )
let latestTest = (try? context.fetch(testDescriptor))?.first let latestTest = (try? cloudContext.fetch(testDescriptor))?.first
// Determine current week (from most studied decks or latest test)
let currentWeek = latestTest?.weekNumber ?? 1 let currentWeek = latestTest?.weekNumber ?? 1
let previousData = shared.data(forKey: dataKey) let previousData = shared.data(forKey: dataKey)

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedModels
import SwiftData import SwiftData
enum PracticeMode: String, CaseIterable, Identifiable, Sendable { enum PracticeMode: String, CaseIterable, Identifiable, Sendable {
@@ -86,7 +87,7 @@ final class PracticeViewModel {
// MARK: - Load next card // MARK: - Load next card
func loadNextCard(context: ModelContext) { func loadNextCard(localContext: ModelContext, cloudContext: ModelContext) {
isAnswerRevealed = false isAnswerRevealed = false
userAnswer = "" userAnswer = ""
isCorrect = nil isCorrect = nil
@@ -94,7 +95,7 @@ final class PracticeViewModel {
currentSpans = [] currentSpans = []
hasCards = true hasCards = true
isLoading = true isLoading = true
let service = PracticeSessionService(context: context) let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
guard let cardLoad = service.nextCard(for: focusMode) else { guard let cardLoad = service.nextCard(for: focusMode) else {
clearCurrentCard() clearCurrentCard()
hasCards = false hasCards = false
@@ -102,7 +103,7 @@ final class PracticeViewModel {
return return
} }
applyCardLoad(cardLoad, context: context) applyCardLoad(cardLoad, localContext: localContext)
isLoading = false isLoading = false
} }
@@ -134,9 +135,9 @@ final class PracticeViewModel {
// MARK: - SRS rating // MARK: - SRS rating
func rateAnswer(quality: ReviewQuality, context: ModelContext) { func rateAnswer(quality: ReviewQuality, localContext: ModelContext, cloudContext: ModelContext) {
guard let form = currentForm else { return } guard let form = currentForm else { return }
let service = PracticeSessionService(context: context) let service = PracticeSessionService(localContext: localContext, cloudContext: cloudContext)
newBadges = service.rate( newBadges = service.rate(
verbId: form.verbId, verbId: form.verbId,
tenseId: form.tenseId, tenseId: form.tenseId,
@@ -208,7 +209,7 @@ final class PracticeViewModel {
// MARK: - Private helpers // MARK: - Private helpers
private func applyCardLoad(_ cardLoad: PracticeCardLoad, context: ModelContext) { private func applyCardLoad(_ cardLoad: PracticeCardLoad, localContext: ModelContext) {
currentVerb = cardLoad.verb currentVerb = cardLoad.verb
currentForm = cardLoad.form currentForm = cardLoad.form
currentSpans = cardLoad.spans currentSpans = cardLoad.spans
@@ -216,7 +217,7 @@ final class PracticeViewModel {
currentPerson = cardLoad.person currentPerson = cardLoad.person
if practiceMode == .multipleChoice { if practiceMode == .multipleChoice {
prepareMultipleChoice(for: cardLoad.form, context: context) prepareMultipleChoice(for: cardLoad.form, context: localContext)
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SharedModels
struct IrregularHighlightText: View { struct IrregularHighlightText: View {
let form: String let form: String

View 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()
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
/// Reusable tappable tense pill that shows a tense info sheet when tapped. /// Reusable tappable tense pill that shows a tense info sheet when tapped.

View File

@@ -9,7 +9,7 @@ struct CourseQuizView: View {
let weekNumber: Int let weekNumber: Int
let isFocusMode: Bool let isFocusMode: Bool
@Environment(\.modelContext) private var modelContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@@ -30,6 +30,7 @@ struct CourseQuizView: View {
@FocusState private var isTypingFocused: Bool @FocusState private var isTypingFocused: Bool
private var isComplete: Bool { currentIndex >= shuffledCards.count } private var isComplete: Bool { currentIndex >= shuffledCards.count }
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var currentCard: VocabCard? { private var currentCard: VocabCard? {
guard currentIndex < shuffledCards.count else { return nil } guard currentIndex < shuffledCards.count else { return nil }
@@ -513,8 +514,8 @@ struct CourseQuizView: View {
correctCount: correctCount, correctCount: correctCount,
missedItems: missedItems missedItems: missedItems
) )
modelContext.insert(result) cloudModelContext.insert(result)
try? modelContext.save() try? cloudModelContext.save()
} }
} }

View File

@@ -3,9 +3,12 @@ import SharedModels
import SwiftData import SwiftData
struct CourseView: View { struct CourseView: View {
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck] @Query(sort: \CourseDeck.weekNumber) private var decks: [CourseDeck]
@Query private var testResults: [TestResult]
@AppStorage("selectedCourse") private var selectedCourse: String? @AppStorage("selectedCourse") private var selectedCourse: String?
@State private var testResults: [TestResult] = []
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var courseNames: [String] { private var courseNames: [String] {
let names = Set(decks.map(\.courseName)) let names = Set(decks.map(\.courseName))
@@ -105,6 +108,7 @@ struct CourseView: View {
} }
} }
.navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse)) .navigationTitle(shortName(activeCourse).isEmpty ? "Course" : shortName(activeCourse))
.onAppear(perform: loadTestResults)
.navigationDestination(for: CourseDeck.self) { deck in .navigationDestination(for: CourseDeck.self) { deck in
DeckStudyView(deck: deck) DeckStudyView(deck: deck)
} }
@@ -113,6 +117,10 @@ struct CourseView: View {
} }
} }
} }
private func loadTestResults() {
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
}
} }
// MARK: - Deck Row // MARK: - Deck Row

View File

@@ -7,11 +7,13 @@ struct VocabFlashcardView: View {
let speechService: SpeechService let speechService: SpeechService
let onDone: () -> Void let onDone: () -> Void
@Environment(\.modelContext) private var modelContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var isRevealed = false @State private var isRevealed = false
@State private var sessionCorrect = 0 @State private var sessionCorrect = 0
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var currentCard: VocabCard? { private var currentCard: VocabCard? {
guard currentIndex < cards.count else { return nil } guard currentIndex < cards.count else { return nil }
return cards[currentIndex] return cards[currentIndex]
@@ -180,7 +182,7 @@ struct VocabFlashcardView: View {
private func rateAndAdvance(quality: ReviewQuality) { private func rateAndAdvance(quality: ReviewQuality) {
guard let card = currentCard else { return } guard let card = currentCard else { return }
CourseReviewStore(context: modelContext).rate(card: card, quality: quality) CourseReviewStore(context: cloudModelContext).rate(card: card, quality: quality)
if quality.rawValue >= 3 { sessionCorrect += 1 } if quality.rawValue >= 3 { sessionCorrect += 1 }

View File

@@ -5,16 +5,13 @@ import SwiftData
struct WeekTestView: View { struct WeekTestView: View {
let weekNumber: Int let weekNumber: Int
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query private var allResults: [TestResult] @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query private var allDecks: [CourseDeck] @Query private var allDecks: [CourseDeck]
@State private var loadedWeekCards: [VocabCard] = [] @State private var loadedWeekCards: [VocabCard] = []
@State private var weekResults: [TestResult] = []
private var weekResults: [TestResult] { private var cloudModelContext: ModelContext { cloudModelContextProvider() }
allResults
.filter { $0.weekNumber == weekNumber }
.sorted { $0.dateTaken > $1.dateTaken }
}
private var weekCards: [VocabCard] { private var weekCards: [VocabCard] {
loadedWeekCards loadedWeekCards
@@ -202,7 +199,10 @@ struct WeekTestView: View {
} }
.navigationTitle("Week \(weekNumber) Test") .navigationTitle("Week \(weekNumber) Test")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear { loadCards() } .onAppear {
loadResults()
loadCards()
}
} }
private func loadCards() { private func loadCards() {
@@ -220,6 +220,14 @@ struct WeekTestView: View {
loadedWeekCards = cards loadedWeekCards = cards
} }
private func loadResults() {
let descriptor = FetchDescriptor<TestResult>(
predicate: #Predicate<TestResult> { $0.weekNumber == weekNumber },
sortBy: [SortDescriptor(\TestResult.dateTaken, order: .reverse)]
)
weekResults = (try? cloudModelContext.fetch(descriptor)) ?? []
}
private func scoreColor(_ percent: Int) -> Color { private func scoreColor(_ percent: Int) -> Color {
if percent >= 90 { return .green } if percent >= 90 { return .green }
if percent >= 70 { return .orange } if percent >= 70 { return .orange }

View File

@@ -3,12 +3,13 @@ import SwiftData
import Charts import Charts
struct DashboardView: View { struct DashboardView: View {
@Query private var progress: [UserProgress] @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Query(sort: \DailyLog.dateString, order: .reverse) private var dailyLogs: [DailyLog] @State private var userProgress: UserProgress?
@Query private var testResults: [TestResult] @State private var dailyLogs: [DailyLog] = []
@Query private var reviewCards: [ReviewCard] @State private var testResults: [TestResult] = []
@State private var reviewCards: [ReviewCard] = []
private var userProgress: UserProgress? { progress.first } private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -32,6 +33,7 @@ struct DashboardView: View {
.adaptiveContainer(maxWidth: 800) .adaptiveContainer(maxWidth: 800)
} }
.navigationTitle("Dashboard") .navigationTitle("Dashboard")
.onAppear(perform: loadData)
} }
} }
@@ -166,6 +168,17 @@ struct DashboardView: View {
let total = logsWithData.reduce(0.0) { $0 + $1.accuracy } let total = logsWithData.reduce(0.0) { $0 + $1.accuracy }
return Int(total / Double(logsWithData.count) * 100) return Int(total / Double(logsWithData.count) * 100)
} }
private func loadData() {
userProgress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
let dailyDescriptor = FetchDescriptor<DailyLog>(
sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)]
)
dailyLogs = (try? cloudModelContext.fetch(dailyDescriptor)) ?? []
testResults = (try? cloudModelContext.fetch(FetchDescriptor<TestResult>())) ?? []
reviewCards = (try? cloudModelContext.fetch(FetchDescriptor<ReviewCard>())) ?? []
try? cloudModelContext.save()
}
} }
// MARK: - Stat Card // MARK: - Stat Card

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import SharedModels
struct GuideView: View { struct GuideView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@@ -38,6 +39,7 @@ struct GuideView: View {
} }
} }
.navigationTitle("Guide") .navigationTitle("Guide")
.task { loadGuides() }
.onAppear(perform: loadGuides) .onAppear(perform: loadGuides)
.onChange(of: selectedTab) { _, _ in .onChange(of: selectedTab) { _, _ in
selectedGuide = nil selectedGuide = nil
@@ -78,7 +80,14 @@ struct GuideView: View {
} }
private func loadGuides() { private func loadGuides() {
guides = ReferenceStore(context: modelContext).fetchGuides() // Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
print("[GuideView] ⚠️ SharedStore.localContainer is nil")
return
}
let context = ModelContext(container)
guides = ReferenceStore(context: context).fetchGuides()
print("[GuideView] loaded \(guides.count) tense guides (container: \(ObjectIdentifier(container)))")
if selectedGuide == nil { if selectedGuide == nil {
selectedGuide = guides.first selectedGuide = guides.first
} }

View File

@@ -1,13 +1,15 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct OnboardingView: View { struct OnboardingView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@AppStorage("onboardingComplete") private var onboardingComplete = false @AppStorage("onboardingComplete") private var onboardingComplete = false
@State private var currentPage = 0 @State private var currentPage = 0
@State private var selectedLevel: VerbLevel = .basic @State private var selectedLevel: VerbLevel = .basic
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
@@ -125,12 +127,12 @@ struct OnboardingView: View {
} }
private func completeOnboarding() { private func completeOnboarding() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress.selectedVerbLevel = selectedLevel progress.selectedVerbLevel = selectedLevel
if progress.enabledTenseIDs.isEmpty { if progress.enabledTenseIDs.isEmpty {
progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses() progress.enabledTenseIDs = ReviewStore.defaultEnabledTenses()
} }
try? modelContext.save() try? cloudModelContext.save()
onboardingComplete = true onboardingComplete = true
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SharedModels
struct AnswerReviewView: View { struct AnswerReviewView: View {
let form: VerbForm? let form: VerbForm?

View File

@@ -1,11 +1,15 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct FlashcardView: View { struct FlashcardView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
var viewModel: PracticeViewModel var viewModel: PracticeViewModel
let speechService: SpeechService let speechService: SpeechService
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
@@ -72,12 +76,22 @@ struct FlashcardView: View {
spans: viewModel.currentSpans, spans: viewModel.currentSpans,
speechService: speechService, speechService: speechService,
onRate: { quality in onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext) viewModel.rateAnswer(
viewModel.loadNextCard(context: modelContext) quality: quality,
localContext: modelContext,
cloudContext: cloudModelContext
)
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
}, },
showAnswer: false, showAnswer: false,
onNext: { onNext: {
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
} }
) )
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))

View File

@@ -1,10 +1,12 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
import PencilKit import PencilKit
/// Practice mode where user fills in all 6 person conjugations for a verb + tense. /// Practice mode where user fills in all 6 person conjugations for a verb + tense.
struct FullTableView: View { struct FullTableView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
let speechService: SpeechService let speechService: SpeechService
@State private var currentVerb: Verb? @State private var currentVerb: Verb?
@@ -27,6 +29,7 @@ struct FullTableView: View {
@FocusState private var focusedField: Int? @FocusState private var focusedField: Int?
private let persons = TenseInfo.persons private let persons = TenseInfo.persons
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var personsToShow: [(index: Int, label: String)] { private var personsToShow: [(index: Int, label: String)] {
persons.enumerated().compactMap { index, label in persons.enumerated().compactMap { index, label in
@@ -240,7 +243,7 @@ struct FullTableView: View {
results = Array(repeating: nil, count: 6) results = Array(repeating: nil, count: 6)
correctForms = [] correctForms = []
drawings = Array(repeating: PKDrawing(), count: 6) drawings = Array(repeating: PKDrawing(), count: 6)
let service = PracticeSessionService(context: modelContext) let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
guard let prompt = service.randomFullTablePrompt() else { guard let prompt = service.randomFullTablePrompt() else {
currentVerb = nil currentVerb = nil
currentTense = nil currentTense = nil
@@ -309,7 +312,7 @@ struct FullTableView: View {
if allCorrect { sessionCorrect += 1 } if allCorrect { sessionCorrect += 1 }
if let verb = currentVerb, let tense = currentTense { if let verb = currentVerb, let tense = currentTense {
let service = PracticeSessionService(context: modelContext) let service = PracticeSessionService(localContext: modelContext, cloudContext: cloudModelContext)
let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) }) let reviewResults = Dictionary(uniqueKeysWithValues: personsToShow.map { ($0.index, results[$0.index] == true) })
_ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults) _ = service.recordFullTableReview(verbId: verb.id, tenseId: tense.id, results: reviewResults)
} }
@@ -328,7 +331,7 @@ struct FullTableView: View {
} }
private func loadSettings() { private func loadSettings() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: modelContext) let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
showVosotros = progress.showVosotros showVosotros = progress.showVosotros
autoFillStem = progress.autoFillStem autoFillStem = progress.autoFillStem
} }

View File

@@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
import PencilKit import PencilKit
struct HandwritingView: View { struct HandwritingView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
var viewModel: PracticeViewModel var viewModel: PracticeViewModel
let speechService: SpeechService let speechService: SpeechService
@@ -11,6 +13,8 @@ struct HandwritingView: View {
@State private var recognizedText = "" @State private var recognizedText = ""
@State private var isRecognizing = false @State private var isRecognizing = false
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
@@ -50,12 +54,22 @@ struct HandwritingView: View {
spans: viewModel.currentSpans, spans: viewModel.currentSpans,
speechService: speechService, speechService: speechService,
onRate: { quality in onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext) viewModel.rateAnswer(
viewModel.loadNextCard(context: modelContext) quality: quality,
localContext: modelContext,
cloudContext: cloudModelContext
)
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
resetCanvas() resetCanvas()
}, },
onNext: { onNext: {
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
resetCanvas() resetCanvas()
} }
) )

View File

@@ -1,12 +1,16 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct MultipleChoiceView: View { struct MultipleChoiceView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
var viewModel: PracticeViewModel var viewModel: PracticeViewModel
let speechService: SpeechService let speechService: SpeechService
@State private var selectedIndex: Int? @State private var selectedIndex: Int?
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
@@ -41,13 +45,23 @@ struct MultipleChoiceView: View {
spans: viewModel.currentSpans, spans: viewModel.currentSpans,
speechService: speechService, speechService: speechService,
onRate: { quality in onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext) viewModel.rateAnswer(
quality: quality,
localContext: modelContext,
cloudContext: cloudModelContext
)
selectedIndex = nil selectedIndex = nil
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
}, },
onNext: { onNext: {
selectedIndex = nil selectedIndex = nil
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
} }
) )
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SharedModels
struct PracticeHeaderView: View { struct PracticeHeaderView: View {
let verb: Verb? let verb: Verb?

View File

@@ -1,16 +1,16 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct PracticeView: View { struct PracticeView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query private var progress: [UserProgress] @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var viewModel = PracticeViewModel() @State private var viewModel = PracticeViewModel()
@State private var speechService = SpeechService() @State private var speechService = SpeechService()
@State private var isPracticing = false @State private var isPracticing = false
@State private var userProgress: UserProgress?
private var userProgress: UserProgress? { private var cloudModelContext: ModelContext { cloudModelContextProvider() }
progress.first
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -23,6 +23,12 @@ struct PracticeView: View {
} }
.navigationTitle("Practice") .navigationTitle("Practice")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onAppear(perform: loadProgress)
.onChange(of: isPracticing) { _, practicing in
if !practicing {
loadProgress()
}
}
.toolbar { .toolbar {
if isPracticing { if isPracticing {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
@@ -80,7 +86,10 @@ struct PracticeView: View {
viewModel.focusMode = .none viewModel.focusMode = .none
viewModel.sessionCorrect = 0 viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0 viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { withAnimation {
isPracticing = true isPracticing = true
} }
@@ -101,7 +110,10 @@ struct PracticeView: View {
viewModel.focusMode = .weakVerbs viewModel.focusMode = .weakVerbs
viewModel.sessionCorrect = 0 viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0 viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
withAnimation { isPracticing = true } withAnimation { isPracticing = true }
} label: { } label: {
HStack(spacing: 14) { HStack(spacing: 14) {
@@ -311,9 +323,15 @@ extension PracticeView {
viewModel.focusMode = .irregularity(filter) viewModel.focusMode = .irregularity(filter)
viewModel.sessionCorrect = 0 viewModel.sessionCorrect = 0
viewModel.sessionTotal = 0 viewModel.sessionTotal = 0
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(localContext: modelContext, cloudContext: cloudModelContext)
withAnimation { isPracticing = true } withAnimation { isPracticing = true }
} }
private func loadProgress() {
let progress = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
userProgress = progress
try? cloudModelContext.save()
}
} }
#Preview { #Preview {

View File

@@ -1,12 +1,16 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct TypingView: View { struct TypingView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Bindable var viewModel: PracticeViewModel @Bindable var viewModel: PracticeViewModel
let speechService: SpeechService let speechService: SpeechService
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 24) { VStack(spacing: 24) {
@@ -78,12 +82,22 @@ struct TypingView: View {
spans: viewModel.currentSpans, spans: viewModel.currentSpans,
speechService: speechService, speechService: speechService,
onRate: { quality in onRate: { quality in
viewModel.rateAnswer(quality: quality, context: modelContext) viewModel.rateAnswer(
viewModel.loadNextCard(context: modelContext) quality: quality,
localContext: modelContext,
cloudContext: cloudModelContext
)
viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
isTextFieldFocused = true isTextFieldFocused = true
}, },
onNext: { onNext: {
viewModel.loadNextCard(context: modelContext) viewModel.loadNextCard(
localContext: modelContext,
cloudContext: cloudModelContext
)
isTextFieldFocused = true isTextFieldFocused = true
} }
) )

View File

@@ -1,8 +1,9 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct SettingsView: View { struct SettingsView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@State private var progress: UserProgress? @State private var progress: UserProgress?
@State private var dailyGoal: Double = 50 @State private var dailyGoal: Double = 50
@@ -11,6 +12,7 @@ struct SettingsView: View {
@State private var selectedLevel: VerbLevel = .basic @State private var selectedLevel: VerbLevel = .basic
private let levels = VerbLevel.allCases private let levels = VerbLevel.allCases
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -83,7 +85,7 @@ struct SettingsView: View {
} }
private func loadProgress() { private func loadProgress() {
let resolved = ReviewStore.fetchOrCreateUserProgress(context: modelContext) let resolved = ReviewStore.fetchOrCreateUserProgress(context: cloudModelContext)
progress = resolved progress = resolved
dailyGoal = Double(resolved.dailyGoal) dailyGoal = Double(resolved.dailyGoal)
showVosotros = resolved.showVosotros showVosotros = resolved.showVosotros
@@ -92,7 +94,7 @@ struct SettingsView: View {
} }
private func saveProgress() { private func saveProgress() {
try? modelContext.save() try? cloudModelContext.save()
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SharedModels
import SwiftData import SwiftData
struct VerbDetailView: View { struct VerbDetailView: View {

View File

@@ -1,9 +1,10 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import SharedModels
struct VerbListView: View { struct VerbListView: View {
@Environment(\.modelContext) private var modelContext @Environment(\.modelContext) private var modelContext
@Query(sort: \Verb.infinitive) private var verbs: [Verb] @State private var verbs: [Verb] = []
@State private var searchText = "" @State private var searchText = ""
@State private var selectedLevel: String? @State private var selectedLevel: String?
@State private var selectedVerb: Verb? @State private var selectedVerb: Verb?
@@ -46,6 +47,8 @@ struct VerbListView: View {
} }
} }
} }
.task { loadVerbs() }
.onAppear { loadVerbs() }
} detail: { } detail: {
if let verb = selectedVerb { if let verb = selectedVerb {
VerbDetailView(verb: verb) VerbDetailView(verb: verb)
@@ -54,6 +57,20 @@ struct VerbListView: View {
} }
} }
} }
private func loadVerbs() {
// Hit the shared local container directly, bypassing @Environment.
guard let container = SharedStore.localContainer else {
print("[VerbListView] ⚠️ SharedStore.localContainer is nil")
return
}
if let url = SharedStore.localStoreURL() {
StoreInspector.dump(at: url, label: "verb-list-load")
}
let context = ModelContext(container)
verbs = ReferenceStore(context: context).fetchVerbs()
print("[VerbListView] loaded \(verbs.count) verbs (container: \(ObjectIdentifier(container)))")
}
} }
struct VerbRowView: View { struct VerbRowView: View {

View File

@@ -2,9 +2,6 @@ import WidgetKit
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import SharedModels import SharedModels
import os
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "CombinedWidget")
struct CombinedEntry: TimelineEntry { struct CombinedEntry: TimelineEntry {
let date: Date let date: Date
@@ -24,13 +21,13 @@ struct CombinedProvider: TimelineProvider {
completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder)) completion(CombinedEntry(date: Date(), word: Self.previewWord, data: .placeholder))
return return
} }
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord let word = fetchWordOfDay(for: Date())
let data = WidgetDataReader.read() let data = WidgetDataReader.read()
completion(CombinedEntry(date: Date(), word: word, data: data)) completion(CombinedEntry(date: Date(), word: word, data: data))
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<CombinedEntry>) -> Void) {
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord let word = fetchWordOfDay(for: Date())
let data = WidgetDataReader.read() let data = WidgetDataReader.read()
let entry = CombinedEntry(date: Date(), word: word, data: data) let entry = CombinedEntry(date: Date(), word: word, data: data)
@@ -42,23 +39,24 @@ struct CombinedProvider: TimelineProvider {
} }
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store") guard let localURL = SharedStore.localStoreURL() else { return nil }
logger.info("Combined store path: \(localURL.path), exists: \(FileManager.default.fileExists(atPath: localURL.path))")
if !FileManager.default.fileExists(atPath: localURL.path) {
let dir = localURL.deletingLastPathComponent()
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? []
logger.error("local.store NOT FOUND. Contents: \(contents.joined(separator: ", "))")
return nil
}
// MUST declare all 6 local entities to match the main app's schema.
// Declaring a subset would cause SwiftData to destructively migrate the store
// on open, dropping the entities not listed here.
let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer( guard let container = try? ModelContainer(
for: VocabCard.self, CourseDeck.self, for: Verb.self, VerbForm.self, IrregularSpan.self,
configurations: ModelConfiguration( TenseGuide.self, CourseDeck.self, VocabCard.self,
"local", configurations: config
url: localURL,
cloudKitDatabase: .none
)
) else { return nil } ) else { return nil }
let context = ModelContext(container) let context = ModelContext(container)
@@ -71,9 +69,20 @@ struct CombinedProvider: TimelineProvider {
let deckDescriptor = FetchDescriptor<CourseDeck>( let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId } predicate: #Predicate<CourseDeck> { $0.id == deckId }
) )
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1 let deck = (try? context.fetch(deckDescriptor))?.first
let week = deck?.weekNumber ?? 1
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week) // If the deck is reversed (English on front), swap so spanish is always Spanish.
let spanish: String
let english: String
if deck?.isReversed == true {
spanish = card.back
english = card.front
} else {
spanish = card.front
english = card.back
}
return WordOfDay(spanish: spanish, english: english, weekNumber: week)
} }
} }

View File

@@ -2,9 +2,6 @@ import WidgetKit
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import SharedModels import SharedModels
import os
private let logger = Logger(subsystem: "com.conjuga.app.widget", category: "WordOfDay")
struct WordOfDayEntry: TimelineEntry { struct WordOfDayEntry: TimelineEntry {
let date: Date let date: Date
@@ -16,21 +13,16 @@ struct WordOfDayProvider: TimelineProvider {
WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)) WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1))
} }
private static let previewWord = WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)
func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) { func getSnapshot(in context: Context, completion: @escaping (WordOfDayEntry) -> Void) {
if context.isPreview { if context.isPreview {
completion(WordOfDayEntry(date: Date(), word: Self.previewWord)) completion(WordOfDayEntry(date: Date(), word: WordOfDay(spanish: "hablar", english: "to speak", weekNumber: 1)))
return return
} }
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord completion(WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date())))
completion(WordOfDayEntry(date: Date(), word: word))
} }
func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) { func getTimeline(in context: Context, completion: @escaping (Timeline<WordOfDayEntry>) -> Void) {
let word = fetchWordOfDay(for: Date()) ?? Self.previewWord let entry = WordOfDayEntry(date: Date(), word: fetchWordOfDay(for: Date()))
let entry = WordOfDayEntry(date: Date(), word: word)
let tomorrow = Calendar.current.startOfDay( let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())! for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
) )
@@ -38,48 +30,49 @@ struct WordOfDayProvider: TimelineProvider {
} }
private func fetchWordOfDay(for date: Date) -> WordOfDay? { private func fetchWordOfDay(for date: Date) -> WordOfDay? {
let localURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.conjuga.app")!.appendingPathComponent("local.store") guard let localURL = SharedStore.localStoreURL() else { return nil }
logger.info("Store path: \(localURL.path)")
logger.info("Store exists: \(FileManager.default.fileExists(atPath: localURL.path))")
if !FileManager.default.fileExists(atPath: localURL.path) { // MUST declare all 6 local entities to match the main app's schema.
let dir = localURL.deletingLastPathComponent() // Declaring a subset would cause SwiftData to destructively migrate the store
let contents = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] // on open, dropping the entities not listed here.
logger.error("local.store NOT FOUND. App Support contents: \(contents.joined(separator: ", "))") let config = ModelConfiguration(
"local",
schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
]),
url: localURL,
cloudKitDatabase: .none
)
guard let container = try? ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self,
configurations: config
) else { return nil }
let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
return nil return nil
} }
let deckId = card.deckId
let descriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let deck = (try? context.fetch(descriptor))?.first
let week = deck?.weekNumber ?? 1
do { // If the deck is reversed (English on front), swap so spanish is always Spanish.
let container = try ModelContainer( let spanish: String
for: VocabCard.self, CourseDeck.self, let english: String
configurations: ModelConfiguration( if deck?.isReversed == true {
"local", spanish = card.back
url: localURL, english = card.front
cloudKitDatabase: .none } else {
) spanish = card.front
) english = card.back
logger.info("ModelContainer opened OK")
let context = ModelContext(container)
let wordOffset = UserDefaults(suiteName: "group.com.conjuga.app")?.integer(forKey: "wordOffset") ?? 0
guard let card = CourseCardStore.fetchWordOfDayCard(for: date, wordOffset: wordOffset, context: context) else {
logger.error("Store has 0 VocabCards")
return nil
}
logger.info("Picked card: \(card.front) = \(card.back)")
let deckId = card.deckId
let deckDescriptor = FetchDescriptor<CourseDeck>(
predicate: #Predicate<CourseDeck> { $0.id == deckId }
)
let week = (try? context.fetch(deckDescriptor))?.first?.weekNumber ?? 1
return WordOfDay(spanish: card.front, english: card.back, weekNumber: week)
} catch {
logger.error("Failed: \(error.localizedDescription)")
return nil
} }
return WordOfDay(spanish: spanish, english: english, weekNumber: week)
} }
} }

View File

@@ -2,18 +2,18 @@ import SwiftData
import Foundation import Foundation
@Model @Model
final class IrregularSpan { public final class IrregularSpan {
var verbId: Int = 0 public var verbId: Int = 0
var tenseId: String = "" public var tenseId: String = ""
var personIndex: Int = 0 public var personIndex: Int = 0
var spanType: Int = 0 public var spanType: Int = 0
var pattern: Int = 0 public var pattern: Int = 0
var start: Int = 0 public var start: Int = 0
var end: Int = 0 public var end: Int = 0
var verbForm: VerbForm? public var verbForm: VerbForm?
init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) { public init(verbId: Int, tenseId: String, personIndex: Int, spanType: Int, pattern: Int, start: Int, end: Int) {
self.verbId = verbId self.verbId = verbId
self.tenseId = tenseId self.tenseId = tenseId
self.personIndex = personIndex self.personIndex = personIndex
@@ -23,7 +23,7 @@ final class IrregularSpan {
self.end = end self.end = end
} }
var category: SpanCategory { public var category: SpanCategory {
switch spanType { switch spanType {
case 100..<200: return .spelling case 100..<200: return .spelling
case 200..<300: return .stemChange case 200..<300: return .stemChange
@@ -32,7 +32,7 @@ final class IrregularSpan {
} }
} }
enum SpanCategory: String { public enum SpanCategory: String, Sendable {
case spelling = "Spelling Change" case spelling = "Spelling Change"
case stemChange = "Stem Change" case stemChange = "Stem Change"
case uniqueIrregular = "Unique Irregular" case uniqueIrregular = "Unique Irregular"

View 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?
}

View 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
}
}

View File

@@ -1,31 +1,30 @@
import SwiftData import SwiftData
import Foundation import Foundation
enum VerbLevel: String, CaseIterable, Sendable { public enum VerbLevel: String, CaseIterable, Sendable {
case basic case basic
case elementary case elementary
case intermediate case intermediate
case advanced case advanced
case expert case expert
var displayName: String { rawValue.capitalized } public var displayName: String { rawValue.capitalized }
} }
@Model @Model
final class Verb { public final class Verb {
var id: Int = 0 public var id: Int = 0
var infinitive: String = "" public var infinitive: String = ""
var english: String = "" public var english: String = ""
var rank: Int = 0 public var rank: Int = 0
var ending: String = "" public var ending: String = ""
var reflexive: Int = 0 public var reflexive: Int = 0
var level: String = "" public var level: String = ""
@Relationship(deleteRule: .cascade, inverse: \VerbForm.verb) @Relationship(deleteRule: .cascade, inverse: \VerbForm.verb)
var forms: [VerbForm]? public var forms: [VerbForm]?
public init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
init(id: Int, infinitive: String, english: String, rank: Int, ending: String, reflexive: Int, level: String) {
self.id = id self.id = id
self.infinitive = infinitive self.infinitive = infinitive
self.english = english self.english = english
@@ -36,14 +35,14 @@ final class Verb {
} }
} }
enum VerbLevelGroup: String, CaseIterable, Sendable { public enum VerbLevelGroup: String, CaseIterable, Sendable {
case basic = "basic" case basic = "basic"
case elementary = "elementary" case elementary = "elementary"
case intermediate = "intermediate" case intermediate = "intermediate"
case advanced = "advanced" case advanced = "advanced"
case expert = "expert" case expert = "expert"
static func dataLevels(for selectedLevel: String) -> Set<String> { public static func dataLevels(for selectedLevel: String) -> Set<String> {
switch selectedLevel { switch selectedLevel {
case Self.basic.rawValue: case Self.basic.rawValue:
return ["basic"] return ["basic"]
@@ -60,7 +59,7 @@ enum VerbLevelGroup: String, CaseIterable, Sendable {
} }
} }
static func matches(_ dataLevel: String, selectedLevel: String) -> Bool { public static func matches(_ dataLevel: String, selectedLevel: String) -> Bool {
dataLevels(for: selectedLevel).contains(dataLevel) dataLevels(for: selectedLevel).contains(dataLevel)
} }
} }

View 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
}
}

View File

@@ -4,6 +4,26 @@ options:
deploymentTarget: deploymentTarget:
iOS: "26.0" iOS: "26.0"
xcodeVersion: "26.0" xcodeVersion: "26.0"
schemePathPrefix: ""
generateEmptyDirectories: true
schemes:
Conjuga:
build:
targets:
Conjuga: all
ConjugaWidgetExtension: all
run:
config: Debug
executable: Conjuga
test:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
packages: packages:
SharedModels: SharedModels:
@@ -23,14 +43,10 @@ targets:
- path: Conjuga - path: Conjuga
excludes: excludes:
- "*.json" - "*.json"
- PrebuiltStore
- path: Conjuga/conjuga_data.json - path: Conjuga/conjuga_data.json
buildPhase: resources buildPhase: resources
- path: Conjuga/course_data.json - path: Conjuga/course_data.json
buildPhase: resources buildPhase: resources
- path: Conjuga/PrebuiltStore
type: folder
buildPhase: resources
info: info:
path: Conjuga/Info.plist path: Conjuga/Info.plist
properties: properties:
@@ -38,7 +54,10 @@ targets:
LSApplicationCategoryType: public.app-category.education LSApplicationCategoryType: public.app-category.education
UILaunchScreen: {} UILaunchScreen: {}
UIBackgroundModes: UIBackgroundModes:
- fetch
- remote-notification - remote-notification
BGTaskSchedulerPermittedIdentifiers:
- com.conjuga.app.refresh
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
UISupportedInterfaceOrientations~ipad: UISupportedInterfaceOrientations~ipad: