Add Lyrics practice: search, translate, and read Spanish song lyrics

New feature in the Practice tab that lets users search for Spanish songs
by artist + title, fetch lyrics from LRCLIB (free, no API key), pull
album art from iTunes Search API, auto-translate to English via Apple's
on-device Translation framework, and save for offline reading.

Components:
- SavedSong SwiftData model (local container, no CloudKit sync)
- LyricsSearchService actor (LRCLIB + iTunes Search, concurrent)
- LyricsSearchView (artist/song fields, result list with album art)
- LyricsConfirmationView (lyrics preview, auto-translation, save)
- LyricsLibraryView (saved songs list, swipe to delete)
- LyricsReaderView (Spanish lines with English subtitles)
- Practice tab integration (Lyrics button with NavigationLink)
- localStoreResetVersion bumped to 3 for schema migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 22:44:40 -05:00
parent 5fa1cc3921
commit faef20e5b8
9 changed files with 718 additions and 1 deletions

View File

@@ -29,16 +29,19 @@
46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; }; 46943ACFABF329DE1CBFC471 /* TensePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102F0E136CDFF8CED710210F /* TensePill.swift */; };
4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; }; 4C3484403FD96E37DA4BEA66 /* NewWordIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */; };
50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; }; 50E0095A23E119D1AB561232 /* VerbDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DBE662F89F02A0282F5BEE /* VerbDetailView.swift */; };
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA01795655C444795577A22 /* LyricsConfirmationView.swift */; };
51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; }; 51D072AF30F4B12CD3E8F918 /* SRSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */; };
53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; }; 53A0AC57EAC44B676C997374 /* QuizType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 626873572466403C0288090D /* QuizType.swift */; };
5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; }; 5A3246026E68AB6483126D0B /* WeekProgressWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1980E8E439EB76ED7330A90D /* WeekProgressWidget.swift */; };
5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; }; 5EA915FFA906C5C2938FCADA /* ConjugaWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E325FE0E484DE75009672D02 /* ConjugaWidgetBundle.swift */; };
60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; }; 60E86BABE2735E2052B99DF3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCC95A95581458E068E0484 /* SettingsView.swift */; };
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58394296923991E56BAC2B02 /* LyricsReaderView.swift */; };
6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; }; 6BB4B0A655E6CB6F82D81B5A /* WeekTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7EF4161C73AAC67B3A0004 /* WeekTestView.swift */; };
6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; }; 6D4A29280FDD99B8E18AF264 /* WidgetDataReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2889F2F81673AFF3A58A07A8 /* WidgetDataReader.swift */; };
6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; }; 6ED2AC2CAA54688161D4B920 /* SyncStatusMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18CCD69C14D1B0CFBD03C92F /* SyncStatusMonitor.swift */; };
728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; }; 728702D9AA7A8BDABBA62513 /* ReviewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */; };
760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; }; 760628EFE1CF191CE2FC07DC /* GuideView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C935ECDF8A5D8D6FA541E20 /* GuideView.swift */; };
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */; };
81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; }; 81FA7EBCF18F0AAE0BF385C3 /* VerbListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63061BBC8998DF33E3DCA2B /* VerbListView.swift */; };
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; }; 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3A33983B2F2078C9EA1A3D /* MultipleChoiceView.swift */; };
84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; }; 84CCBAE22A9E0DA27AE28723 /* DeckStudyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */; };
@@ -48,6 +51,7 @@
9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; }; 9D9FD3853C5C969C62AE9999 /* StartupCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B95B276C054DBFE508C4D1 /* StartupCoordinator.swift */; };
A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; }; A9959AE6C87B4AD21554E401 /* FullTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */; };
AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; }; AAC6F85A1C3B6C1186E1656A /* TenseEndingTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D98E1564C6538056D81200 /* TenseEndingTable.swift */; };
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */; };
BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; }; BB48230C3B26EA6E84D2D823 /* DailyProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180F9D59828C36B44A5E384F /* DailyProgressRing.swift */; };
BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; }; BF0832865857EFDA1D1CDEAD /* SharedModels in Frameworks */ = {isa = PBXBuildFile; productRef = BCCBABD74CADDB118179D8E9 /* SharedModels */; };
C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; }; C0BAEF49A6270D8F64CF13D6 /* PracticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C359C051FB157EF447561405 /* PracticeViewModel.swift */; };
@@ -62,6 +66,7 @@
D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; }; D4DDE25FB2DAD315370AFB74 /* FlashcardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */; };
D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; }; D6B67523714E0B3618391956 /* CombinedWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */; };
D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; }; D7456B289D135CEB3A15122B /* TestResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE27F29412021AEC57E728 /* TestResult.swift */; };
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */; };
DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DF06034A4B2C11BA0C0A84CB /* ConjugaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9708FF3CF33E4765DB225F93 /* ConjugaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; }; DF82C2579F9889DDB06362CC /* ReferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 777C696A841803D5B775B678 /* ReferenceStore.swift */; };
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; }; E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */; };
@@ -122,11 +127,14 @@
3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; }; 3B16FF4C52457CD8CD703532 /* AdaptiveContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveContainer.swift; sourceTree = "<group>"; };
3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; }; 3BC3247457109FC6BF00D85B /* TenseInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseInfo.swift; sourceTree = "<group>"; };
3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; }; 3CC1AD23158CBABBB753FA1E /* ConjugaWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ConjugaWidget.entitlements; sourceTree = "<group>"; };
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsConfirmationView.swift; sourceTree = "<group>"; };
3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; }; 3F1A6221A35699BD8065D064 /* GrammarNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNotesView.swift; sourceTree = "<group>"; };
42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; }; 42ADC600530309A9B147A663 /* IrregularHighlightText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IrregularHighlightText.swift; sourceTree = "<group>"; };
43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; }; 43345D6C7EAA4017E3A45935 /* CombinedWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedWidget.swift; sourceTree = "<group>"; };
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchService.swift; sourceTree = "<group>"; };
49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; }; 49E3AD244327CBF24B7A2752 /* SpeechService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechService.swift; sourceTree = "<group>"; };
4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; }; 4D389CA5B5C4E7A12CAEA5BC /* GrammarNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrammarNote.swift; sourceTree = "<group>"; };
58394296923991E56BAC2B02 /* LyricsReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsReaderView.swift; sourceTree = "<group>"; };
5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; }; 5983A534E4836F30B5281ACB /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; }; 5BF946245110C92F087D81E8 /* PracticeHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PracticeHeaderView.swift; sourceTree = "<group>"; };
5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; }; 5C0E6EAFC0D24928BA956FA5 /* SRSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SRSEngine.swift; sourceTree = "<group>"; };
@@ -135,6 +143,7 @@
631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; }; 631DC0A942DD57C81DECE083 /* DeckStudyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeckStudyView.swift; sourceTree = "<group>"; };
69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; }; 69D98E1564C6538056D81200 /* TenseEndingTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenseEndingTable.swift; sourceTree = "<group>"; };
6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; }; 6B9A9F2AB21895E06989A4D5 /* FlashcardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlashcardView.swift; sourceTree = "<group>"; };
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsSearchView.swift; sourceTree = "<group>"; };
711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; }; 711CB7539EF5887F6F7B8B82 /* FullTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullTableView.swift; sourceTree = "<group>"; };
72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; }; 72CB5F95DF256DF7CD73269D /* NewWordIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWordIntent.swift; sourceTree = "<group>"; };
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; }; 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentenceBuilderView.swift; sourceTree = "<group>"; };
@@ -168,6 +177,7 @@
E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; }; E536AD1180FE10576EAC884A /* UserProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProgress.swift; sourceTree = "<group>"; };
E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; }; E8E9833868EB73AF9EB3A611 /* StoreInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreInspector.swift; sourceTree = "<group>"; };
E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; E972AA745F44586EF0B1B0C8 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsLibraryView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -231,6 +241,7 @@
DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */, DAF7CA1E6F9979CB2C699FDC /* CourseReviewStore.swift */,
DADCA82DDD34DF36D59BB283 /* DataLoader.swift */, DADCA82DDD34DF36D59BB283 /* DataLoader.swift */,
3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */, 3695075616689E72DBB26D4C /* HandwritingRecognizer.swift */,
43B8AED76C14A05AF2339C27 /* LyricsSearchService.swift */,
842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */, 842DB48F8570C39CDCFF2F57 /* PracticeSessionService.swift */,
777C696A841803D5B775B678 /* ReferenceStore.swift */, 777C696A841803D5B775B678 /* ReferenceStore.swift */,
CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */, CBCF6FCFA6B00151C2371E77 /* ReviewStore.swift */,
@@ -316,6 +327,7 @@
1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */, 1EA0FA4F9149B9D8E197ADE9 /* PracticeView.swift */,
731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */, 731614CACCB73B6FD592D34A /* SentenceBuilderView.swift */,
10C16AA6022E4742898745CE /* TypingView.swift */, 10C16AA6022E4742898745CE /* TypingView.swift */,
895E547BEFB5D0FBF676BE33 /* Lyrics */,
); );
path = Practice; path = Practice;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -329,6 +341,17 @@
path = Guide; path = Guide;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
895E547BEFB5D0FBF676BE33 /* Lyrics */ = {
isa = PBXGroup;
children = (
3EA01795655C444795577A22 /* LyricsConfirmationView.swift */,
FC2B1F646394D7C03493F1BF /* LyricsLibraryView.swift */,
58394296923991E56BAC2B02 /* LyricsReaderView.swift */,
70960F0FD7509310B3F61C48 /* LyricsSearchView.swift */,
);
path = Lyrics;
sourceTree = "<group>";
};
A591A3B6F1F13D23D68D7A9D = { A591A3B6F1F13D23D68D7A9D = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -518,6 +541,11 @@
E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */, E7BFEE9A90E1300EFF5B1F32 /* HandwritingRecognizer.swift in Sources */,
33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */, 33E885EB38C3BB0CB058871A /* HandwritingView.swift in Sources */,
28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */, 28D2F489F1927BCCC2B56086 /* IrregularHighlightText.swift in Sources */,
519E68D2DF4C80AB96058C0D /* LyricsConfirmationView.swift in Sources */,
B4603AA6EFB134794AA39BF4 /* LyricsLibraryView.swift in Sources */,
615D3128ED6E84EF59BB5AA3 /* LyricsReaderView.swift in Sources */,
DB73836F751BB2751439E826 /* LyricsSearchService.swift in Sources */,
7A13757EA40E81E55640D0FC /* LyricsSearchView.swift in Sources */,
C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */, C1F84182F12EB5CFF32768B6 /* MainTabView.swift in Sources */,
82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */, 82F6079BE3F31AC3FB2D1013 /* MultipleChoiceView.swift in Sources */,
13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */, 13F29AD5745FB532709FA28A /* OnboardingView.swift in Sources */,

View File

@@ -189,6 +189,7 @@ struct ConjugaApp: App {
schema: Schema([ schema: Schema([
Verb.self, VerbForm.self, IrregularSpan.self, Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
SavedSong.self,
]), ]),
url: url, url: url,
cloudKitDatabase: .none cloudKitDatabase: .none
@@ -196,6 +197,7 @@ struct ConjugaApp: App {
return try ModelContainer( return try ModelContainer(
for: Verb.self, VerbForm.self, IrregularSpan.self, for: Verb.self, VerbForm.self, IrregularSpan.self,
TenseGuide.self, CourseDeck.self, VocabCard.self, TenseGuide.self, CourseDeck.self, VocabCard.self,
SavedSong.self,
configurations: localConfig configurations: localConfig
) )
} }
@@ -224,7 +226,7 @@ struct ConjugaApp: App {
/// Clears accumulated stale schema metadata from previous container configurations. /// Clears accumulated stale schema metadata from previous container configurations.
/// Bump the version number to force another reset if the schema changes again. /// Bump the version number to force another reset if the schema changes again.
private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) { private static func performOneTimeLocalStoreResetIfNeeded(at url: URL) {
let resetVersion = 2 // bump: widget schema moved to SharedModels let resetVersion = 3 // bump: added SavedSong to local schema
let key = "localStoreResetVersion" let key = "localStoreResetVersion"
let defaults = UserDefaults.standard let defaults = UserDefaults.standard

View File

@@ -0,0 +1,121 @@
import Foundation
struct LyricsSearchResult: Sendable {
let title: String
let artist: String
let lyricsES: String
let albumArtURL: String?
let appleMusicURL: String?
}
actor LyricsSearchService {
// MARK: - Public
func searchLyrics(artist: String, title: String) async throws -> [LyricsSearchResult] {
async let lrcResults = searchLRCLIB(artist: artist, title: title)
async let itunesResults = searchITunes(artist: artist, title: title)
let lyrics = try await lrcResults
let metadata = try? await itunesResults
return lyrics.map { lrc in
let match = metadata?.bestMatch(artist: lrc.artistName, title: lrc.trackName)
return LyricsSearchResult(
title: lrc.trackName,
artist: lrc.artistName,
lyricsES: lrc.plainLyrics,
albumArtURL: match?.artworkURL600,
appleMusicURL: match?.trackViewURL
)
}
}
// MARK: - LRCLIB
private struct LRCLIBResult: Decodable, Sendable {
let trackName: String
let artistName: String
let plainLyrics: String
enum CodingKeys: String, CodingKey {
case trackName, artistName, plainLyrics
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
trackName = try c.decode(String.self, forKey: .trackName)
artistName = try c.decode(String.self, forKey: .artistName)
plainLyrics = (try? c.decode(String.self, forKey: .plainLyrics)) ?? ""
}
}
private func searchLRCLIB(artist: String, title: String) async throws -> [LRCLIBResult] {
var components = URLComponents(string: "https://lrclib.net/api/search")!
components.queryItems = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist),
]
guard let url = components.url else { return [] }
var request = URLRequest(url: url)
request.setValue("Conjuga/1.0", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return [] }
let results = try JSONDecoder().decode([LRCLIBResult].self, from: data)
return results.filter { !$0.plainLyrics.isEmpty }
}
// MARK: - iTunes Search
private struct ITunesResponse: Decodable {
let results: [ITunesTrack]
}
private struct ITunesTrack: Decodable {
let trackName: String?
let artistName: String?
let artworkUrl100: String?
let trackViewUrl: String?
var artworkURL600: String? {
artworkUrl100?.replacingOccurrences(of: "100x100", with: "600x600")
}
var trackViewURL: String? { trackViewUrl }
}
private struct ITunesMetadata: Sendable {
let tracks: [ITunesTrack]
func bestMatch(artist: String, title: String) -> ITunesTrack? {
let normalizedArtist = artist.lowercased()
let normalizedTitle = title.lowercased()
// Prefer exact title+artist match, then just title
return tracks.first {
($0.trackName ?? "").lowercased().contains(normalizedTitle) &&
($0.artistName ?? "").lowercased().contains(normalizedArtist)
} ?? tracks.first {
($0.trackName ?? "").lowercased().contains(normalizedTitle)
} ?? tracks.first
}
}
private func searchITunes(artist: String, title: String) async throws -> ITunesMetadata {
let query = "\(artist) \(title)"
var components = URLComponents(string: "https://itunes.apple.com/search")!
components.queryItems = [
URLQueryItem(name: "term", value: query),
URLQueryItem(name: "media", value: "music"),
URLQueryItem(name: "limit", value: "5"),
]
guard let url = components.url else { return ITunesMetadata(tracks: []) }
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(ITunesResponse.self, from: data)
return ITunesMetadata(tracks: response.results)
}
}

View File

@@ -0,0 +1,160 @@
import SwiftUI
import SharedModels
import SwiftData
import Translation
struct LyricsConfirmationView: View {
let result: LyricsSearchResult
let onSave: () -> Void
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var translatedEN = ""
@State private var isTranslating = true
@State private var translationError = false
@State private var translationConfig: TranslationSession.Configuration?
var body: some View {
ScrollView {
VStack(spacing: 20) {
headerSection
lyricsPreview
actionButtons
}
.padding()
.adaptiveContainer()
}
.navigationTitle("Confirm Lyrics")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
translationConfig = .init(
source: Locale.Language(identifier: "es"),
target: Locale.Language(identifier: "en")
)
}
.translationTask(translationConfig) { session in
await translateLyrics(session: session)
}
}
// MARK: - Sections
private var headerSection: some View {
VStack(spacing: 12) {
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.fill.quaternary)
.overlay {
ProgressView()
}
}
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(result.title)
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
Text(result.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
private var lyricsPreview: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Spanish Lyrics")
.font(.headline)
Text(result.lyricsES.prefix(500) + (result.lyricsES.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.primary)
Divider()
HStack {
Text("English Translation")
.font(.headline)
if isTranslating {
ProgressView()
.controlSize(.small)
}
}
if translationError {
Label("Translation unavailable. You can still save with Spanish only.",
systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
} else if translatedEN.isEmpty && isTranslating {
Text("Translating...")
.font(.body)
.foregroundStyle(.secondary)
} else {
Text(translatedEN.prefix(500) + (translatedEN.count > 500 ? "\n..." : ""))
.font(.body)
.foregroundStyle(.secondary)
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
private var actionButtons: some View {
HStack(spacing: 16) {
Button(role: .cancel) {
dismiss()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
Button {
saveSong()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.disabled(isTranslating && translatedEN.isEmpty && !translationError)
}
}
// MARK: - Logic
private func translateLyrics(session: sending TranslationSession) async {
await MainActor.run { isTranslating = true }
let text = result.lyricsES
do {
let response = try await session.translate(text)
await MainActor.run { translatedEN = response.targetText }
} catch {
print("Translation error: \(error)")
await MainActor.run { translationError = true }
}
await MainActor.run { isTranslating = false }
}
private func saveSong() {
let song = SavedSong(
title: result.title,
artist: result.artist,
lyricsES: result.lyricsES,
lyricsEN: translatedEN,
albumArtURL: result.albumArtURL ?? "",
appleMusicURL: result.appleMusicURL ?? ""
)
modelContext.insert(song)
try? modelContext.save()
onSave()
}
}

View File

@@ -0,0 +1,89 @@
import SwiftUI
import SharedModels
import SwiftData
struct LyricsLibraryView: View {
@Query(sort: \SavedSong.savedDate, order: .reverse) private var songs: [SavedSong]
var body: some View {
Group {
if songs.isEmpty {
ContentUnavailableView(
"No Songs Yet",
systemImage: "music.note.list",
description: Text("Tap + to search for Spanish song lyrics.")
)
} else {
List {
ForEach(songs) { song in
NavigationLink(value: song) {
SongRowView(song: song)
}
}
.onDelete(perform: deleteSongs)
}
}
}
.navigationTitle("Lyrics")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
LyricsSearchView()
} label: {
Image(systemName: "plus")
}
}
}
.navigationDestination(for: SavedSong.self) { song in
LyricsReaderView(song: song)
}
}
@Environment(\.modelContext) private var modelContext
private func deleteSongs(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(songs[index])
}
try? modelContext.save()
}
}
// MARK: - Song Row
private struct SongRowView: View {
let song: SavedSong
var body: some View {
HStack(spacing: 12) {
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.fill.quaternary)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "music.note")
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 50, height: 50)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
}
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(song.artist)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 2)
}
}

View File

@@ -0,0 +1,88 @@
import SwiftUI
import SharedModels
struct LyricsReaderView: View {
let song: SavedSong
var body: some View {
ScrollView {
VStack(spacing: 20) {
headerSection
lyricsBody
}
.padding()
.adaptiveContainer()
}
.navigationTitle(song.title)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 10) {
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(.fill.quaternary)
}
.frame(width: 160, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
Text(song.title)
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
Text(song.artist)
.font(.subheadline)
.foregroundStyle(.secondary)
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
Link(destination: url) {
Label("Open in Apple Music", systemImage: "apple.logo")
.font(.caption.weight(.medium))
}
.tint(.pink)
}
}
}
// MARK: - Lyrics Body
private var lyricsBody: some View {
let spanishLines = song.lyricsES.components(separatedBy: "\n")
let englishLines = song.lyricsEN.components(separatedBy: "\n")
let lineCount = max(spanishLines.count, englishLines.count)
return VStack(alignment: .leading, spacing: 0) {
ForEach(0..<lineCount, id: \.self) { index in
let es = index < spanishLines.count ? spanishLines[index] : ""
let en = index < englishLines.count ? englishLines[index] : ""
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
en.trimmingCharacters(in: .whitespaces).isEmpty {
// Blank line = section divider
Spacer().frame(height: 20)
} else {
VStack(alignment: .leading, spacing: 2) {
if !es.isEmpty {
Text(es)
.font(.body.weight(.medium))
}
if !en.isEmpty {
Text(en)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
}
.padding()
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
import SharedModels
import SwiftData
import Translation
struct LyricsSearchView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var artist = ""
@State private var songTitle = ""
@State private var isSearching = false
@State private var searchResults: [LyricsSearchResult] = []
@State private var errorMessage: String?
@State private var selectedResult: LyricsSearchResult?
private let service = LyricsSearchService()
var body: some View {
List {
searchSection
if isSearching {
loadingSection
}
if let errorMessage {
errorSection(errorMessage)
}
if !searchResults.isEmpty {
resultsSection
}
}
.navigationTitle("Search Lyrics")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(item: $selectedResult) { result in
LyricsConfirmationView(result: result) {
dismiss()
}
}
}
// MARK: - Sections
private var searchSection: some View {
Section {
TextField("Artist", text: $artist)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
TextField("Song Title", text: $songTitle)
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
Button {
performSearch()
} label: {
HStack {
Spacer()
Label("Search", systemImage: "magnifyingglass")
.font(.headline)
Spacer()
}
}
.disabled(artist.trimmingCharacters(in: .whitespaces).isEmpty ||
songTitle.trimmingCharacters(in: .whitespaces).isEmpty ||
isSearching)
} header: {
Text("Find a Song")
}
}
private var loadingSection: some View {
Section {
HStack {
Spacer()
ProgressView("Searching...")
Spacer()
}
.padding(.vertical, 8)
}
}
private func errorSection(_ message: String) -> some View {
Section {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
private var resultsSection: some View {
Section {
ForEach(Array(searchResults.prefix(5).enumerated()), id: \.offset) { _, result in
Button {
selectedResult = result
} label: {
HStack(spacing: 12) {
if let artURL = result.albumArtURL, let url = URL(string: artURL) {
AsyncImage(url: url) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.fill.quaternary)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "music.note")
.font(.title2)
.frame(width: 50, height: 50)
.background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 6))
}
VStack(alignment: .leading, spacing: 2) {
Text(result.title)
.font(.subheadline.weight(.semibold))
Text(result.artist)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.tint(.primary)
}
} header: {
Text("Results")
}
}
// MARK: - Actions
private func performSearch() {
isSearching = true
errorMessage = nil
searchResults = []
Task {
do {
let results = try await service.searchLyrics(artist: artist, title: songTitle)
searchResults = results
if results.isEmpty {
errorMessage = "No lyrics found. Try a different spelling."
}
} catch {
errorMessage = "Search failed. Check your connection."
}
isSearching = false
}
}
}
// MARK: - Identifiable conformance for navigation
extension LyricsSearchResult: Equatable {
static func == (lhs: LyricsSearchResult, rhs: LyricsSearchResult) -> Bool {
lhs.title == rhs.title && lhs.artist == rhs.artist && lhs.lyricsES == rhs.lyricsES
}
}
extension LyricsSearchResult: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(title)
hasher.combine(artist)
}
}
extension LyricsSearchResult: Identifiable {
var id: String { "\(artist)\(title)" }
}

View File

@@ -98,6 +98,37 @@ struct PracticeView: View {
} }
.padding(.horizontal) .padding(.horizontal)
// Lyrics
NavigationLink {
LyricsLibraryView()
} label: {
HStack(spacing: 14) {
Image(systemName: "music.note.list")
.font(.title3)
.frame(width: 36)
.foregroundStyle(.pink)
VStack(alignment: .leading, spacing: 2) {
Text("Lyrics")
.font(.subheadline.weight(.semibold))
Text("Read Spanish song lyrics with translations")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.tint(.primary)
.glassEffect(in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
// Quick Actions // Quick Actions
VStack(spacing: 12) { VStack(spacing: 12) {
Text("Quick Actions") Text("Quick Actions")

View File

@@ -0,0 +1,25 @@
import SwiftData
import Foundation
@Model
public final class SavedSong {
public var id: String = ""
public var title: String = ""
public var artist: String = ""
public var lyricsES: String = ""
public var lyricsEN: String = ""
public var albumArtURL: String = ""
public var appleMusicURL: String = ""
public var savedDate: Date = Date()
public init(title: String, artist: String, lyricsES: String, lyricsEN: String, albumArtURL: String = "", appleMusicURL: String = "") {
self.id = UUID().uuidString
self.title = title
self.artist = artist
self.lyricsES = lyricsES
self.lyricsEN = lyricsEN
self.albumArtURL = albumArtURL
self.appleMusicURL = appleMusicURL
self.savedDate = Date()
}
}