From fd5861c48d42fba1189360ce3911cf3f4d2f8ea9 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 10 Apr 2026 13:51:02 -0500 Subject: [PATCH] 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) --- Conjuga/Conjuga.xcodeproj/project.pbxproj | 73 +++--- .../xcshareddata/xcschemes/Conjuga.xcscheme | 109 +++++++++ Conjuga/Conjuga/ConjugaApp.swift | 207 ++++++++++++++++-- Conjuga/Conjuga/Info.plist | 5 + Conjuga/Conjuga/Models/GrammarNote.swift | 1 + Conjuga/Conjuga/Models/TenseEndingTable.swift | 1 + Conjuga/Conjuga/Models/TenseGuide.swift | 15 -- Conjuga/Conjuga/Models/UserProgress.swift | 1 + Conjuga/Conjuga/Models/VerbForm.swift | 24 -- .../Conjuga/Services/AchievementService.swift | 38 +++- Conjuga/Conjuga/Services/DataLoader.swift | 35 ++- .../Services/PracticeSessionService.swift | 25 ++- Conjuga/Conjuga/Services/ReferenceStore.swift | 1 + Conjuga/Conjuga/Services/ReviewStore.swift | 18 +- .../Conjuga/Services/StartupCoordinator.swift | 21 +- Conjuga/Conjuga/Services/StoreInspector.swift | 64 ++++++ .../Conjuga/Services/SyncStatusMonitor.swift | 42 ++++ .../Conjuga/Services/WidgetDataService.swift | 28 ++- .../ViewModels/PracticeViewModel.swift | 15 +- .../Components/IrregularHighlightText.swift | 1 + .../Conjuga/Views/Components/SyncToast.swift | 23 ++ .../Conjuga/Views/Components/TensePill.swift | 1 + .../Conjuga/Views/Course/CourseQuizView.swift | 7 +- Conjuga/Conjuga/Views/Course/CourseView.swift | 10 +- .../Views/Course/VocabFlashcardView.swift | 6 +- .../Conjuga/Views/Course/WeekTestView.swift | 22 +- .../Views/Dashboard/DashboardView.swift | 23 +- Conjuga/Conjuga/Views/Guide/GuideView.swift | 11 +- .../Views/Onboarding/OnboardingView.swift | 8 +- .../Views/Practice/AnswerReviewView.swift | 1 + .../Views/Practice/FlashcardView.swift | 20 +- .../Views/Practice/FullTableView.swift | 9 +- .../Views/Practice/HandwritingView.swift | 20 +- .../Views/Practice/MultipleChoiceView.swift | 20 +- .../Views/Practice/PracticeHeaderView.swift | 1 + .../Conjuga/Views/Practice/PracticeView.swift | 32 ++- .../Conjuga/Views/Practice/TypingView.swift | 20 +- .../Conjuga/Views/Settings/SettingsView.swift | 8 +- .../Conjuga/Views/Verbs/VerbDetailView.swift | 1 + .../Conjuga/Views/Verbs/VerbListView.swift | 19 +- Conjuga/ConjugaWidget/CombinedWidget.swift | 53 +++-- Conjuga/ConjugaWidget/WordOfDayWidget.swift | 89 ++++---- .../Sources/SharedModels}/IrregularSpan.swift | 24 +- .../Sources/SharedModels/SharedStore.swift | 26 +++ .../Sources/SharedModels/TenseGuide.swift | 15 ++ .../Sources/SharedModels}/Verb.swift | 31 ++- .../Sources/SharedModels/VerbForm.swift | 24 ++ Conjuga/project.yml | 27 ++- 48 files changed, 969 insertions(+), 306 deletions(-) create mode 100644 Conjuga/Conjuga.xcodeproj/xcshareddata/xcschemes/Conjuga.xcscheme delete mode 100644 Conjuga/Conjuga/Models/TenseGuide.swift delete mode 100644 Conjuga/Conjuga/Models/VerbForm.swift create mode 100644 Conjuga/Conjuga/Services/StoreInspector.swift create mode 100644 Conjuga/Conjuga/Services/SyncStatusMonitor.swift create mode 100644 Conjuga/Conjuga/Views/Components/SyncToast.swift rename Conjuga/{Conjuga/Models => SharedModels/Sources/SharedModels}/IrregularSpan.swift (55%) create mode 100644 Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift create mode 100644 Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift rename Conjuga/{Conjuga/Models => SharedModels/Sources/SharedModels}/Verb.swift (61%) create mode 100644 Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift diff --git a/Conjuga/Conjuga.xcodeproj/project.pbxproj b/Conjuga/Conjuga.xcodeproj/project.pbxproj index 3b6017c..de9a6fe 100644 --- a/Conjuga/Conjuga.xcodeproj/project.pbxproj +++ b/Conjuga/Conjuga.xcodeproj/project.pbxproj @@ -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 = ""; }; 10C16AA6022E4742898745CE /* TypingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingView.swift; sourceTree = ""; }; 143D06606AE10DCA30A140C2 /* CourseQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseQuizView.swift; sourceTree = ""; }; - 165B15630F4560F5891D9763 /* Verb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Verb.swift; sourceTree = ""; }; 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 = ""; }; 18AC3C548BDB9EF8701BE64C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatusMonitor.swift; sourceTree = ""; }; 195DA9CDA703DDFAD1B3CD5A /* DailyProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyProgressWidget.swift; sourceTree = ""; }; 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekProgressWidget.swift; sourceTree = ""; }; 1C42EA0EBD4CB1E10A82BA25 /* AchievementService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementService.swift; sourceTree = ""; }; + 1C4B5204F6B8647C816814F0 /* SyncToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncToast.swift; sourceTree = ""; }; 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeView.swift; sourceTree = ""; }; 1EB4830F9289AACC82D753F8 /* ConjugaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaApp.swift; sourceTree = ""; }; 1F842EB5E566C74658D918BB /* HandwritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingView.swift; sourceTree = ""; }; - 21FB1479EA5779A109BC517D /* IrregularSpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularSpan.swift; sourceTree = ""; }; 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataReader.swift; sourceTree = ""; }; 2931634BEB33B93429CE254F /* VocabFlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VocabFlashcardView.swift; sourceTree = ""; }; 30EF2362D9FFF9B07A45CE6D /* StreakCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakCalendarView.swift; sourceTree = ""; }; @@ -139,37 +138,36 @@ 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = ""; }; 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = ""; }; 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = ""; }; + 777C696A841803D5B775B678 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = ""; }; 7E6AF62A3A949630E067DC22 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 80D974250C396589656B8443 /* HandwritingCanvas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandwritingCanvas.swift; sourceTree = ""; }; 833516C5D57F164C8660A479 /* CourseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseView.swift; sourceTree = ""; }; 83A8C1A048627C8DEB83C12D /* AnswerReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnswerReviewView.swift; sourceTree = ""; }; - 8B6C1705F97FA0D59E996529 /* VerbForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbForm.swift; sourceTree = ""; }; + 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = ""; }; 8C2D88FF9A3B0590B22C7837 /* conjuga_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = conjuga_data.json; sourceTree = ""; }; 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuideView.swift; sourceTree = ""; }; 8E9BCDBB9BC24F5C8117767E /* WordOfDayWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordOfDayWidget.swift; sourceTree = ""; }; 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 = ""; }; A014EEC3EE08E945FBBA5335 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = ""; }; A63061BBC8998DF33E3DCA2B /* VerbListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbListView.swift; sourceTree = ""; }; AC34396050805693AA4AC582 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - B11B11111111111111111111 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = ""; }; - B22B22222222222222222222 /* ReferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferenceStore.swift; sourceTree = ""; }; - B33B33333333333333333333 /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = ""; }; - B44B44444444444444444444 /* PracticeSessionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeSessionService.swift; sourceTree = ""; }; - B55B55555555555555555555 /* StartupCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupCoordinator.swift; sourceTree = ""; }; BC273716CD14A99EFF8206CA /* course_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = course_data.json; sourceTree = ""; }; BCCC95A95581458E068E0484 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; C359C051FB157EF447561405 /* PracticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeViewModel.swift; sourceTree = ""; }; - CF6D58AEE2F0DFE0F1829A73 /* SharedModels */ = {isa = PBXFileReference; lastKnownFileType = folder; path = SharedModels; sourceTree = SOURCE_ROOT; }; + CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStore.swift; sourceTree = ""; }; + 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 = ""; }; DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipleChoiceView.swift; sourceTree = ""; }; DADCA82DDD34DF36D59BB283 /* DataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoader.swift; sourceTree = ""; }; + DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewStore.swift; sourceTree = ""; }; DAFE27F29412021AEC57E728 /* TestResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestResult.swift; sourceTree = ""; }; E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerbDetailView.swift; sourceTree = ""; }; E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConjugaWidgetBundle.swift; sourceTree = ""; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = ""; }; + E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = ""; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; - F7F329B097F5611FBBD7BD84 /* TenseGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseGuide.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 */, diff --git a/Conjuga/Conjuga.xcodeproj/xcshareddata/xcschemes/Conjuga.xcscheme b/Conjuga/Conjuga.xcodeproj/xcshareddata/xcschemes/Conjuga.xcscheme new file mode 100644 index 0000000..d7181a7 --- /dev/null +++ b/Conjuga/Conjuga.xcodeproj/xcshareddata/xcschemes/Conjuga.xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Conjuga/Conjuga/ConjugaApp.swift b/Conjuga/Conjuga/ConjugaApp.swift index 81b453e..4ffef9c 100644 --- a/Conjuga/Conjuga/ConjugaApp.swift +++ b/Conjuga/Conjuga/ConjugaApp.swift @@ -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()) + 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) } } diff --git a/Conjuga/Conjuga/Info.plist b/Conjuga/Conjuga/Info.plist index 2c852ca..ee6dff3 100644 --- a/Conjuga/Conjuga/Info.plist +++ b/Conjuga/Conjuga/Info.plist @@ -26,8 +26,13 @@ UIBackgroundModes + fetch remote-notification + BGTaskSchedulerPermittedIdentifiers + + com.conjuga.app.refresh + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/Conjuga/Conjuga/Models/GrammarNote.swift b/Conjuga/Conjuga/Models/GrammarNote.swift index 141a24b..1c26d93 100644 --- a/Conjuga/Conjuga/Models/GrammarNote.swift +++ b/Conjuga/Conjuga/Models/GrammarNote.swift @@ -1,4 +1,5 @@ import Foundation +import SharedModels struct GrammarNote: Identifiable { let id: String diff --git a/Conjuga/Conjuga/Models/TenseEndingTable.swift b/Conjuga/Conjuga/Models/TenseEndingTable.swift index 307a068..de506a2 100644 --- a/Conjuga/Conjuga/Models/TenseEndingTable.swift +++ b/Conjuga/Conjuga/Models/TenseEndingTable.swift @@ -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. diff --git a/Conjuga/Conjuga/Models/TenseGuide.swift b/Conjuga/Conjuga/Models/TenseGuide.swift deleted file mode 100644 index 157304e..0000000 --- a/Conjuga/Conjuga/Models/TenseGuide.swift +++ /dev/null @@ -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 - } -} diff --git a/Conjuga/Conjuga/Models/UserProgress.swift b/Conjuga/Conjuga/Models/UserProgress.swift index 3771082..6b0b188 100644 --- a/Conjuga/Conjuga/Models/UserProgress.swift +++ b/Conjuga/Conjuga/Models/UserProgress.swift @@ -1,4 +1,5 @@ import SwiftData +import SharedModels import Foundation @Model diff --git a/Conjuga/Conjuga/Models/VerbForm.swift b/Conjuga/Conjuga/Models/VerbForm.swift deleted file mode 100644 index edf764f..0000000 --- a/Conjuga/Conjuga/Models/VerbForm.swift +++ /dev/null @@ -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 - } -} diff --git a/Conjuga/Conjuga/Services/AchievementService.swift b/Conjuga/Conjuga/Services/AchievementService.swift index 8ee4a38..aec2b72 100644 --- a/Conjuga/Conjuga/Services/AchievementService.swift +++ b/Conjuga/Conjuga/Services/AchievementService.swift @@ -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( @@ -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) diff --git a/Conjuga/Conjuga/Services/DataLoader.swift b/Conjuga/Conjuga/Services/DataLoader.swift index ee7be19..01d4189 100644 --- a/Conjuga/Conjuga/Services/DataLoader.swift +++ b/Conjuga/Conjuga/Services/DataLoader.swift @@ -6,9 +6,14 @@ actor DataLoader { static func seedIfNeeded(container: ModelContainer) async { let context = ModelContext(container) - var descriptor = FetchDescriptor() - descriptor.fetchLimit = 1 - let count = (try? context.fetchCount(descriptor)) ?? 0 + let count: Int + do { + count = try context.fetchCount(FetchDescriptor()) + 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() - 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") } diff --git a/Conjuga/Conjuga/Services/PracticeSessionService.swift b/Conjuga/Conjuga/Services/PracticeSessionService.swift index 447f8f6..a367cfc 100644 --- a/Conjuga/Conjuga/Services/PracticeSessionService.swift +++ b/Conjuga/Conjuga/Services/PracticeSessionService.swift @@ -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 { $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) diff --git a/Conjuga/Conjuga/Services/ReferenceStore.swift b/Conjuga/Conjuga/Services/ReferenceStore.swift index 17e042f..cd23e5e 100644 --- a/Conjuga/Conjuga/Services/ReferenceStore.swift +++ b/Conjuga/Conjuga/Services/ReferenceStore.swift @@ -1,4 +1,5 @@ import Foundation +import SharedModels import SwiftData struct ReferenceStore { diff --git a/Conjuga/Conjuga/Services/ReviewStore.swift b/Conjuga/Conjuga/Services/ReviewStore.swift index 233bc95..c424d26 100644 --- a/Conjuga/Conjuga/Services/ReviewStore.swift +++ b/Conjuga/Conjuga/Services/ReviewStore.swift @@ -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 } diff --git a/Conjuga/Conjuga/Services/StartupCoordinator.swift b/Conjuga/Conjuga/Services/StartupCoordinator.swift index b7e4016..83831d2 100644 --- a/Conjuga/Conjuga/Services/StartupCoordinator.swift +++ b/Conjuga/Conjuga/Services/StartupCoordinator.swift @@ -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 { diff --git a/Conjuga/Conjuga/Services/StoreInspector.swift b/Conjuga/Conjuga/Services/StoreInspector.swift new file mode 100644 index 0000000..6061d13 --- /dev/null +++ b/Conjuga/Conjuga/Services/StoreInspector.swift @@ -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)) + } +} diff --git a/Conjuga/Conjuga/Services/SyncStatusMonitor.swift b/Conjuga/Conjuga/Services/SyncStatusMonitor.swift new file mode 100644 index 0000000..6023c38 --- /dev/null +++ b/Conjuga/Conjuga/Services/SyncStatusMonitor.swift @@ -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 + } +} diff --git a/Conjuga/Conjuga/Services/WidgetDataService.swift b/Conjuga/Conjuga/Services/WidgetDataService.swift index c88b959..793db76 100644 --- a/Conjuga/Conjuga/Services/WidgetDataService.swift +++ b/Conjuga/Conjuga/Services/WidgetDataService.swift @@ -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( predicate: #Predicate { $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( predicate: #Predicate { $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( 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) diff --git a/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift b/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift index 7f94809..a76a17a 100644 --- a/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift +++ b/Conjuga/Conjuga/ViewModels/PracticeViewModel.swift @@ -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) } } diff --git a/Conjuga/Conjuga/Views/Components/IrregularHighlightText.swift b/Conjuga/Conjuga/Views/Components/IrregularHighlightText.swift index 45f882a..3316ca0 100644 --- a/Conjuga/Conjuga/Views/Components/IrregularHighlightText.swift +++ b/Conjuga/Conjuga/Views/Components/IrregularHighlightText.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedModels struct IrregularHighlightText: View { let form: String diff --git a/Conjuga/Conjuga/Views/Components/SyncToast.swift b/Conjuga/Conjuga/Views/Components/SyncToast.swift new file mode 100644 index 0000000..021eb1a --- /dev/null +++ b/Conjuga/Conjuga/Views/Components/SyncToast.swift @@ -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() + } +} diff --git a/Conjuga/Conjuga/Views/Components/TensePill.swift b/Conjuga/Conjuga/Views/Components/TensePill.swift index 7867816..ed90ce2 100644 --- a/Conjuga/Conjuga/Views/Components/TensePill.swift +++ b/Conjuga/Conjuga/Views/Components/TensePill.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedModels import SwiftData /// Reusable tappable tense pill that shows a tense info sheet when tapped. diff --git a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift index 286190a..8ea2062 100644 --- a/Conjuga/Conjuga/Views/Course/CourseQuizView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseQuizView.swift @@ -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() } } diff --git a/Conjuga/Conjuga/Views/Course/CourseView.swift b/Conjuga/Conjuga/Views/Course/CourseView.swift index fecbc74..a565d54 100644 --- a/Conjuga/Conjuga/Views/Course/CourseView.swift +++ b/Conjuga/Conjuga/Views/Course/CourseView.swift @@ -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())) ?? [] + } } // MARK: - Deck Row diff --git a/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift b/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift index 1ea100d..d7065b8 100644 --- a/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift +++ b/Conjuga/Conjuga/Views/Course/VocabFlashcardView.swift @@ -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 } diff --git a/Conjuga/Conjuga/Views/Course/WeekTestView.swift b/Conjuga/Conjuga/Views/Course/WeekTestView.swift index d0fd460..040a547 100644 --- a/Conjuga/Conjuga/Views/Course/WeekTestView.swift +++ b/Conjuga/Conjuga/Views/Course/WeekTestView.swift @@ -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( + predicate: #Predicate { $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 } diff --git a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift index a908232..ef70b02 100644 --- a/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift +++ b/Conjuga/Conjuga/Views/Dashboard/DashboardView.swift @@ -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( + sortBy: [SortDescriptor(\DailyLog.dateString, order: .reverse)] + ) + dailyLogs = (try? cloudModelContext.fetch(dailyDescriptor)) ?? [] + testResults = (try? cloudModelContext.fetch(FetchDescriptor())) ?? [] + reviewCards = (try? cloudModelContext.fetch(FetchDescriptor())) ?? [] + try? cloudModelContext.save() + } } // MARK: - Stat Card diff --git a/Conjuga/Conjuga/Views/Guide/GuideView.swift b/Conjuga/Conjuga/Views/Guide/GuideView.swift index 7eedb73..6330fb6 100644 --- a/Conjuga/Conjuga/Views/Guide/GuideView.swift +++ b/Conjuga/Conjuga/Views/Guide/GuideView.swift @@ -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 } diff --git a/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift b/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift index 0606e54..fc3324f 100644 --- a/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift +++ b/Conjuga/Conjuga/Views/Onboarding/OnboardingView.swift @@ -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 } } diff --git a/Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift b/Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift index b75df7f..a94e121 100644 --- a/Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift +++ b/Conjuga/Conjuga/Views/Practice/AnswerReviewView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedModels struct AnswerReviewView: View { let form: VerbForm? diff --git a/Conjuga/Conjuga/Views/Practice/FlashcardView.swift b/Conjuga/Conjuga/Views/Practice/FlashcardView.swift index ef06ca2..85c2ea6 100644 --- a/Conjuga/Conjuga/Views/Practice/FlashcardView.swift +++ b/Conjuga/Conjuga/Views/Practice/FlashcardView.swift @@ -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)) diff --git a/Conjuga/Conjuga/Views/Practice/FullTableView.swift b/Conjuga/Conjuga/Views/Practice/FullTableView.swift index 681ebc9..9577f0d 100644 --- a/Conjuga/Conjuga/Views/Practice/FullTableView.swift +++ b/Conjuga/Conjuga/Views/Practice/FullTableView.swift @@ -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 } diff --git a/Conjuga/Conjuga/Views/Practice/HandwritingView.swift b/Conjuga/Conjuga/Views/Practice/HandwritingView.swift index 34715ad..20c3935 100644 --- a/Conjuga/Conjuga/Views/Practice/HandwritingView.swift +++ b/Conjuga/Conjuga/Views/Practice/HandwritingView.swift @@ -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() } ) diff --git a/Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift b/Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift index dcde1ea..6c8393b 100644 --- a/Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift +++ b/Conjuga/Conjuga/Views/Practice/MultipleChoiceView.swift @@ -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)) diff --git a/Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift b/Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift index 14e0a33..331e823 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeHeaderView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedModels struct PracticeHeaderView: View { let verb: Verb? diff --git a/Conjuga/Conjuga/Views/Practice/PracticeView.swift b/Conjuga/Conjuga/Views/Practice/PracticeView.swift index bb70309..8454d1c 100644 --- a/Conjuga/Conjuga/Views/Practice/PracticeView.swift +++ b/Conjuga/Conjuga/Views/Practice/PracticeView.swift @@ -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 { diff --git a/Conjuga/Conjuga/Views/Practice/TypingView.swift b/Conjuga/Conjuga/Views/Practice/TypingView.swift index 1a8823b..1710f88 100644 --- a/Conjuga/Conjuga/Views/Practice/TypingView.swift +++ b/Conjuga/Conjuga/Views/Practice/TypingView.swift @@ -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 } ) diff --git a/Conjuga/Conjuga/Views/Settings/SettingsView.swift b/Conjuga/Conjuga/Views/Settings/SettingsView.swift index 443d622..84ba361 100644 --- a/Conjuga/Conjuga/Views/Settings/SettingsView.swift +++ b/Conjuga/Conjuga/Views/Settings/SettingsView.swift @@ -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() } } diff --git a/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift b/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift index 346ce20..cd864f6 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SharedModels import SwiftData struct VerbDetailView: View { diff --git a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift index efd816c..0930a06 100644 --- a/Conjuga/Conjuga/Views/Verbs/VerbListView.swift +++ b/Conjuga/Conjuga/Views/Verbs/VerbListView.swift @@ -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 { diff --git a/Conjuga/ConjugaWidget/CombinedWidget.swift b/Conjuga/ConjugaWidget/CombinedWidget.swift index e35c2d7..b6917fd 100644 --- a/Conjuga/ConjugaWidget/CombinedWidget.swift +++ b/Conjuga/ConjugaWidget/CombinedWidget.swift @@ -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) -> 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( predicate: #Predicate { $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) } } diff --git a/Conjuga/ConjugaWidget/WordOfDayWidget.swift b/Conjuga/ConjugaWidget/WordOfDayWidget.swift index 5621d17..d017329 100644 --- a/Conjuga/ConjugaWidget/WordOfDayWidget.swift +++ b/Conjuga/ConjugaWidget/WordOfDayWidget.swift @@ -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) -> 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( + predicate: #Predicate { $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( - predicate: #Predicate { $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) } } diff --git a/Conjuga/Conjuga/Models/IrregularSpan.swift b/Conjuga/SharedModels/Sources/SharedModels/IrregularSpan.swift similarity index 55% rename from Conjuga/Conjuga/Models/IrregularSpan.swift rename to Conjuga/SharedModels/Sources/SharedModels/IrregularSpan.swift index 2d8f995..0fce4ef 100644 --- a/Conjuga/Conjuga/Models/IrregularSpan.swift +++ b/Conjuga/SharedModels/Sources/SharedModels/IrregularSpan.swift @@ -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" diff --git a/Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift b/Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift new file mode 100644 index 0000000..50b2b83 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/SharedStore.swift @@ -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? +} diff --git a/Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift b/Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift new file mode 100644 index 0000000..073b969 --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/TenseGuide.swift @@ -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 + } +} diff --git a/Conjuga/Conjuga/Models/Verb.swift b/Conjuga/SharedModels/Sources/SharedModels/Verb.swift similarity index 61% rename from Conjuga/Conjuga/Models/Verb.swift rename to Conjuga/SharedModels/Sources/SharedModels/Verb.swift index 5d9e31b..92faca5 100644 --- a/Conjuga/Conjuga/Models/Verb.swift +++ b/Conjuga/SharedModels/Sources/SharedModels/Verb.swift @@ -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 { + public static func dataLevels(for selectedLevel: String) -> Set { 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) } } diff --git a/Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift b/Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift new file mode 100644 index 0000000..200732f --- /dev/null +++ b/Conjuga/SharedModels/Sources/SharedModels/VerbForm.swift @@ -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 + } +} diff --git a/Conjuga/project.yml b/Conjuga/project.yml index e16f956..695a80e 100644 --- a/Conjuga/project.yml +++ b/Conjuga/project.yml @@ -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: