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: