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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,4 +1,5 @@
import SwiftUI
import SharedModels
struct IrregularHighlightText: View {
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 SharedModels
import SwiftData
/// 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 isFocusMode: Bool
@Environment(\.modelContext) private var modelContext
@Environment(\.cloudModelContextProvider) private var cloudModelContextProvider
@Environment(\.dismiss) private var dismiss
@State private var speechService = SpeechService()
@@ -30,6 +30,7 @@ struct CourseQuizView: View {
@FocusState private var isTypingFocused: Bool
private var isComplete: Bool { currentIndex >= shuffledCards.count }
private var cloudModelContext: ModelContext { cloudModelContextProvider() }
private var currentCard: VocabCard? {
guard currentIndex < shuffledCards.count else { return nil }
@@ -513,8 +514,8 @@ struct CourseQuizView: View {
correctCount: correctCount,
missedItems: missedItems
)
modelContext.insert(result)
try? modelContext.save()
cloudModelContext.insert(result)
try? cloudModelContext.save()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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