diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index fe6356a66..5012774b8 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */; }; 3F66408A2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408B2E162ABF00356522 /* AudioMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6640892E162ABF00356522 /* AudioMetadataService.swift */; }; 3F66408D2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */; }; @@ -513,6 +514,14 @@ 6399D0702CEBA35D00A2E278 /* RemoteItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399D06F2CEBA35D00A2E278 /* RemoteItemListView.swift */; }; 6399D0722CEBA37C00A2E278 /* RemoteItemListCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399D0712CEBA37C00A2E278 /* RemoteItemListCellView.swift */; }; 6399D0762CECFFA900A2E278 /* CoreServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399D0752CECFFA900A2E278 /* CoreServices.swift */; }; + 6399EA252F12B1870077BB13 /* PasskeyRegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA222F12B1870077BB13 /* PasskeyRegistrationView.swift */; }; + 6399EA272F12B1870077BB13 /* PasskeySignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA232F12B1870077BB13 /* PasskeySignInButton.swift */; }; + 6399EA352F12BA5B0077BB13 /* PasskeyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA332F12BA5B0077BB13 /* PasskeyService.swift */; }; + 6399EA372F12BA5B0077BB13 /* PasskeyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA312F12BA5B0077BB13 /* PasskeyAPI.swift */; }; + 6399EA392F12BB690077BB13 /* PasskeyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA382F12BB690077BB13 /* PasskeyModels.swift */; }; + 6399EA3A2F12BB690077BB13 /* PasskeyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA382F12BB690077BB13 /* PasskeyModels.swift */; }; + 6399EA3C2F1BED4B0077BB13 /* PrimaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA3B2F1BED4B0077BB13 /* PrimaryButton.swift */; }; + 6399EA9B2F12B1870077BB13 /* EmailVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399EA9A2F12B1870077BB13 /* EmailVerificationView.swift */; }; 6399F94D2AA03C6C00A5C8EA /* BPSKANManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6399F94C2AA03C6C00A5C8EA /* BPSKANManager.swift */; }; 639AC9892AD9F1D50053AFC6 /* BPDownloadURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639AC9882AD9F1D50053AFC6 /* BPDownloadURLSession.swift */; }; 639AC98A2AD9F1D50053AFC6 /* BPDownloadURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 639AC9882AD9F1D50053AFC6 /* BPDownloadURLSession.swift */; }; @@ -563,6 +572,8 @@ 63C48C802E3DC01A005FBB96 /* LoadingOverlayModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48C7F2E3DC01A005FBB96 /* LoadingOverlayModifier.swift */; }; 63C48C822E3DC2BE005FBB96 /* LoginSignInButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48C812E3DC2BE005FBB96 /* LoginSignInButton.swift */; }; 63C48C842E3E66D1005FBB96 /* PurchasesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48C832E3E66D1005FBB96 /* PurchasesManager.swift */; }; + 63C48CA12E4181F0005FBB96 /* ContinueWithPasskeyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48CA02E4181F0005FBB96 /* ContinueWithPasskeyButton.swift */; }; + 63C48CA32E418200005FBB96 /* AppleSignInLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48CA22E418200005FBB96 /* AppleSignInLink.swift */; }; 63C48D3C2E3E7D29005FBB96 /* SettingsCompleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D3B2E3E7D29005FBB96 /* SettingsCompleteAccountView.swift */; }; 63C48D3E2E3E7F9E005FBB96 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D3D2E3E7F9E005FBB96 /* LoginViewModel.swift */; }; 63C48D402E3E8128005FBB96 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D3F2E3E8128005FBB96 /* AccountView.swift */; }; @@ -573,6 +584,7 @@ 63C48D4A2E3F008F005FBB96 /* AccountPerksSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D492E3F008F005FBB96 /* AccountPerksSectionView.swift */; }; 63C48D4E2E3F1A9C005FBB96 /* WidgetRenderModeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D4D2E3F1A9C005FBB96 /* WidgetRenderModeModifier.swift */; }; 63C48D512E3F1FCC005FBB96 /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D502E3F1FCC005FBB96 /* ItemListView.swift */; }; + 63C48D522E50AAA1005FBB96 /* AccountPasskeySectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C48D532E50AAA1005FBB96 /* AccountPasskeySectionView.swift */; }; 63C6C2E62B5029BC00FFE0D8 /* SettingsAutolockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C2E52B5029BC00FFE0D8 /* SettingsAutolockView.swift */; }; 63C6C2E82B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C2E72B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift */; }; 63C6C30D2B538D8500FFE0D8 /* SyncTasksStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */; }; @@ -805,6 +817,7 @@ C3FE3F8220A090880055B9C6 /* limitPanAngle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FE3F8120A090880055B9C6 /* limitPanAngle.swift */; }; D6BA8F162A4CA94800C2BD9A /* StorageRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F152A4CA94800C2BD9A /* StorageRowView.swift */; }; D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BA8F172A4D66CD00C2BD9A /* StorageView.swift */; }; + F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -955,6 +968,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyEmailInputView.swift; sourceTree = ""; }; 3F6640892E162ABF00356522 /* AudioMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMetadataService.swift; sourceTree = ""; }; 3F66408C2E172DF500356522 /* MappingModel_v9_to_v10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = MappingModel_v9_to_v10.xcmappingmodel; sourceTree = ""; }; 3F6640932E17386400356522 /* DeleteUserBookData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteUserBookData.swift; sourceTree = ""; }; @@ -1427,6 +1441,13 @@ 6399D06F2CEBA35D00A2E278 /* RemoteItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteItemListView.swift; sourceTree = ""; }; 6399D0712CEBA37C00A2E278 /* RemoteItemListCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteItemListCellView.swift; sourceTree = ""; }; 6399D0752CECFFA900A2E278 /* CoreServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreServices.swift; sourceTree = ""; }; + 6399EA222F12B1870077BB13 /* PasskeyRegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyRegistrationView.swift; sourceTree = ""; }; + 6399EA232F12B1870077BB13 /* PasskeySignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeySignInButton.swift; sourceTree = ""; }; + 6399EA312F12BA5B0077BB13 /* PasskeyAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyAPI.swift; sourceTree = ""; }; + 6399EA332F12BA5B0077BB13 /* PasskeyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyService.swift; sourceTree = ""; }; + 6399EA382F12BB690077BB13 /* PasskeyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasskeyModels.swift; sourceTree = ""; }; + 6399EA3B2F1BED4B0077BB13 /* PrimaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryButton.swift; sourceTree = ""; }; + 6399EA9A2F12B1870077BB13 /* EmailVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailVerificationView.swift; sourceTree = ""; }; 6399F94C2AA03C6C00A5C8EA /* BPSKANManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPSKANManager.swift; sourceTree = ""; }; 639AC9882AD9F1D50053AFC6 /* BPDownloadURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPDownloadURLSession.swift; sourceTree = ""; }; 639DF4972E634C8600B29BBB /* MiniPlayerArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerArtworkView.swift; sourceTree = ""; }; @@ -1469,6 +1490,8 @@ 63C48C7F2E3DC01A005FBB96 /* LoadingOverlayModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingOverlayModifier.swift; sourceTree = ""; }; 63C48C812E3DC2BE005FBB96 /* LoginSignInButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSignInButton.swift; sourceTree = ""; }; 63C48C832E3E66D1005FBB96 /* PurchasesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesManager.swift; sourceTree = ""; }; + 63C48CA02E4181F0005FBB96 /* ContinueWithPasskeyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinueWithPasskeyButton.swift; sourceTree = ""; }; + 63C48CA22E418200005FBB96 /* AppleSignInLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleSignInLink.swift; sourceTree = ""; }; 63C48D3B2E3E7D29005FBB96 /* SettingsCompleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCompleteAccountView.swift; sourceTree = ""; }; 63C48D3D2E3E7F9E005FBB96 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; 63C48D3F2E3E8128005FBB96 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; @@ -1479,6 +1502,7 @@ 63C48D492E3F008F005FBB96 /* AccountPerksSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPerksSectionView.swift; sourceTree = ""; }; 63C48D4D2E3F1A9C005FBB96 /* WidgetRenderModeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetRenderModeModifier.swift; sourceTree = ""; }; 63C48D502E3F1FCC005FBB96 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = ""; }; + 63C48D532E50AAA1005FBB96 /* AccountPasskeySectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPasskeySectionView.swift; sourceTree = ""; }; 63C6C2E52B5029BC00FFE0D8 /* SettingsAutolockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAutolockView.swift; sourceTree = ""; }; 63C6C2E72B5029FE00FFE0D8 /* SettingsAutolockViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAutolockViewModel.swift; sourceTree = ""; }; 63C6C30B2B538B7A00FFE0D8 /* SyncTasksStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTasksStorage.swift; sourceTree = ""; }; @@ -1659,6 +1683,7 @@ 9FF710B82A213084006490E0 /* QueuedSyncTaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskRowView.swift; sourceTree = ""; }; 9FF710BC2A215686006490E0 /* QueuedSyncTaskType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueuedSyncTaskType.swift; sourceTree = ""; }; 9FFCC08E289418CA00F4952E /* SimpleChapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleChapter.swift; sourceTree = ""; }; + C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PasskeyCreatingView.swift; sourceTree = ""; }; C30B085E209654E3003F325B /* UIColor+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+BookPlayer.swift"; sourceTree = ""; }; C30B66AD20E2D8CF00FC0030 /* ArtworkControl.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ArtworkControl.xib; sourceTree = ""; }; C30CD2A0209791FA00258B09 /* UIColor+Sweetercolor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Sweetercolor.swift"; sourceTree = ""; }; @@ -2818,6 +2843,28 @@ path = RemoteItemList; sourceTree = ""; }; + 6399EA242F12B1870077BB13 /* Passkey */ = { + isa = PBXGroup; + children = ( + 6399EA342F12BA5B0077BB13 /* Service */, + 6399EA222F12B1870077BB13 /* PasskeyRegistrationView.swift */, + 6399EA9A2F12B1870077BB13 /* EmailVerificationView.swift */, + 6399EA232F12B1870077BB13 /* PasskeySignInButton.swift */, + 3373A34FC2536111E50BF868 /* PasskeyEmailInputView.swift */, + C084D4BC05BF6B413C86C6F0 /* PasskeyCreatingView.swift */, + ); + path = Passkey; + sourceTree = ""; + }; + 6399EA342F12BA5B0077BB13 /* Service */ = { + isa = PBXGroup; + children = ( + 6399EA312F12BA5B0077BB13 /* PasskeyAPI.swift */, + 6399EA332F12BA5B0077BB13 /* PasskeyService.swift */, + ); + path = Service; + sourceTree = ""; + }; 63B9149D2EE47951000918D8 /* Utils */ = { isa = PBXGroup; children = ( @@ -3069,6 +3116,8 @@ 63C48C7B2E3DAD6E005FBB96 /* LoginBenefitSectionView.swift */, 63C48C7D2E3DB24A005FBB96 /* LoginDisclaimerSectionView.swift */, 63C48C812E3DC2BE005FBB96 /* LoginSignInButton.swift */, + 63C48CA02E4181F0005FBB96 /* ContinueWithPasskeyButton.swift */, + 63C48CA22E418200005FBB96 /* AppleSignInLink.swift */, ); path = Login; sourceTree = ""; @@ -3092,6 +3141,7 @@ 63C48D452E3EA035005FBB96 /* AccountTermsConditionsSectionView.swift */, 63C48D472E3EFD59005FBB96 /* AccountManageProSectionView.swift */, 63C48D492E3F008F005FBB96 /* AccountPerksSectionView.swift */, + 63C48D532E50AAA1005FBB96 /* AccountPasskeySectionView.swift */, ); path = Account; sourceTree = ""; @@ -3187,6 +3237,7 @@ 9FBDDB9A27DD1346005FB447 /* Profile */ = { isa = PBXGroup; children = ( + 6399EA242F12B1870077BB13 /* Passkey */, 9F4691EB2800C8EE00A8F0E8 /* Profile */, 9F4691F82800F85C00A8F0E8 /* Account */, 9F4691EC2800C90400A8F0E8 /* Login */, @@ -3213,6 +3264,7 @@ 9FC1E4612814F68F00522FA8 /* Account */ = { isa = PBXGroup; children = ( + 6399EA382F12BB690077BB13 /* PasskeyModels.swift */, 9F2DC9E32A01313C006CDF1F /* PricingModel.swift */, 9F7B64762803B0B000895ECC /* AccountService.swift */, 9FC1E45E2814F66F00522FA8 /* AccountAPI.swift */, @@ -3340,6 +3392,7 @@ 634BA5912C160B720015314D /* StoryViewer */, 8A9D0D272CCEEE30007A924D /* NavigationLazyView.swift */, 63C48C7F2E3DC01A005FBB96 /* LoadingOverlayModifier.swift */, + 6399EA3B2F1BED4B0077BB13 /* PrimaryButton.swift */, ); path = Views; sourceTree = ""; @@ -3511,8 +3564,6 @@ 416A29BF256A43F800605395 /* PBXTargetDependency */, ); name = BookPlayerWidgetsPhone; - packageProductDependencies = ( - ); productName = BookPlayerWidgetsPhone; productReference = 416A29A32569658100605395 /* BookPlayerWidgetsPhone.appex */; productType = "com.apple.product-type.app-extension"; @@ -3607,8 +3658,6 @@ 637DAB102AEBEF1B006DC2D1 /* PBXTargetDependency */, ); name = BookPlayerWidgetsWatch; - packageProductDependencies = ( - ); productName = BookPlayerWidgetsWatch; productReference = 63005A432AE7FD8000A4CA2C /* BookPlayerWidgetsWatch.appex */; productType = "com.apple.product-type.app-extension"; @@ -4032,6 +4081,7 @@ 9F9C7B5429F9672700E257B0 /* SyncableBookmark.swift in Sources */, 5126F123258E9F18009965DC /* URL+BookPlayer.swift in Sources */, 9FFCC090289418CA00F4952E /* SimpleChapter.swift in Sources */, + 6399EA392F12BB690077BB13 /* PasskeyModels.swift in Sources */, 419BD5992443FD04001E50A0 /* Intents.intentdefinition in Sources */, 62AAE22D274AA6DE001EB9FF /* LibraryService.swift in Sources */, 62CADBAF2725FE2C00A4A98F /* ArtworkService.swift in Sources */, @@ -4248,6 +4298,7 @@ 631908B22E36A1AB009249C1 /* SmartRewindSectionView.swift in Sources */, 63FCAB1B2E3BBA5F005EB9DE /* JellyfinTagsView.swift in Sources */, 63C48D4A2E3F008F005FBB96 /* AccountPerksSectionView.swift in Sources */, + 63C48D522E50AAA1005FBB96 /* AccountPasskeySectionView.swift in Sources */, 4165EE0520A743D500616EDF /* BookPlayer.xcdatamodeld in Sources */, 63C48D3C2E3E7D29005FBB96 /* SettingsCompleteAccountView.swift in Sources */, 9FF710B72A212A19006490E0 /* QueuedSyncTasksView.swift in Sources */, @@ -4361,7 +4412,10 @@ 638487A72EC7722400DF442B /* AudiobookShelfAudiobookDetailsData.swift in Sources */, 3F7B64362E0F71E900299D97 /* HardcoverSettingsView.swift in Sources */, 631908A12E33EF50009249C1 /* ConfettiView.swift in Sources */, + 6399EA352F12BA5B0077BB13 /* PasskeyService.swift in Sources */, + 6399EA372F12BA5B0077BB13 /* PasskeyAPI.swift in Sources */, 6399F94D2AA03C6C00A5C8EA /* BPSKANManager.swift in Sources */, + 6399EA3C2F1BED4B0077BB13 /* PrimaryButton.swift in Sources */, C3A479192094CAF300D92122 /* UIViewController+BookPlayer.swift in Sources */, 3F7B64492E0FB2C100299D97 /* InsertUserBookData.swift in Sources */, 636E77802DF33DA200D4DC0A /* BP+ErrorAlerts.swift in Sources */, @@ -4418,6 +4472,9 @@ 9F2E00DE2A5C2B810001FE20 /* StorageCloudDeletedViewModel.swift in Sources */, 3F7B64422E0FB27000299D97 /* AnyCodable.swift in Sources */, 4151A6A626E3A40600E49DBE /* SpeedService.swift in Sources */, + 6399EA252F12B1870077BB13 /* PasskeyRegistrationView.swift in Sources */, + 6399EA9B2F12B1870077BB13 /* EmailVerificationView.swift in Sources */, + 6399EA272F12B1870077BB13 /* PasskeySignInButton.swift in Sources */, 637043462E2C0199005353D1 /* SettingsiCloudSectionView.swift in Sources */, 3FF8EA4C2E11E2110054CD2C /* ItemDetailsHardcoverSectionView.swift in Sources */, 3FF8EA4D2E11E2110054CD2C /* HardcoverBookPickerViewModel.swift in Sources */, @@ -4472,6 +4529,8 @@ 6309F1262B0CF1C1002B86A4 /* BookPlaybackToggleIntent.swift in Sources */, 632D5A582E58C3CC006D10BC /* VerticalLabelStyle.swift in Sources */, 63C48C822E3DC2BE005FBB96 /* LoginSignInButton.swift in Sources */, + 63C48CA12E4181F0005FBB96 /* ContinueWithPasskeyButton.swift in Sources */, + 63C48CA32E418200005FBB96 /* AppleSignInLink.swift in Sources */, 63B760E82C31FFC600AA98C7 /* SupportFlowCoordinator.swift in Sources */, D6BA8F182A4D66CD00C2BD9A /* StorageView.swift in Sources */, 3F7B64472E0FB2A900299D97 /* AudiobookEditionsData.swift in Sources */, @@ -4494,6 +4553,8 @@ 637043402E2BFD6C005353D1 /* SettingsStorageSectionView.swift in Sources */, 8AAD8A572CEE88E5000A4B4B /* JellyfinConnectionData.swift in Sources */, 631908AC2E369E31009249C1 /* GlobalSpeedSectionView.swift in Sources */, + F906EF4FC85B1CCE138B230D /* PasskeyEmailInputView.swift in Sources */, + 07416E5AD384927D90BFB6EE /* PasskeyCreatingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4540,6 +4601,7 @@ 9F1345B82938DF360089B1DE /* UIFont+BookPlayer.swift in Sources */, 639AC98C2AD9F2840053AFC6 /* BPTaskDownloadDelegate.swift in Sources */, 9FFCC08F289418CA00F4952E /* SimpleChapter.swift in Sources */, + 6399EA3A2F12BB690077BB13 /* PasskeyModels.swift in Sources */, 416AAC3423F515AF005AD04F /* String+BookPlayer.swift in Sources */, 9FBDBC792879409600D315A2 /* SyncableItem.swift in Sources */, 62AAE22E274AA6DE001EB9FF /* LibraryService.swift in Sources */, diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index c85700f33..c5d221344 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -562,6 +562,9 @@ extension AppDelegate { self.coreServices = coreServices + // Wire up accountService for Watch auth transfer + watchService.setAccountService(accountService) + return coreServices } } diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index fcc669c8f..1855350cd 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continue last played book"; "watchapp_connect_error_title" = "Connectivity Error"; "watchapp_connect_error_description" = "There's a problem connecting to your phone, please try again later"; +"watch_signin_with_iphone" = "Sign in with iPhone"; +"watch_signin_phone_required" = "Please sign in on your iPhone first, then try again."; +"watch_signin_failed" = "Sign in failed"; "sleep_remaining_title" = "%@ remaining until sleep"; "audio_source_title" = "Audio Source"; "speed_title" = "speed"; @@ -255,6 +258,10 @@ "logout_title" = "Log out"; "delete_account_title" = "Delete Account"; "account_title" = "Account"; +"account_passkeys_title" = "Passkeys"; +"account_passkeys_description" = "Passkeys let you sign in securely with Face ID or Touch ID instead of a password."; +"account_passkey_configured" = "Your passkey is synced via iCloud Keychain and works across all your Apple devices."; +"account_add_passkey_title" = "Add a Passkey"; "benefits_cloudsync_title" = "Cloud sync (Beta)"; "benefits_themesicons_title" = "Themes & Icons"; "benefits_supportus_title" = "Support us"; @@ -395,3 +402,32 @@ We're working hard on providing a seamless experience, if possible, please conta "database_no_backup_message" = "The database is corrupted and no backup is available. The library will need to be reset and rebuilt from your audio files."; "settings_smartrewind_max_interval_title" = "Smart Rewind Limit"; "settings_support_discord_title" = "Join our Discord server"; + +// Passkey Authentication +"passkey_unnamed_device" = "Unnamed Device"; +"passkey_delete_title" = "Delete Passkey"; +"passkey_delete_message" = "Are you sure you want to delete this passkey? You won't be able to use it to sign in anymore."; +"passkey_continue_button" = "Continue with Passkey"; +"passkey_signin_button" = "Sign in with Passkey"; +"apple_signin_link" = "Sign in with Apple"; +"apple_signin_title" = "Sign in with Apple"; +"apple_signin_subtitle" = "Use your existing Apple ID to sign in or create an account."; +"email_title" = "Email"; +"passkey_registration_title" = "Create Account"; +"auth_methods_section_title" = "Sign-in Methods"; +"passkey_created" = "Created"; +"auth_method_added" = "Added"; +"auth_method_primary" = "Primary"; + +/* Email Verification */ +"verify_email_title" = "Verify Your Email"; +"verify_email_subtitle" = "Enter the 6-digit code sent to %@"; +"verify_button" = "Verify"; +"verify_didnt_receive" = "Didn't receive the code?"; +"verify_resend_button" = "Resend Code"; +"verify_resend_wait" = "Resend in %d seconds"; +"continue_title" = "Continue"; +"passkey_creating" = "Creating your passkey..."; +"passkey_email_exists_title" = "Account Exists"; +"passkey_email_exists_message" = "An account with this email already exists. Please sign in with your existing passkey or Apple ID instead."; +"passkey_signin_existing" = "Sign in with existing passkey"; diff --git a/BookPlayer/BookPlayer.entitlements b/BookPlayer/BookPlayer.entitlements index 4ee542517..f111acb3f 100644 --- a/BookPlayer/BookPlayer.entitlements +++ b/BookPlayer/BookPlayer.entitlements @@ -6,6 +6,10 @@ Default + com.apple.developer.associated-domains + + webcredentials:bookplayer.app + com.apple.developer.icloud-container-identifiers iCloud.$(CFBundleIdentifier) diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index da1da1001..276ae7de0 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -131,13 +131,13 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { var insertItemsFromReceivedFiles: [URL]? var insertItemsFromReceivedInvocations: [[URL]] = [] var insertItemsFromReturnValue: [SimpleLibraryItem]! - var insertItemsFromClosure: (([URL]) -> [SimpleLibraryItem])? - func insertItems(from files: [URL]) -> [SimpleLibraryItem] { + var insertItemsFromClosure: (([URL]) async -> [SimpleLibraryItem])? + func insertItems(from files: [URL]) async -> [SimpleLibraryItem] { insertItemsFromCallsCount += 1 insertItemsFromReceivedFiles = files insertItemsFromReceivedInvocations.append(files) if let insertItemsFromClosure = insertItemsFromClosure { - return insertItemsFromClosure(files) + return await insertItemsFromClosure(files) } else { return insertItemsFromReturnValue } @@ -465,13 +465,13 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol { var createBookFromReceivedUrl: URL? var createBookFromReceivedInvocations: [URL] = [] var createBookFromReturnValue: Book! - var createBookFromClosure: ((URL) -> Book)? - func createBook(from url: URL) -> Book { + var createBookFromClosure: ((URL) async -> Book)? + func createBook(from url: URL) async -> Book { createBookFromCallsCount += 1 createBookFromReceivedUrl = url createBookFromReceivedInvocations.append(url) if let createBookFromClosure = createBookFromClosure { - return createBookFromClosure(url) + return await createBookFromClosure(url) } else { return createBookFromReturnValue } diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index 207d47241..90ae4b369 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -534,7 +534,7 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { return item.publisher(for: \.percentCompleted, options: [.initial, .new]) .map { percentCompleted in let progress = item.isFinished ? 1.0 : percentCompleted / 100 - return (item.relativePath, progress ) + return (item.relativePath, progress) } .eraseToAnyPublisher() } diff --git a/BookPlayer/Profile/Account/AccountPasskeySectionView.swift b/BookPlayer/Profile/Account/AccountPasskeySectionView.swift new file mode 100644 index 000000000..3fb2218e8 --- /dev/null +++ b/BookPlayer/Profile/Account/AccountPasskeySectionView.swift @@ -0,0 +1,169 @@ +// +// AccountPasskeySectionView.swift +// BookPlayer +// +// Created by Claude on 1/10/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct AccountPasskeySectionView: View { + @State private var passkey: PasskeyInfo? + @State private var isLoading = false + @State private var loadFailed = false + @State private var error: Error? + @State private var showDeleteConfirmation = false + + @EnvironmentObject private var theme: ThemeViewModel + @Environment(\.accountService) private var accountService + @Environment(\.passkeyService) private var passkeyService + + var body: some View { + Section { + if isLoading { + loadingView + } else if let passkey = passkey { + passkeyRow(passkey) + } else if loadFailed { + retryButton + } else { + addButton + } + } header: { + Text("Passkey") + .foregroundStyle(theme.secondaryColor) + } + .onAppear { + Task { + await loadPasskey() + } + } + .errorAlert(error: $error) + .alert("passkey_delete_title".localized, isPresented: $showDeleteConfirmation) { + Button("cancel_button".localized, role: .cancel) {} + Button("delete_button".localized, role: .destructive) { + if let passkey = passkey { + Task { + await deletePasskey(id: passkey.id) + } + } + } + } message: { + Text("passkey_delete_message".localized) + } + } + + @ViewBuilder + private var loadingView: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + + @ViewBuilder + private func passkeyRow(_ passkey: PasskeyInfo) -> some View { + HStack(spacing: Spacing.S) { + Image(systemName: "person.badge.key") + .font(.title2) + .foregroundStyle(theme.linkColor) + + VStack(alignment: .leading, spacing: 4) { + Text(passkey.deviceName ?? "passkey_unnamed_device".localized) + .font(.body) + .foregroundStyle(theme.primaryColor) + + Text("passkey_created".localized + " " + passkey.createdAt.formatted(date: .abbreviated, time: .omitted)) + .font(.caption) + .foregroundStyle(theme.secondaryColor) + } + + Spacer() + + Menu { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + Label("delete_button".localized, systemImage: "trash") + } + .tint(.red) + } label: { + Image(systemName: "ellipsis.circle") + .font(.title2) + .foregroundStyle(theme.linkColor) + .frame(width: 44, height: 44) + } + } + } + + @ViewBuilder + private var retryButton: some View { + Button { + Task { + await loadPasskey() + } + } label: { + Label("network_error_title".localized, systemImage: "arrow.clockwise") + .foregroundStyle(theme.linkColor) + } + } + + @ViewBuilder + private var addButton: some View { + Button { + addPasskey() + } label: { + Label("account_add_passkey_title".localized, systemImage: "person.badge.key") + .foregroundStyle(theme.linkColor) + } + } + + private func loadPasskey() async { + isLoading = true + loadFailed = false + do { + let passkeys = try await passkeyService.listPasskeys() + // Only use the first passkey (we only support one per account) + passkey = passkeys.first + } catch { + loadFailed = true + } + isLoading = false + } + + private func addPasskey() { + // Don't allow adding if one already exists + guard passkey == nil else { return } + + Task { + do { + let deviceName = UIDevice.current.name + let email = accountService.getAccount()?.email + try await passkeyService.addPasskeyToAccount(deviceName: deviceName, email: email!) + await loadPasskey() + } catch PasskeyError.userCancelled { + // User cancelled, do nothing + } catch { + self.error = error + } + } + } + + private func deletePasskey(id: Int) async { + do { + try await passkeyService.deletePasskey(id: id) + await loadPasskey() + } catch { + self.error = error + } + } +} + +#Preview { + Form { + AccountPasskeySectionView() + } +} diff --git a/BookPlayer/Profile/Account/AccountView.swift b/BookPlayer/Profile/Account/AccountView.swift index 53dbf4d52..414a8e8f4 100644 --- a/BookPlayer/Profile/Account/AccountView.swift +++ b/BookPlayer/Profile/Account/AccountView.swift @@ -30,6 +30,7 @@ struct AccountView: View { } } AccountTermsConditionsSectionView() + AccountPasskeySectionView() AccountLogoutSectionView() AccountDeleteSectionView(showAlert: $showDeleteAlert) } diff --git a/BookPlayer/Profile/Login/AppleSignInLink.swift b/BookPlayer/Profile/Login/AppleSignInLink.swift new file mode 100644 index 000000000..ced879092 --- /dev/null +++ b/BookPlayer/Profile/Login/AppleSignInLink.swift @@ -0,0 +1,68 @@ +// +// AppleSignInLink.swift +// BookPlayer +// +// Created by Claude on 1/10/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import AuthenticationServices +import BookPlayerKit +import SwiftUI + +struct AppleSignInLink: View { + @Environment(\.loadingState) private var loadingState + @Environment(\.accountService) private var accountService + @EnvironmentObject private var theme: ThemeViewModel + + @State private var showAppleSignIn = false + + var handleSignIn: (_ hasSubscription: Bool) -> Void + + var body: some View { + SignInWithAppleButton(.signIn) { request in + request.requestedScopes = [.email] + } onCompletion: { result in + switch result { + case .success(let authorization): + Task { + do { + loadingState.show = true + + guard + let creds = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = creds.identityToken, + let token = String(data: tokenData, encoding: .utf8) + else { + throw AccountError.missingToken + } + + let account = try await accountService.login( + with: token, + userId: creds.user + ) + + loadingState.show = false + + handleSignIn(account?.hasSubscription == true) + } catch { + loadingState.show = false + loadingState.error = error + } + } + case .failure(let error): + if (error as? ASAuthorizationError)?.code != .canceled { + loadingState.error = error + } + } + } + .frame(height: 46) + .signInWithAppleButtonStyle(theme.useDarkVariant ? .white : .black) + .padding(.horizontal, Spacing.M) + } +} + +#Preview { + AppleSignInLink { _ in } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Login/ContinueWithPasskeyButton.swift b/BookPlayer/Profile/Login/ContinueWithPasskeyButton.swift new file mode 100644 index 000000000..495cf8de0 --- /dev/null +++ b/BookPlayer/Profile/Login/ContinueWithPasskeyButton.swift @@ -0,0 +1,34 @@ +// +// ContinueWithPasskeyButton.swift +// BookPlayer +// +// Created by Claude on 1/10/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct ContinueWithPasskeyButton: View { + @EnvironmentObject private var theme: ThemeViewModel + + var action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: Spacing.S) { + Image(systemName: "person.badge.key.fill") + Text("passkey_continue_button".localized) + } + .bpFont(Fonts.body) + .frame(maxWidth: .infinity) + .foregroundColor(theme.linkColor) + } + .padding(.horizontal, Spacing.M) + } +} + +#Preview { + ContinueWithPasskeyButton { } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Login/LoginView.swift b/BookPlayer/Profile/Login/LoginView.swift index 23c2e41b8..218b25882 100644 --- a/BookPlayer/Profile/Login/LoginView.swift +++ b/BookPlayer/Profile/Login/LoginView.swift @@ -13,6 +13,7 @@ import SwiftUI struct LoginView: View { @State private var loadingState = LoadingOverlayState() @State private var showCompleteAccount = false + @State private var showPasskeyRegistration = false @EnvironmentObject private var theme: ThemeViewModel @Environment(\.dismiss) private var dismiss @@ -38,12 +39,17 @@ struct LoginView: View { LoginDisclaimerSectionView() } .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) - LoginSignInButton { hasSubscription in - if hasSubscription { - dismiss() - } else { - showCompleteAccount = true + + VStack(spacing: Spacing.S) { + AppleSignInLink { hasSubscription in + handleSignInResult(hasSubscription: hasSubscription) } + + // Continue with Passkey - goes to registration/sign-in screen + ContinueWithPasskeyButton { + showPasskeyRegistration = true + } + .padding(.bottom, Spacing.S) } } .environment(\.loadingState, loadingState) @@ -60,6 +66,12 @@ struct LoginView: View { } .presentationDetents([.medium]) } + .sheet(isPresented: $showPasskeyRegistration) { + PasskeyRegistrationView { hasSubscription in + showPasskeyRegistration = false + handleSignInResult(hasSubscription: hasSubscription) + } + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { @@ -71,6 +83,14 @@ struct LoginView: View { } } } + + private func handleSignInResult(hasSubscription: Bool) { + if hasSubscription { + dismiss() + } else { + showCompleteAccount = true + } + } } #Preview { diff --git a/BookPlayer/Profile/Passkey/EmailVerificationView.swift b/BookPlayer/Profile/Passkey/EmailVerificationView.swift new file mode 100644 index 000000000..0e2ec3e37 --- /dev/null +++ b/BookPlayer/Profile/Passkey/EmailVerificationView.swift @@ -0,0 +1,142 @@ +// +// EmailVerificationView.swift +// BookPlayer +// +// Created by Claude on 1/11/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct EmailVerificationView: View { + let email: String + var onVerified: (String) -> Void + + @State private var code: String = "" + @FocusState private var isTextFieldFocused: Bool + @State private var resendCooldown: Int = 0 + + @Environment(\.loadingState) private var loadingState + @Environment(\.passkeyService) private var passkeyService + @EnvironmentObject private var theme: ThemeViewModel + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(spacing: Spacing.S) { + Text(String(format: "verify_email_subtitle".localized, email)) + .bpFont(Fonts.subheadline) + .foregroundStyle(theme.primaryColor) + .multilineTextAlignment(.center) + + ClearableTextField("", text: $code) { + verifyCode() + } + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .autocapitalization(.none) + .focused($isTextFieldFocused) + .padding(.vertical, Spacing.M) + + // Verify button + PrimaryButton(text: "verify_button".localized) { + verifyCode() + } + .disabled(code.isEmpty || loadingState.show) + + // Resend section + VStack(spacing: Spacing.S4) { + Text("verify_didnt_receive".localized) + .bpFont(Fonts.caption) + .foregroundStyle(theme.secondaryColor) + + if resendCooldown > 0 { + Text(String(format: "verify_resend_wait".localized, resendCooldown)) + .bpFont(Fonts.caption) + .foregroundStyle(theme.secondaryColor) + } else { + Button("verify_resend_button".localized) { + resendCode() + } + .bpFont(Fonts.caption) + .foregroundStyle(theme.linkColor) + .disabled(loadingState.show) + } + } + Spacer() + } + .padding(.horizontal, Spacing.M) + .onReceive(timer) { _ in + if resendCooldown > 0 { + resendCooldown -= 1 + } + } + .onAppear { + isTextFieldFocused = true + } + .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) + .navigationTitle("verify_email_title".localized) + .navigationBarTitleDisplayMode(.inline) + } + + private func verifyCode() { + guard !code.isEmpty, !loadingState.show else { return } + + loadingState.show = true + + Task { + do { + let response = try await passkeyService.checkVerificationCode( + email: email, + code: code + ) + + loadingState.show = false + + if let token = response.verificationToken { + onVerified(token) + } else { + loadingState.error = PasskeyError.emailVerificationFailed( + response.message ?? "Verification failed" + ) + } + } catch { + loadingState.show = false + loadingState.error = error + // Clear code on error + code = "" + isTextFieldFocused = true + } + } + } + + private func resendCode() { + guard !loadingState.show else { return } + + loadingState.show = true + + Task { + do { + _ = try await passkeyService.sendVerificationCode(email: email) + loadingState.show = false + resendCooldown = 60 // Wait 60 seconds before allowing resend + code = "" + isTextFieldFocused = true + } catch { + loadingState.show = false + loadingState.error = error + } + } + } +} + +#Preview { + NavigationStack { + EmailVerificationView( + email: "test@example.com", + onVerified: { _ in } + ) + } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/PasskeyCreatingView.swift b/BookPlayer/Profile/Passkey/PasskeyCreatingView.swift new file mode 100644 index 000000000..203136303 --- /dev/null +++ b/BookPlayer/Profile/Passkey/PasskeyCreatingView.swift @@ -0,0 +1,42 @@ +// +// PasskeyCreatingView.swift +// BookPlayer +// +// Created by Claude on 1/17/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct PasskeyCreatingView: View { + let email: String + + @EnvironmentObject private var theme: ThemeViewModel + + var body: some View { + VStack(spacing: Spacing.M) { + Text("passkey_creating".localized) + .font(.headline) + .padding(.top, Spacing.S2) + Text(email) + .font(.subheadline) + .foregroundStyle(.secondary) + ProgressView() + .scaleEffect(1.5) + .padding(.top, Spacing.S2) + Spacer() + } + .frame(maxWidth: .infinity) + .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) + .navigationTitle("passkey_registration_title".localized) + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + NavigationStack { + PasskeyCreatingView(email: "test@example.com") + } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/PasskeyEmailInputView.swift b/BookPlayer/Profile/Passkey/PasskeyEmailInputView.swift new file mode 100644 index 000000000..c58be09ee --- /dev/null +++ b/BookPlayer/Profile/Passkey/PasskeyEmailInputView.swift @@ -0,0 +1,99 @@ +// +// PasskeyEmailInputView.swift +// BookPlayer +// +// Created by Claude on 1/17/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct PasskeyEmailInputView: View { + @Binding var email: String + let isLoading: Bool + let onContinue: () -> Void + let onSignIn: () -> Void + let onDismiss: () -> Void + + @FocusState private var isTextFieldFocused: Bool + @EnvironmentObject private var theme: ThemeViewModel + + private var isEmailValid: Bool { + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + return NSPredicate(format: "SELF MATCHES %@", emailRegex).evaluate(with: email) + } + + var body: some View { + Form { + Section { + ClearableTextField( + "email_title".localized, + text: $email, + onCommit: { + isTextFieldFocused = false + } + ) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + .disableAutocorrection(true) + .focused($isTextFieldFocused) + .onAppear { + isTextFieldFocused = true + } + } header: { + Text("email_title".localized) + .foregroundStyle(theme.secondaryColor) + } + + Section { + PrimaryButton(text: "continue_title".localized) { + onContinue() + } + .disabled(!isEmailValid || isLoading) + + Button(action: onSignIn) { + Text("passkey_signin_existing".localized) + .bpFont(Fonts.body) + .frame(maxWidth: .infinity) + .foregroundColor(theme.linkColor) + } + .buttonStyle(.borderless) + .tint(theme.linkColor) + } + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + } + .contentShape(Rectangle()) + .onTapGesture { + isTextFieldFocused = false + } + .navigationTitle("passkey_registration_title".localized) + .navigationBarTitleDisplayMode(.inline) + .applyListStyle(with: theme, background: theme.systemGroupedBackgroundColor) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .foregroundStyle(theme.linkColor) + } + } + } + } +} + +#Preview { + NavigationStack { + PasskeyEmailInputView( + email: .constant(""), + isLoading: false, + onContinue: {}, + onSignIn: {}, + onDismiss: {} + ) + } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/PasskeyRegistrationView.swift b/BookPlayer/Profile/Passkey/PasskeyRegistrationView.swift new file mode 100644 index 000000000..f4064c9aa --- /dev/null +++ b/BookPlayer/Profile/Passkey/PasskeyRegistrationView.swift @@ -0,0 +1,145 @@ +// +// PasskeyRegistrationView.swift +// BookPlayer +// +// Created by Claude on 1/9/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +enum PasskeyDestination: Hashable { + case verifyEmail + case createPasskey +} + +struct PasskeyRegistrationView: View { + @State private var path = NavigationPath() + @State private var email: String = "" + @State private var verificationToken: String = "" + @State private var loadingState = LoadingOverlayState() + @State private var showEmailExistsAlert = false + + @Environment(\.dismiss) private var dismiss + @Environment(\.accountService) private var accountService + @Environment(\.passkeyService) private var passkeyService + @EnvironmentObject private var theme: ThemeViewModel + + var onRegistrationComplete: (_ hasSubscription: Bool) -> Void + + var body: some View { + NavigationStack(path: $path) { + PasskeyEmailInputView( + email: $email, + isLoading: loadingState.show, + onContinue: sendVerificationCode, + onSignIn: signInWithPasskey, + onDismiss: { dismiss() } + ) + .navigationDestination(for: PasskeyDestination.self) { destination in + switch destination { + case .verifyEmail: + EmailVerificationView( + email: email, + onVerified: { token in + verificationToken = token + path.append(PasskeyDestination.createPasskey) + createPasskey() + } + ) + case .createPasskey: + PasskeyCreatingView(email: email) + .navigationBarBackButtonHidden(true) + } + } + } + .environment(\.loadingState, loadingState) + .errorAlert(error: $loadingState.error) + .loadingOverlay(loadingState.show) + .alert("passkey_email_exists_title".localized, isPresented: $showEmailExistsAlert) { + Button("ok_button".localized) {} + } message: { + Text("passkey_email_exists_message".localized) + } + } + + // MARK: - Actions + + private func signInWithPasskey() { + Task { + do { + loadingState.show = true + + let response = try await passkeyService.signIn() + + try await accountService.handlePasskeyLogin(response: response) + + loadingState.show = false + + onRegistrationComplete(accountService.account.hasSubscription) + } catch PasskeyError.userCancelled { + loadingState.show = false + // User cancelled, stay on screen + } catch { + loadingState.show = false + loadingState.error = error + } + } + } + + private func sendVerificationCode() { + Task { + do { + loadingState.show = true + + _ = try await passkeyService.sendVerificationCode(email: email) + + loadingState.show = false + path.append(PasskeyDestination.verifyEmail) + } catch PasskeyError.emailAlreadyRegistered { + loadingState.show = false + showEmailExistsAlert = true + } catch { + loadingState.show = false + loadingState.error = error + } + } + } + + private func createPasskey() { + Task { + do { + loadingState.show = true + + let deviceName = UIDevice.current.name + let response = try await passkeyService.registerNewAccount( + email: email, + verificationToken: verificationToken, + deviceName: deviceName + ) + + // Store token and update account + try await accountService.handlePasskeyLogin(response: response) + + loadingState.show = false + + onRegistrationComplete(accountService.account.hasSubscription) + } catch PasskeyError.userCancelled { + loadingState.show = false + // Pop to root on cancel + path = NavigationPath() + } catch { + loadingState.show = false + loadingState.error = error + // Pop to root on error + path = NavigationPath() + } + } + } +} + +#Preview { + PasskeyRegistrationView { _ in } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/PasskeySignInButton.swift b/BookPlayer/Profile/Passkey/PasskeySignInButton.swift new file mode 100644 index 000000000..2f2495ef0 --- /dev/null +++ b/BookPlayer/Profile/Passkey/PasskeySignInButton.swift @@ -0,0 +1,64 @@ +// +// PasskeySignInButton.swift +// BookPlayer +// +// Created by Claude on 1/9/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct PasskeySignInButton: View { + @Environment(\.loadingState) private var loadingState + @Environment(\.accountService) private var accountService + @Environment(\.passkeyService) private var passkeyService + @EnvironmentObject private var theme: ThemeViewModel + + var handleSignIn: (_ hasSubscription: Bool) -> Void + + var body: some View { + Button(action: signIn) { + HStack(spacing: Spacing.S) { + Image(systemName: "person.badge.key.fill") + .font(.title2) + Text("passkey_signin_button".localized) + .font(.headline) + } + .frame(maxWidth: .infinity) + .frame(height: 46) + .background(theme.useDarkVariant ? Color.white : Color.black) + .foregroundColor(theme.useDarkVariant ? .black : .white) + .cornerRadius(8) + } + .padding(.horizontal, Spacing.M) + } + + private func signIn() { + Task { + do { + loadingState.show = true + + let response = try await passkeyService.signIn() + + // Store token and update account + try await accountService.handlePasskeyLogin(response: response) + + loadingState.show = false + + handleSignIn(accountService.account.hasSubscription) + } catch PasskeyError.userCancelled { + loadingState.show = false + // User cancelled, no error to show + } catch { + loadingState.show = false + loadingState.error = error + } + } + } +} + +#Preview { + PasskeySignInButton { _ in } + .environmentObject(ThemeViewModel()) +} diff --git a/BookPlayer/Profile/Passkey/Service/PasskeyAPI.swift b/BookPlayer/Profile/Passkey/Service/PasskeyAPI.swift new file mode 100644 index 000000000..f4f3168e2 --- /dev/null +++ b/BookPlayer/Profile/Passkey/Service/PasskeyAPI.swift @@ -0,0 +1,158 @@ +// +// PasskeyAPI.swift +// BookPlayer +// +// Created by Claude on 1/9/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import Foundation + +public enum PasskeyAPI { + // Email verification + case sendVerificationCode(email: String) + case checkVerificationCode(email: String, code: String) + + // Registration + case registrationOptions(email: String, verificationToken: String?, deviceName: String?) + case registrationVerify( + email: String, + credentialId: String, + attestationObject: String, + clientDataJSON: String, + transports: [String]?, + deviceName: String? + ) + + // Authentication + case authenticationOptions(email: String?) + case authenticationVerify( + credentialId: String, + authenticatorData: String, + clientDataJSON: String, + signature: String, + userHandle: String? + ) + + // Credential management + case listPasskeys + case deletePasskey(id: Int) + case renamePasskey(id: Int, deviceName: String) + + // Auth method management + case listAuthMethods +} + +extension PasskeyAPI: Endpoint { + public var path: String { + switch self { + case .sendVerificationCode: + return "/v1/passkey/verify-email/send" + case .checkVerificationCode: + return "/v1/passkey/verify-email/check" + case .registrationOptions: + return "/v1/passkey/register/options" + case .registrationVerify: + return "/v1/passkey/register/verify" + case .authenticationOptions: + return "/v1/passkey/auth/options" + case .authenticationVerify: + return "/v1/passkey/auth/verify" + case .listPasskeys: + return "/v1/passkey/credentials" + case .deletePasskey(let id): + return "/v1/passkey/credentials/\(id)" + case .renamePasskey(let id, _): + return "/v1/passkey/credentials/\(id)" + case .listAuthMethods: + return "/v1/passkey/auth-methods" + } + } + + public var method: HTTPMethod { + switch self { + case .sendVerificationCode, .checkVerificationCode, + .registrationOptions, .registrationVerify, + .authenticationOptions, .authenticationVerify: + return .post + case .listPasskeys, .listAuthMethods: + return .get + case .deletePasskey: + return .delete + case .renamePasskey: + return .patch + } + } + + public var parameters: [String: Any]? { + switch self { + case .sendVerificationCode(let email): + return ["email": email] + + case .checkVerificationCode(let email, let code): + return ["email": email, "code": code] + + case .registrationOptions(let email, let verificationToken, let deviceName): + var params: [String: Any] = ["email": email] + if let verificationToken = verificationToken { + params["verification_token"] = verificationToken + } + if let deviceName = deviceName { + params["device_name"] = deviceName + } + return params + + case .registrationVerify(let email, let credentialId, let attestationObject, + let clientDataJSON, let transports, let deviceName): + var response: [String: Any] = [ + "attestation_object": attestationObject, + "client_data_json": clientDataJSON + ] + if let transports = transports { + response["transports"] = transports + } + + var params: [String: Any] = [ + "email": email, + "credential_id": credentialId, + "response": response + ] + if let deviceName = deviceName { + params["device_name"] = deviceName + } + return params + + case .authenticationOptions(let email): + if let email = email { + return ["email": email] + } + return [:] + + case .authenticationVerify(let credentialId, let authenticatorData, + let clientDataJSON, let signature, let userHandle): + var response: [String: Any] = [ + "authenticator_data": authenticatorData, + "client_data_json": clientDataJSON, + "signature": signature + ] + if let userHandle = userHandle { + response["user_handle"] = userHandle + } + + return [ + "credential_id": credentialId, + "response": response + ] + + case .listPasskeys, .listAuthMethods: + return nil + + case .deletePasskey: + return nil + + case .renamePasskey(_, let deviceName): + return ["device_name": deviceName] + } + } +} diff --git a/BookPlayer/Profile/Passkey/Service/PasskeyService.swift b/BookPlayer/Profile/Passkey/Service/PasskeyService.swift new file mode 100644 index 000000000..9ef3c9818 --- /dev/null +++ b/BookPlayer/Profile/Passkey/Service/PasskeyService.swift @@ -0,0 +1,311 @@ +// +// PasskeyService.swift +// BookPlayer +// +// Created by Claude on 1/9/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import AuthenticationServices +import Foundation +import UIKit +import BookPlayerKit + +public protocol PasskeyServiceProtocol { + // Email verification + func sendVerificationCode(email: String) async throws -> EmailVerificationSendResponse + func checkVerificationCode(email: String, code: String) async throws -> EmailVerificationCheckResponse + + // Registration + func registerNewAccount( + email: String, + verificationToken: String, + deviceName: String? + ) async throws -> PasskeyLoginResponse + func addPasskeyToAccount(deviceName: String?, email: String) async throws + + // Authentication + func signIn() async throws -> PasskeyLoginResponse + + // Credential management + func listPasskeys() async throws -> [PasskeyInfo] + func deletePasskey(id: Int) async throws + func renamePasskey(id: Int, deviceName: String) async throws + func listAuthMethods() async throws -> [AuthMethodInfo] +} + +public final class PasskeyService: NSObject, PasskeyServiceProtocol, @unchecked Sendable { + private let relyingPartyIdentifier = "bookplayer.app" + private let client: NetworkClientProtocol + private var provider: NetworkProvider! + + private var registrationContinuation: CheckedContinuation? + private var authenticationContinuation: CheckedContinuation? + + public init(client: NetworkClientProtocol = NetworkClient()) { + self.client = client + super.init() + self.provider = NetworkProvider(client: client) + } + + // MARK: - Email Verification + + public func sendVerificationCode(email: String) async throws -> EmailVerificationSendResponse { + do { + let response: EmailVerificationSendResponse = try await provider.request( + .sendVerificationCode(email: email) + ) + + if !response.success, let message = response.message { + throw PasskeyError.emailVerificationFailed(message) + } + + return response + } catch let error as BookPlayerError { + if case .networkErrorWithCode(_, let code) = error, + code == "EMAIL_ALREADY_REGISTERED" { + throw PasskeyError.emailAlreadyRegistered + } + throw error + } + } + + public func checkVerificationCode(email: String, code: String) async throws -> EmailVerificationCheckResponse { + let response: EmailVerificationCheckResponse = try await provider.request( + .checkVerificationCode(email: email, code: code) + ) + + if !response.verified, let message = response.message { + throw PasskeyError.emailVerificationFailed(message) + } + + return response + } + + // MARK: - Registration + + public func registerNewAccount( + email: String, + verificationToken: String, + deviceName: String? + ) async throws -> PasskeyLoginResponse { + // 1. Request registration options from server (with verification token) + let options: PasskeyRegistrationOptions = try await provider.request( + .registrationOptions(email: email, verificationToken: verificationToken, deviceName: deviceName) + ) + + // 2. Create credential with platform authenticator + let credential = try await performRegistration( + challenge: options.challenge, + userId: options.userId, + userName: email + ) + + // 3. Send attestation to server + let transports = credential.rawAttestationObject != nil + ? ["internal"] + : nil + + let response: PasskeyLoginResponse = try await provider.request( + .registrationVerify( + email: email, + credentialId: credential.credentialID.base64URLEncodedString(), + attestationObject: credential.rawAttestationObject?.base64URLEncodedString() ?? "", + clientDataJSON: credential.rawClientDataJSON.base64URLEncodedString(), + transports: transports, + deviceName: deviceName + ) + ) + + return response + } + + public func addPasskeyToAccount(deviceName: String?, email: String) async throws { + // 1. Request registration options for existing account (requires auth token, no verification needed) + let options: PasskeyRegistrationOptions = try await provider.request( + .registrationOptions(email: email, verificationToken: nil, deviceName: deviceName) + ) + + // 2. Create credential + let credential = try await performRegistration( + challenge: options.challenge, + userId: options.userId, + userName: options.userName + ) + + // 3. Complete registration + let transports = credential.rawAttestationObject != nil + ? ["internal"] + : nil + + // Server returns PasskeyLoginResponse for all registrations, but we ignore it + // since the user is already logged in when adding a passkey to existing account + let _: PasskeyLoginResponse = try await provider.request( + .registrationVerify( + email: options.userName, + credentialId: credential.credentialID.base64URLEncodedString(), + attestationObject: credential.rawAttestationObject?.base64URLEncodedString() ?? "", + clientDataJSON: credential.rawClientDataJSON.base64URLEncodedString(), + transports: transports, + deviceName: deviceName + ) + ) + } + + // MARK: - Authentication + + public func signIn() async throws -> PasskeyLoginResponse { + // 1. Request authentication options + let options: PasskeyAuthenticationOptions = try await provider.request( + .authenticationOptions(email: nil) + ) + + // 2. Authenticate with platform authenticator + let assertion = try await performAuthentication(challenge: options.challenge) + + // 3. Verify assertion with server + let response: PasskeyLoginResponse = try await provider.request( + .authenticationVerify( + credentialId: assertion.credentialID.base64URLEncodedString(), + authenticatorData: assertion.rawAuthenticatorData.base64URLEncodedString(), + clientDataJSON: assertion.rawClientDataJSON.base64URLEncodedString(), + signature: assertion.signature.base64URLEncodedString(), + userHandle: assertion.userID.base64URLEncodedString() + ) + ) + + return response + } + + // MARK: - Credential Management + + public func listPasskeys() async throws -> [PasskeyInfo] { + let response: PasskeyListResponse = try await provider.request(.listPasskeys) + return response.passkeys + } + + public func deletePasskey(id: Int) async throws { + let _: PasskeySuccessResponse = try await provider.request(.deletePasskey(id: id)) + } + + public func renamePasskey(id: Int, deviceName: String) async throws { + let _: PasskeySuccessResponse = try await provider.request(.renamePasskey(id: id, deviceName: deviceName)) + } + + public func listAuthMethods() async throws -> [AuthMethodInfo] { + let response: AuthMethodListResponse = try await provider.request(.listAuthMethods) + return response.methods + } + + // MARK: - Private Methods + + @MainActor + private func performRegistration( + challenge: String, + userId: String, + userName: String + ) async throws -> ASAuthorizationPlatformPublicKeyCredentialRegistration { + guard let challengeData = Data(base64URLEncoded: challenge) else { + throw PasskeyError.registrationFailed("Invalid challenge") + } + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + + let registrationRequest = platformProvider.createCredentialRegistrationRequest( + challenge: challengeData, + name: userName, + userID: userId.data(using: .utf8)! + ) + + let controller = ASAuthorizationController(authorizationRequests: [registrationRequest]) + controller.delegate = self + controller.presentationContextProvider = self + + return try await withCheckedThrowingContinuation { continuation in + self.registrationContinuation = continuation + controller.performRequests() + } + } + + @MainActor + private func performAuthentication( + challenge: String + ) async throws -> ASAuthorizationPlatformPublicKeyCredentialAssertion { + guard let challengeData = Data(base64URLEncoded: challenge) else { + throw PasskeyError.authenticationFailed("Invalid challenge") + } + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + + let assertionRequest = platformProvider.createCredentialAssertionRequest( + challenge: challengeData + ) + + let controller = ASAuthorizationController(authorizationRequests: [assertionRequest]) + controller.delegate = self + controller.presentationContextProvider = self + + return try await withCheckedThrowingContinuation { continuation in + self.authenticationContinuation = continuation + controller.performRequests() + } + } +} + +// MARK: - ASAuthorizationControllerDelegate + +extension PasskeyService: ASAuthorizationControllerDelegate { + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + switch authorization.credential { + case let registration as ASAuthorizationPlatformPublicKeyCredentialRegistration: + registrationContinuation?.resume(returning: registration) + registrationContinuation = nil + + case let assertion as ASAuthorizationPlatformPublicKeyCredentialAssertion: + authenticationContinuation?.resume(returning: assertion) + authenticationContinuation = nil + + default: + break + } + } + + public func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let authError = error as? ASAuthorizationError + + if authError?.code == .canceled { + registrationContinuation?.resume(throwing: PasskeyError.userCancelled) + authenticationContinuation?.resume(throwing: PasskeyError.userCancelled) + } else { + registrationContinuation?.resume(throwing: PasskeyError.registrationFailed(error.localizedDescription)) + authenticationContinuation?.resume(throwing: PasskeyError.authenticationFailed(error.localizedDescription)) + } + + registrationContinuation = nil + authenticationContinuation = nil + } +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding + +extension PasskeyService: ASAuthorizationControllerPresentationContextProviding { + @MainActor + public func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first + else { + fatalError("No window available for passkey presentation") + } + return window + } +} diff --git a/BookPlayer/Services/PhoneWatchConnectivityService.swift b/BookPlayer/Services/PhoneWatchConnectivityService.swift index 3b2e0c9f6..0a59a18cb 100644 --- a/BookPlayer/Services/PhoneWatchConnectivityService.swift +++ b/BookPlayer/Services/PhoneWatchConnectivityService.swift @@ -14,6 +14,7 @@ public class PhoneWatchConnectivityService: NSObject, WCSessionDelegate { let libraryService: LibraryServiceProtocol let playbackService: PlaybackServiceProtocol let playerManager: PlayerManagerProtocol + var accountService: AccountServiceProtocol? /// Flag to avoid calling activate more than once from outside the service var didStartSession = false /// Flag used to register observers only once @@ -33,6 +34,10 @@ public class PhoneWatchConnectivityService: NSObject, WCSessionDelegate { super.init() } + public func setAccountService(_ accountService: AccountServiceProtocol) { + self.accountService = accountService + } + func bindObservers() { NotificationCenter.default.publisher(for: .bookReady, object: nil) .sink(receiveValue: { [weak self] notification in @@ -212,10 +217,43 @@ extension PhoneWatchConnectivityService { } public func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + // Handle auth request from Watch + if let command = message["command"] as? String, command == "requestAuth" { + handleAuthRequest(replyHandler: replyHandler) + return + } + NotificationCenter.default.post(name: .messageReceived, object: nil, userInfo: message) replyHandler(["success": true]) } + private func handleAuthRequest(replyHandler: @escaping ([String: Any]) -> Void) { + guard let accountService = accountService else { + replyHandler(["error": "serviceUnavailable"]) + return + } + + guard let account = accountService.getAccount(), + !account.id.isEmpty else { + replyHandler(["error": "notSignedIn"]) + return + } + + let keychain = KeychainService() + guard let token: String = try? keychain.get(.token) else { + replyHandler(["error": "noToken"]) + return + } + + replyHandler([ + "token": token, + "email": account.email ?? "", + "accountId": account.id, + "hasSubscription": account.hasSubscription, + "donationMade": account.donationMade + ]) + } + public func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { NotificationCenter.default.post(name: .messageReceived, object: nil, userInfo: message) } diff --git a/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift b/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift index 558340c0a..3059db8e6 100644 --- a/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift +++ b/BookPlayer/Utils/Extensions/Environment+BookPlayer.swift @@ -20,6 +20,7 @@ extension EnvironmentValues { @Entry var hardcoverService: HardcoverService = .init() @Entry var loadingState: LoadingOverlayState = .init() @Entry var playerState: PlayerState = .init() + @Entry var passkeyService: PasskeyServiceProtocol = PasskeyService() } extension EnvironmentValues { diff --git a/BookPlayer/Utils/Views/PrimaryButton.swift b/BookPlayer/Utils/Views/PrimaryButton.swift new file mode 100644 index 000000000..7fc3a4602 --- /dev/null +++ b/BookPlayer/Utils/Views/PrimaryButton.swift @@ -0,0 +1,52 @@ +// +// PrimaryButton.swift +// BookPlayer +// +// Created by Gianni Carlo on 17/1/26. +// Copyright © 2026 BookPlayer LLC. All rights reserved. +// + +import BookPlayerKit +import SwiftUI + +struct PrimaryButton: View { + var text: String + var action: () -> Void + + @EnvironmentObject var theme: ThemeViewModel + + var body: some View { + Button(action: action, label: { + Text(text) + }) + .buttonStyle(PrimaryButtonStyle( + background: theme.useDarkVariant ? .white : .black, + foregroundStyle: theme.useDarkVariant ? .black : .white + )) + } +} + +struct PrimaryButtonStyle: ButtonStyle { + let background: Color + let foregroundStyle: Color + @Environment(\.isEnabled) var isEnabled + + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .bpFont(Fonts.title) + .frame(height: 48) + .frame(maxWidth: .infinity) + .background( + isEnabled + ? background + : background.opacity(0.5) + ) + .foregroundStyle( + isEnabled + ? foregroundStyle + : foregroundStyle.opacity(0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .opacity(configuration.isPressed ? 0.4 : 1.0) + } +} diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index f86111c62..39b064435 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "متابعة آخر كتاب تم تشغيله"; "watchapp_connect_error_title" = "خطأ في الاتصال"; "watchapp_connect_error_description" = "هناك مشكلة في الاتصال بهاتفك، الرجاء المحاولة مرة أخرى لاحقًا"; +"watch_signin_with_iphone" = "تسجيل الدخول باستخدام iPhone"; +"watch_signin_phone_required" = "يرجى تسجيل الدخول على iPhone أولاً، ثم حاول مرة أخرى."; +"watch_signin_failed" = "فشل تسجيل الدخول"; "sleep_remaining_title" = "%@ المتبقي حتى النوم"; "audio_source_title" = "مصدر الصوت"; "speed_title" = "السرعة"; @@ -253,6 +256,10 @@ "logout_title" = "تسجيل خروج"; "delete_account_title" = "حذف الحساب"; "account_title" = "حساب"; +"account_passkeys_title" = "مفاتيح المرور"; +"account_passkeys_description" = "تتيح لك مفاتيح المرور تسجيل الدخول بأمان باستخدام Face ID أو Touch ID بدلاً من كلمة المرور."; +"account_passkey_configured" = "تتم مزامنة مفتاح المرور الخاص بك عبر سلسلة مفاتيح iCloud ويعمل على جميع أجهزة Apple الخاصة بك."; +"account_add_passkey_title" = "إضافة مفتاح مرور"; "benefits_cloudsync_title" = "المزامنة السحابية (Beta)"; "benefits_themesicons_title" = "المظاهر والأيقونات"; "benefits_supportus_title" = "ادعمنا"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ بواسطة %@، غلاف الكتاب"; "voiceover_loading_book_cover" = "جاري تحميل غلاف الكتاب"; "integrations_title" = "التكاملات"; +"File Path" = "مسار الملف"; +"Genres" = "الأنواع"; +"Overview" = "نظرة عامة"; +"Tags" = "الوسوم"; +"All" = "الكل"; +"Quick Action 1" = "إجراء سريع 1"; +"Quick Action 2" = "إجراء سريع 2"; +"Quick Action 3" = "إجراء سريع 3"; +"database_backup_available_message" = "قاعدة البيانات تالفة ولكن يتوفر نسخة احتياطية حديثة. هل تريد الاستعادة من أحدث نسخة احتياطية؟"; +"database_restore_button" = "استعادة من النسخة الاحتياطية"; +"database_reset_button" = "إعادة تعيين قاعدة البيانات"; +"database_restore_failed_message" = "قاعدة البيانات تالفة وفشلت استعادة النسخة الاحتياطية. ستحتاج المكتبة إلى إعادة التعيين وإعادة البناء من ملفاتك الصوتية."; +"database_no_backup_message" = "قاعدة البيانات تالفة ولا تتوفر نسخة احتياطية. ستحتاج المكتبة إلى إعادة التعيين وإعادة البناء من ملفاتك الصوتية."; "settings_smartrewind_max_interval_title" = "حد التراجع الذكي"; "settings_support_discord_title" = "انضم إلى خادم Discord الخاص بنا"; + +// Passkey Authentication +"passkey_unnamed_device" = "جهاز بدون اسم"; +"passkey_delete_title" = "حذف مفتاح المرور"; +"passkey_delete_message" = "هل أنت متأكد أنك تريد حذف مفتاح المرور هذا؟ لن تتمكن من استخدامه لتسجيل الدخول بعد الآن."; +"passkey_continue_button" = "المتابعة باستخدام مفتاح المرور"; +"passkey_signin_button" = "تسجيل الدخول باستخدام مفتاح المرور"; +"apple_signin_link" = "تسجيل الدخول باستخدام Apple"; +"apple_signin_title" = "تسجيل الدخول باستخدام Apple"; +"apple_signin_subtitle" = "استخدم معرف Apple الحالي لتسجيل الدخول أو إنشاء حساب."; +"email_title" = "البريد الإلكتروني"; +"passkey_registration_title" = "إنشاء حساب"; +"auth_methods_section_title" = "طرق تسجيل الدخول"; +"passkey_created" = "تم الإنشاء"; +"auth_method_added" = "تمت الإضافة"; +"auth_method_primary" = "أساسي"; + +/* Email Verification */ +"verify_email_title" = "تحقق من بريدك الإلكتروني"; +"verify_email_subtitle" = "أدخل الرمز المكون من 6 أرقام المرسل إلى %@"; +"verify_button" = "تحقق"; +"verify_didnt_receive" = "لم تستلم الرمز؟"; +"verify_resend_button" = "إعادة إرسال الرمز"; +"verify_resend_wait" = "إعادة الإرسال خلال %d ثانية"; +"continue_title" = "متابعة"; +"passkey_creating" = "جارٍ إنشاء مفتاح المرور..."; +"passkey_email_exists_title" = "الحساب موجود"; +"passkey_email_exists_message" = "يوجد حساب بهذا البريد الإلكتروني بالفعل. يرجى تسجيل الدخول باستخدام مفتاح المرور الحالي أو معرف Apple بدلاً من ذلك."; +"passkey_signin_existing" = "تسجيل الدخول بمفتاح المرور الحالي"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index d76b6bf45..43e205182 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continua l'últim llibre reproduït"; "watchapp_connect_error_title" = "Error de connectivitat"; "watchapp_connect_error_description" = "Hi ha un problema de connexió al telèfon, torneu-ho a provar més tard"; +"watch_signin_with_iphone" = "Iniciar sessió amb iPhone"; +"watch_signin_phone_required" = "Si us plau, inicia sessió primer al teu iPhone i torna-ho a provar."; +"watch_signin_failed" = "Error d'inici de sessió"; "sleep_remaining_title" = "%@ restant fins a dormir"; "audio_source_title" = "Font d'àudio"; "speed_title" = "velocitat"; @@ -253,6 +256,10 @@ "logout_title" = "Tanca la sessió"; "delete_account_title" = "Suprimeix el compte"; "account_title" = "Compte"; +"account_passkeys_title" = "Claus d'accés"; +"account_passkeys_description" = "Les claus d'accés et permeten iniciar sessió de manera segura amb Face ID o Touch ID en lloc d'una contrasenya."; +"account_passkey_configured" = "La teva clau d'accés es sincronitza mitjançant el clauer d'iCloud i funciona a tots els teus dispositius Apple."; +"account_add_passkey_title" = "Afegir una clau d'accés"; "benefits_cloudsync_title" = "Sincronització al núvol (beta)"; "benefits_themesicons_title" = "Temes i icones"; "benefits_supportus_title" = "Doneu-nos suport"; @@ -378,5 +385,47 @@ Estem treballant dur per oferir una experiència perfecta; si és possible, pose "voiceover_book_cover" = "%@ per %@, coberta del llibre"; "voiceover_loading_book_cover" = "S'està carregant la coberta del llibre"; "integrations_title" = "Integracions"; +"File Path" = "Ruta del fitxer"; +"Genres" = "Gèneres"; +"Overview" = "Resum"; +"Tags" = "Etiquetes"; +"All" = "Tot"; +"Quick Action 1" = "Acció ràpida 1"; +"Quick Action 2" = "Acció ràpida 2"; +"Quick Action 3" = "Acció ràpida 3"; +"database_backup_available_message" = "La base de dades està malmesa però hi ha disponible una còpia de seguretat recent. Vols restaurar des de l'última còpia de seguretat?"; +"database_restore_button" = "Restaurar des de la còpia"; +"database_reset_button" = "Restablir base de dades"; +"database_restore_failed_message" = "La base de dades està malmesa i la restauració de la còpia de seguretat ha fallat. La biblioteca s'haurà de restablir i reconstruir a partir dels teus fitxers d'àudio."; +"database_no_backup_message" = "La base de dades està malmesa i no hi ha cap còpia de seguretat disponible. La biblioteca s'haurà de restablir i reconstruir a partir dels teus fitxers d'àudio."; "settings_smartrewind_max_interval_title" = "Límit de rebobinat intel·ligent"; "settings_support_discord_title" = "Uneix-te al nostre servidor de Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispositiu sense nom"; +"passkey_delete_title" = "Eliminar clau d'accés"; +"passkey_delete_message" = "Estàs segur que vols eliminar aquesta clau d'accés? Ja no la podràs utilitzar per iniciar sessió."; +"passkey_continue_button" = "Continuar amb clau d'accés"; +"passkey_signin_button" = "Iniciar sessió amb clau d'accés"; +"apple_signin_link" = "Iniciar sessió amb Apple"; +"apple_signin_title" = "Iniciar sessió amb Apple"; +"apple_signin_subtitle" = "Utilitza el teu ID d'Apple existent per iniciar sessió o crear un compte."; +"email_title" = "Correu electrònic"; +"passkey_registration_title" = "Crear compte"; +"auth_methods_section_title" = "Mètodes d'inici de sessió"; +"passkey_created" = "Creat"; +"auth_method_added" = "Afegit"; +"auth_method_primary" = "Principal"; + +/* Email Verification */ +"verify_email_title" = "Verifica el teu correu"; +"verify_email_subtitle" = "Introdueix el codi de 6 dígits enviat a %@"; +"verify_button" = "Verificar"; +"verify_didnt_receive" = "No has rebut el codi?"; +"verify_resend_button" = "Reenviar codi"; +"verify_resend_wait" = "Reenviar en %d segons"; +"continue_title" = "Continuar"; +"passkey_creating" = "Creant la teva clau d'accés..."; +"passkey_email_exists_title" = "El compte ja existeix"; +"passkey_email_exists_message" = "Ja existeix un compte amb aquest correu electrònic. Si us plau, inicia sessió amb la teva clau d'accés o ID d'Apple existent."; +"passkey_signin_existing" = "Iniciar sessió amb clau d'accés existent"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index 3262516e5..34cffbb87 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Pokračovat v naposledy přehrávané audioknize"; "watchapp_connect_error_title" = "Chyba připojení"; "watchapp_connect_error_description" = "Při připojení k telefonu došlo k potížím, zkuste to prosím později."; +"watch_signin_with_iphone" = "Přihlásit se pomocí iPhone"; +"watch_signin_phone_required" = "Nejprve se přihlaste na svém iPhonu a zkuste to znovu."; +"watch_signin_failed" = "Přihlášení selhalo"; "sleep_remaining_title" = "%@zbývá do režimu spánku"; "audio_source_title" = "Zdroj zvuku"; "speed_title" = "Rychlost"; @@ -253,6 +256,10 @@ "logout_title" = "Odhlásit se"; "delete_account_title" = "Smazat účet"; "account_title" = "Účet"; +"account_passkeys_title" = "Přístupové klíče"; +"account_passkeys_description" = "Přístupové klíče vám umožňují bezpečně se přihlásit pomocí Face ID nebo Touch ID místo hesla."; +"account_passkey_configured" = "Váš přístupový klíč je synchronizován přes iCloud Keychain a funguje na všech vašich zařízeních Apple."; +"account_add_passkey_title" = "Přidat přístupový klíč"; "benefits_cloudsync_title" = "Cloud synchronizace (Beta)"; "benefits_themesicons_title" = "Motivy a ikony"; "benefits_supportus_title" = "Podpoř nás"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ od %@, obálka knihy"; "voiceover_loading_book_cover" = "Načítá se obálka knihy"; "integrations_title" = "Integrace"; +"File Path" = "Cesta k souboru"; +"Genres" = "Žánry"; +"Overview" = "Přehled"; +"Tags" = "Štítky"; +"All" = "Vše"; +"Quick Action 1" = "Rychlá akce 1"; +"Quick Action 2" = "Rychlá akce 2"; +"Quick Action 3" = "Rychlá akce 3"; +"database_backup_available_message" = "Databáze je poškozená, ale je k dispozici nedávná záloha. Chcete obnovit z poslední zálohy?"; +"database_restore_button" = "Obnovit ze zálohy"; +"database_reset_button" = "Resetovat databázi"; +"database_restore_failed_message" = "Databáze je poškozená a obnovení ze zálohy selhalo. Knihovna bude muset být resetována a znovu vytvořena z vašich audio souborů."; +"database_no_backup_message" = "Databáze je poškozená a není k dispozici žádná záloha. Knihovna bude muset být resetována a znovu vytvořena z vašich audio souborů."; "settings_smartrewind_max_interval_title" = "Chytrý limit převíjení"; "settings_support_discord_title" = "Připojte se k našemu Discord serveru"; + +// Passkey Authentication +"passkey_unnamed_device" = "Nepojmenované zařízení"; +"passkey_delete_title" = "Smazat přístupový klíč"; +"passkey_delete_message" = "Opravdu chcete smazat tento přístupový klíč? Nebudete se s ním moci přihlásit."; +"passkey_continue_button" = "Pokračovat s přístupovým klíčem"; +"passkey_signin_button" = "Přihlásit se přístupovým klíčem"; +"apple_signin_link" = "Přihlásit se přes Apple"; +"apple_signin_title" = "Přihlásit se přes Apple"; +"apple_signin_subtitle" = "Použijte své stávající Apple ID k přihlášení nebo vytvoření účtu."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Vytvořit účet"; +"auth_methods_section_title" = "Metody přihlášení"; +"passkey_created" = "Vytvořeno"; +"auth_method_added" = "Přidáno"; +"auth_method_primary" = "Primární"; + +/* Email Verification */ +"verify_email_title" = "Ověřte svůj e-mail"; +"verify_email_subtitle" = "Zadejte 6místný kód zaslaný na %@"; +"verify_button" = "Ověřit"; +"verify_didnt_receive" = "Nedostali jste kód?"; +"verify_resend_button" = "Znovu odeslat kód"; +"verify_resend_wait" = "Znovu odeslat za %d sekund"; +"continue_title" = "Pokračovat"; +"passkey_creating" = "Vytváření přístupového klíče..."; +"passkey_email_exists_title" = "Účet existuje"; +"passkey_email_exists_message" = "Účet s tímto e-mailem již existuje. Přihlaste se prosím pomocí stávajícího přístupového klíče nebo Apple ID."; +"passkey_signin_existing" = "Přihlásit se stávajícím přístupovým klíčem"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 09e1a7152..a21b89794 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Fortsæt med seneste afspillede bog"; "watchapp_connect_error_title" = "Forbindelsesfejl"; "watchapp_connect_error_description" = "Der er et problem med at oprette forbindelse til din telefon. Prøv igen senere"; +"watch_signin_with_iphone" = "Log ind med iPhone"; +"watch_signin_phone_required" = "Log venligst ind på din iPhone først, og prøv derefter igen."; +"watch_signin_failed" = "Login mislykkedes"; "sleep_remaining_title" = "%@ tilbage indtil søvn"; "audio_source_title" = "Lydkilde"; "speed_title" = "Hastighed"; @@ -253,6 +256,10 @@ "logout_title" = "Log ud"; "delete_account_title" = "Slet konto"; "account_title" = "Konto"; +"account_passkeys_title" = "Adgangsnøgler"; +"account_passkeys_description" = "Adgangsnøgler lader dig logge sikkert ind med Face ID eller Touch ID i stedet for en adgangskode."; +"account_passkey_configured" = "Din adgangsnøgle synkroniseres via iCloud-nøglering og fungerer på alle dine Apple-enheder."; +"account_add_passkey_title" = "Tilføj en adgangsnøgle"; "benefits_cloudsync_title" = "Skysynkronisering (Beta)"; "benefits_themesicons_title" = "Temaer og ikoner"; "benefits_supportus_title" = "Støt os"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ af %@, bogomlaget"; "voiceover_loading_book_cover" = "Indlæser bogomlaget"; "integrations_title" = "Integrationer"; +"File Path" = "Filsti"; +"Genres" = "Genrer"; +"Overview" = "Oversigt"; +"Tags" = "Tags"; +"All" = "Alle"; +"Quick Action 1" = "Hurtig handling 1"; +"Quick Action 2" = "Hurtig handling 2"; +"Quick Action 3" = "Hurtig handling 3"; +"database_backup_available_message" = "Databasen er beskadiget, men en nylig sikkerhedskopi er tilgængelig. Vil du gendanne fra den seneste sikkerhedskopi?"; +"database_restore_button" = "Gendan fra sikkerhedskopi"; +"database_reset_button" = "Nulstil database"; +"database_restore_failed_message" = "Databasen er beskadiget, og gendannelsen af sikkerhedskopien mislykkedes. Biblioteket skal nulstilles og genopbygges fra dine lydfiler."; +"database_no_backup_message" = "Databasen er beskadiget, og ingen sikkerhedskopi er tilgængelig. Biblioteket skal nulstilles og genopbygges fra dine lydfiler."; "settings_smartrewind_max_interval_title" = "Smart tilbagespolingsgrænse"; "settings_support_discord_title" = "Deltag i vores Discord-server"; + +// Passkey Authentication +"passkey_unnamed_device" = "Unavngivet enhed"; +"passkey_delete_title" = "Slet adgangsnøgle"; +"passkey_delete_message" = "Er du sikker på, at du vil slette denne adgangsnøgle? Du vil ikke længere kunne bruge den til at logge ind."; +"passkey_continue_button" = "Fortsæt med adgangsnøgle"; +"passkey_signin_button" = "Log ind med adgangsnøgle"; +"apple_signin_link" = "Log ind med Apple"; +"apple_signin_title" = "Log ind med Apple"; +"apple_signin_subtitle" = "Brug dit eksisterende Apple-id til at logge ind eller oprette en konto."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Opret konto"; +"auth_methods_section_title" = "Loginmetoder"; +"passkey_created" = "Oprettet"; +"auth_method_added" = "Tilføjet"; +"auth_method_primary" = "Primær"; + +/* Email Verification */ +"verify_email_title" = "Bekræft din e-mail"; +"verify_email_subtitle" = "Indtast den 6-cifrede kode sendt til %@"; +"verify_button" = "Bekræft"; +"verify_didnt_receive" = "Modtog du ikke koden?"; +"verify_resend_button" = "Send kode igen"; +"verify_resend_wait" = "Send igen om %d sekunder"; +"continue_title" = "Fortsæt"; +"passkey_creating" = "Opretter din adgangsnøgle..."; +"passkey_email_exists_title" = "Konto findes"; +"passkey_email_exists_message" = "En konto med denne e-mail findes allerede. Log venligst ind med din eksisterende adgangsnøgle eller Apple-id i stedet."; +"passkey_signin_existing" = "Log ind med eksisterende adgangsnøgle"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 3ef86279e..085e83d3e 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Zuletzt gespieltes Buch weiter hören"; "watchapp_connect_error_title" = "Verbindungsfehler"; "watchapp_connect_error_description" = "Es gibt ein Problem mit der Verbindung zu deinem Telefon. Bitte versuche es später noch einmal."; +"watch_signin_with_iphone" = "Mit iPhone anmelden"; +"watch_signin_phone_required" = "Bitte melde dich zuerst auf deinem iPhone an und versuche es dann erneut."; +"watch_signin_failed" = "Anmeldung fehlgeschlagen"; "sleep_remaining_title" = "%@ verbleibend bis zum Ende der Wiedergabe"; "audio_source_title" = "Abspielgerät"; "speed_title" = "Wiedergabegeschwindigkeit"; @@ -253,6 +256,10 @@ "logout_title" = "Ausloggen"; "delete_account_title" = "Konto löschen"; "account_title" = "Konto"; +"account_passkeys_title" = "Passkeys"; +"account_passkeys_description" = "Mit Passkeys können Sie sich sicher mit Face ID oder Touch ID anmelden, anstatt ein Passwort zu verwenden."; +"account_passkey_configured" = "Ihr Passkey wird über den iCloud-Schlüsselbund synchronisiert und funktioniert auf allen Ihren Apple-Geräten."; +"account_add_passkey_title" = "Passkey hinzufügen"; "benefits_cloudsync_title" = "Cloud-Synchronisierung (Beta)"; "benefits_themesicons_title" = "Themen & Symbole"; "benefits_supportus_title" = "Unterstütze uns"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ von %@, Buchcover"; "voiceover_loading_book_cover" = "Buchcover wird geladen"; "integrations_title" = "Integrationen"; +"File Path" = "Dateipfad"; +"Genres" = "Genres"; +"Overview" = "Übersicht"; +"Tags" = "Tags"; +"All" = "Alle"; +"Quick Action 1" = "Schnellaktion 1"; +"Quick Action 2" = "Schnellaktion 2"; +"Quick Action 3" = "Schnellaktion 3"; +"database_backup_available_message" = "Die Datenbank ist beschädigt, aber ein aktuelles Backup ist verfügbar. Möchten Sie vom neuesten Backup wiederherstellen?"; +"database_restore_button" = "Aus Backup wiederherstellen"; +"database_reset_button" = "Datenbank zurücksetzen"; +"database_restore_failed_message" = "Die Datenbank ist beschädigt und die Wiederherstellung aus dem Backup ist fehlgeschlagen. Die Bibliothek muss zurückgesetzt und aus Ihren Audiodateien neu aufgebaut werden."; +"database_no_backup_message" = "Die Datenbank ist beschädigt und kein Backup ist verfügbar. Die Bibliothek muss zurückgesetzt und aus Ihren Audiodateien neu aufgebaut werden."; "settings_smartrewind_max_interval_title" = "Intelligentes Rückspullimit"; "settings_support_discord_title" = "Tritt unserem Discord-Server bei"; + +// Passkey Authentication +"passkey_unnamed_device" = "Unbenanntes Gerät"; +"passkey_delete_title" = "Passkey löschen"; +"passkey_delete_message" = "Bist du sicher, dass du diesen Passkey entfernen möchtest? Du kannst dich damit nicht mehr anmelden."; +"passkey_continue_button" = "Mit Passkey fortfahren"; +"passkey_signin_button" = "Mit Passkey anmelden"; +"apple_signin_link" = "Mit Apple anmelden"; +"apple_signin_title" = "Mit Apple anmelden"; +"apple_signin_subtitle" = "Verwende deine bestehende Apple-ID, um dich anzumelden oder ein Konto zu erstellen."; +"email_title" = "E-Mail"; +"passkey_registration_title" = "Konto erstellen"; +"auth_methods_section_title" = "Anmeldemethoden"; +"passkey_created" = "Erstellt"; +"auth_method_added" = "Hinzugefügt"; +"auth_method_primary" = "Primär"; + +/* Email Verification */ +"verify_email_title" = "E-Mail bestätigen"; +"verify_email_subtitle" = "Gib den 6-stelligen Code ein, der an %@ gesendet wurde"; +"verify_button" = "Bestätigen"; +"verify_didnt_receive" = "Code nicht erhalten?"; +"verify_resend_button" = "Code erneut senden"; +"verify_resend_wait" = "Erneut senden in %d Sekunden"; +"continue_title" = "Weiter"; +"passkey_creating" = "Passkey wird erstellt..."; +"passkey_email_exists_title" = "Konto existiert bereits"; +"passkey_email_exists_message" = "Ein Konto mit dieser E-Mail-Adresse existiert bereits. Bitte melde dich stattdessen mit deinem bestehenden Passkey oder deiner Apple-ID an."; +"passkey_signin_existing" = "Mit bestehendem Passkey anmelden"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 1c8ca2f6b..ce2830de2 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Συνέχεια τελευταίου βιβλίου που έπαιξε"; "watchapp_connect_error_title" = "Σφάλμα συνδεσιμότητας"; "watchapp_connect_error_description" = "Παρουσιάστηκε πρόβλημα με τη σύνδεση με το τηλέφωνό σας, δοκιμάστε ξανά αργότερα"; +"watch_signin_with_iphone" = "Σύνδεση με iPhone"; +"watch_signin_phone_required" = "Παρακαλώ συνδεθείτε πρώτα στο iPhone σας και δοκιμάστε ξανά."; +"watch_signin_failed" = "Η σύνδεση απέτυχε"; "sleep_remaining_title" = "%@ απομένει μέχρι τον ύπνο"; "audio_source_title" = "Πηγή ήχου"; "speed_title" = "Ταχύτητα"; @@ -253,6 +256,10 @@ "logout_title" = "Αποσύνδεση"; "delete_account_title" = "Διαγραφή λογαριασμού"; "account_title" = "λογαριασμός"; +"account_passkeys_title" = "Κλειδιά πρόσβασης"; +"account_passkeys_description" = "Τα κλειδιά πρόσβασης σας επιτρέπουν να συνδεθείτε με ασφάλεια με Face ID ή Touch ID αντί για κωδικό πρόσβασης."; +"account_passkey_configured" = "Το κλειδί πρόσβασής σας συγχρονίζεται μέσω του iCloud Keychain και λειτουργεί σε όλες τις συσκευές Apple σας."; +"account_add_passkey_title" = "Προσθήκη κλειδιού πρόσβασης"; "benefits_cloudsync_title" = "Cloud sync (Beta)"; "benefits_themesicons_title" = "Θέματα & εικονίδια"; "benefits_supportus_title" = "Υποστήριξε μας"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ από %@, εξώφυλλο βιβλίου"; "voiceover_loading_book_cover" = "Φόρτωση εξώφυλλου βιβλίου"; "integrations_title" = "Ενσωματώσεις"; +"File Path" = "Διαδρομή αρχείου"; +"Genres" = "Είδη"; +"Overview" = "Επισκόπηση"; +"Tags" = "Ετικέτες"; +"All" = "Όλα"; +"Quick Action 1" = "Γρήγορη ενέργεια 1"; +"Quick Action 2" = "Γρήγορη ενέργεια 2"; +"Quick Action 3" = "Γρήγορη ενέργεια 3"; +"database_backup_available_message" = "Η βάση δεδομένων είναι κατεστραμμένη αλλά υπάρχει διαθέσιμο ένα πρόσφατο αντίγραφο ασφαλείας. Θέλετε να επαναφέρετε από το τελευταίο αντίγραφο ασφαλείας;"; +"database_restore_button" = "Επαναφορά από αντίγραφο ασφαλείας"; +"database_reset_button" = "Επαναφορά βάσης δεδομένων"; +"database_restore_failed_message" = "Η βάση δεδομένων είναι κατεστραμμένη και η επαναφορά του αντιγράφου ασφαλείας απέτυχε. Η βιβλιοθήκη θα πρέπει να επαναφερθεί και να ανακατασκευαστεί από τα αρχεία ήχου σας."; +"database_no_backup_message" = "Η βάση δεδομένων είναι κατεστραμμένη και δεν υπάρχει διαθέσιμο αντίγραφο ασφαλείας. Η βιβλιοθήκη θα πρέπει να επαναφερθεί και να ανακατασκευαστεί από τα αρχεία ήχου σας."; "settings_smartrewind_max_interval_title" = "Έξυπνο όριο επαναφοράς"; "settings_support_discord_title" = "Συμμετάσχετε στον διακομιστή Discord μας"; + +// Passkey Authentication +"passkey_unnamed_device" = "Ανώνυμη συσκευή"; +"passkey_delete_title" = "Διαγραφή κλειδιού πρόσβασης"; +"passkey_delete_message" = "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το κλειδί πρόσβασης; Δεν θα μπορείτε πλέον να το χρησιμοποιήσετε για σύνδεση."; +"passkey_continue_button" = "Συνέχεια με κλειδί πρόσβασης"; +"passkey_signin_button" = "Σύνδεση με κλειδί πρόσβασης"; +"apple_signin_link" = "Σύνδεση με Apple"; +"apple_signin_title" = "Σύνδεση με Apple"; +"apple_signin_subtitle" = "Χρησιμοποιήστε το υπάρχον Apple ID σας για σύνδεση ή δημιουργία λογαριασμού."; +"email_title" = "Email"; +"passkey_registration_title" = "Δημιουργία λογαριασμού"; +"auth_methods_section_title" = "Μέθοδοι σύνδεσης"; +"passkey_created" = "Δημιουργήθηκε"; +"auth_method_added" = "Προστέθηκε"; +"auth_method_primary" = "Κύρια"; + +/* Email Verification */ +"verify_email_title" = "Επαλήθευση email"; +"verify_email_subtitle" = "Εισάγετε τον 6ψήφιο κωδικό που στάλθηκε στο %@"; +"verify_button" = "Επαλήθευση"; +"verify_didnt_receive" = "Δεν λάβατε τον κωδικό;"; +"verify_resend_button" = "Επαναποστολή κωδικού"; +"verify_resend_wait" = "Επαναποστολή σε %d δευτερόλεπτα"; +"continue_title" = "Συνέχεια"; +"passkey_creating" = "Δημιουργία κλειδιού πρόσβασης..."; +"passkey_email_exists_title" = "Ο λογαριασμός υπάρχει"; +"passkey_email_exists_message" = "Υπάρχει ήδη λογαριασμός με αυτό το email. Παρακαλώ συνδεθείτε με το υπάρχον κλειδί πρόσβασης ή Apple ID σας."; +"passkey_signin_existing" = "Σύνδεση με υπάρχον κλειδί πρόσβασης"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 6596b0bca..81aff5caf 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continue last played book"; "watchapp_connect_error_title" = "Connectivity Error"; "watchapp_connect_error_description" = "There's a problem connecting to your phone, please try again later"; +"watch_signin_with_iphone" = "Sign in with iPhone"; +"watch_signin_phone_required" = "Please sign in on your iPhone first, then try again."; +"watch_signin_failed" = "Sign in failed"; "sleep_remaining_title" = "%@ remaining until sleep"; "audio_source_title" = "Audio Source"; "speed_title" = "speed"; @@ -253,6 +256,13 @@ "logout_title" = "Log out"; "delete_account_title" = "Delete Account"; "account_title" = "Account"; +"account_passkeys_title" = "Passkeys"; +"account_passkeys_description" = "Passkeys let you sign in securely with Face ID or Touch ID instead of a password."; +"account_passkey_configured" = "Your passkey is synced via iCloud Keychain and works across all your Apple devices."; +"account_add_passkey_title" = "Add a Passkey"; +"passkey_unnamed_device" = "Unnamed Device"; +"passkey_delete_title" = "Delete Passkey"; +"passkey_delete_message" = "Are you sure you want to remove this passkey? You won't be able to sign in with it anymore."; "benefits_cloudsync_title" = "Cloud sync (Beta)"; "benefits_themesicons_title" = "Themes & Icons"; "benefits_supportus_title" = "Support us"; @@ -393,3 +403,29 @@ We're working hard on providing a seamless experience, if possible, please conta "database_no_backup_message" = "The database is corrupted and no backup is available. The library will need to be reset and rebuilt from your audio files."; "settings_smartrewind_max_interval_title" = "Smart Rewind Limit"; "settings_support_discord_title" = "Join our Discord server"; + +// Passkey Authentication +"passkey_continue_button" = "Continue with Passkey"; +"passkey_signin_button" = "Sign in with Passkey"; +"apple_signin_link" = "Sign in with Apple"; +"apple_signin_title" = "Sign in with Apple"; +"apple_signin_subtitle" = "Use your existing Apple ID to sign in or create an account."; +"email_title" = "Email"; +"passkey_registration_title" = "Create Account"; +"auth_methods_section_title" = "Sign-in Methods"; +"passkey_created" = "Created"; +"auth_method_added" = "Added"; +"auth_method_primary" = "Primary"; + +/* Email Verification */ +"verify_email_title" = "Verify Your Email"; +"verify_email_subtitle" = "Enter the 6-digit code sent to %@"; +"verify_button" = "Verify"; +"verify_didnt_receive" = "Didn't receive the code?"; +"verify_resend_button" = "Resend Code"; +"verify_resend_wait" = "Resend in %d seconds"; +"continue_title" = "Continue"; +"passkey_creating" = "Creating your passkey..."; +"passkey_email_exists_title" = "Account Exists"; +"passkey_email_exists_message" = "An account with this email already exists. Please sign in with your existing passkey or Apple ID instead."; +"passkey_signin_existing" = "Sign in with existing passkey"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 0ef59794c..82858a6f3 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continuar con el último libro"; "watchapp_connect_error_title" = "Error de conectividad"; "watchapp_connect_error_description" = "Hay un problema de conexión con el teléfono, por favor, inténtelo de nuevo más tarde."; +"watch_signin_with_iphone" = "Iniciar sesión con iPhone"; +"watch_signin_phone_required" = "Por favor, inicia sesión primero en tu iPhone e inténtalo de nuevo."; +"watch_signin_failed" = "Error de inicio de sesión"; "sleep_remaining_title" = "%@ restante hasta pausar"; "audio_source_title" = "Fuente de audio"; "speed_title" = "velocidad"; @@ -253,6 +256,10 @@ "logout_title" = "Cerrar sesión"; "delete_account_title" = "Borrar cuenta"; "account_title" = "Cuenta"; +"account_passkeys_title" = "Llaves de acceso"; +"account_passkeys_description" = "Las llaves de acceso te permiten iniciar sesión de forma segura con Face ID o Touch ID en lugar de una contraseña."; +"account_passkey_configured" = "Tu llave de acceso se sincroniza a través del llavero de iCloud y funciona en todos tus dispositivos Apple."; +"account_add_passkey_title" = "Añadir una llave de acceso"; "benefits_cloudsync_title" = "Sincronización en la nube (Beta)"; "benefits_themesicons_title" = "Temas e iconos"; "benefits_supportus_title" = "Apóyanos"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ de %@, portada del libro"; "voiceover_loading_book_cover" = "Cargando portada del libro"; "integrations_title" = "Integraciones"; +"File Path" = "Ruta del archivo"; +"Genres" = "Géneros"; +"Overview" = "Resumen"; +"Tags" = "Etiquetas"; +"All" = "Todo"; +"Quick Action 1" = "Acción rápida 1"; +"Quick Action 2" = "Acción rápida 2"; +"Quick Action 3" = "Acción rápida 3"; +"database_backup_available_message" = "La base de datos está dañada pero hay una copia de seguridad reciente disponible. ¿Desea restaurar desde la última copia de seguridad?"; +"database_restore_button" = "Restaurar desde copia"; +"database_reset_button" = "Restablecer base de datos"; +"database_restore_failed_message" = "La base de datos está dañada y la restauración de la copia de seguridad falló. La biblioteca deberá restablecerse y reconstruirse a partir de sus archivos de audio."; +"database_no_backup_message" = "La base de datos está dañada y no hay copia de seguridad disponible. La biblioteca deberá restablecerse y reconstruirse a partir de sus archivos de audio."; "settings_smartrewind_max_interval_title" = "Límite de rebobinado inteligente"; "settings_support_discord_title" = "Únete a nuestro servidor de Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispositivo sin nombre"; +"passkey_delete_title" = "Eliminar llave de acceso"; +"passkey_delete_message" = "¿Estás seguro de que quieres eliminar esta llave de acceso? Ya no podrás iniciar sesión con ella."; +"passkey_continue_button" = "Continuar con llave de acceso"; +"passkey_signin_button" = "Iniciar sesión con llave de acceso"; +"apple_signin_link" = "Iniciar sesión con Apple"; +"apple_signin_title" = "Iniciar sesión con Apple"; +"apple_signin_subtitle" = "Usa tu Apple ID existente para iniciar sesión o crear una cuenta."; +"email_title" = "Correo electrónico"; +"passkey_registration_title" = "Crear cuenta"; +"auth_methods_section_title" = "Métodos de inicio de sesión"; +"passkey_created" = "Creado"; +"auth_method_added" = "Añadido"; +"auth_method_primary" = "Principal"; + +/* Email Verification */ +"verify_email_title" = "Verifica tu correo"; +"verify_email_subtitle" = "Ingresa el código de 6 dígitos enviado a %@"; +"verify_button" = "Verificar"; +"verify_didnt_receive" = "¿No recibiste el código?"; +"verify_resend_button" = "Reenviar código"; +"verify_resend_wait" = "Reenviar en %d segundos"; +"continue_title" = "Continuar"; +"passkey_creating" = "Creando tu llave de acceso..."; +"passkey_email_exists_title" = "La cuenta ya existe"; +"passkey_email_exists_message" = "Ya existe una cuenta con este correo electrónico. Por favor, inicia sesión con tu llave de acceso o Apple ID existente."; +"passkey_signin_existing" = "Iniciar sesión con llave existente"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 721d4869d..59d755b18 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Jatka viimeksi toistettua kirjaa"; "watchapp_connect_error_title" = "Yhteysvirhe"; "watchapp_connect_error_description" = "Ongelma yhteyden muodostamisessa puhelimeesi. Yritä myöhemmin uudelleen"; +"watch_signin_with_iphone" = "Kirjaudu iPhonella"; +"watch_signin_phone_required" = "Kirjaudu ensin iPhonellasi ja yritä uudelleen."; +"watch_signin_failed" = "Kirjautuminen epäonnistui"; "sleep_remaining_title" = "uniajastinta jäljellä %@"; "audio_source_title" = "Äänilähde"; "speed_title" = "nopeus"; @@ -253,6 +256,10 @@ "logout_title" = "Kirjautua ulos"; "delete_account_title" = "Poista tili"; "account_title" = "Tili"; +"account_passkeys_title" = "Tunnusavaimet"; +"account_passkeys_description" = "Tunnusavaimilla voit kirjautua sisään turvallisesti Face ID:llä tai Touch ID:llä salasanan sijaan."; +"account_passkey_configured" = "Tunnusavaimesi synkronoidaan iCloud-avainnipun kautta ja toimii kaikissa Apple-laitteissasi."; +"account_add_passkey_title" = "Lisää tunnusavain"; "benefits_cloudsync_title" = "Pilven synkronointi (Beta)"; "benefits_themesicons_title" = "Teemat ja kuvakkeet"; "benefits_supportus_title" = "Tue meitä"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ kirjoittaja %@, kirjan kansi"; "voiceover_loading_book_cover" = "Ladataan kirjan kantta"; "integrations_title" = "Integraatiot"; +"File Path" = "Tiedostopolku"; +"Genres" = "Genret"; +"Overview" = "Yleiskatsaus"; +"Tags" = "Tunnisteet"; +"All" = "Kaikki"; +"Quick Action 1" = "Pikatoiminto 1"; +"Quick Action 2" = "Pikatoiminto 2"; +"Quick Action 3" = "Pikatoiminto 3"; +"database_backup_available_message" = "Tietokanta on vioittunut, mutta tuore varmuuskopio on saatavilla. Haluatko palauttaa viimeisimmästä varmuuskopiosta?"; +"database_restore_button" = "Palauta varmuuskopiosta"; +"database_reset_button" = "Nollaa tietokanta"; +"database_restore_failed_message" = "Tietokanta on vioittunut ja varmuuskopion palautus epäonnistui. Kirjasto on nollattava ja rakennettava uudelleen äänitiedostoistasi."; +"database_no_backup_message" = "Tietokanta on vioittunut eikä varmuuskopiota ole saatavilla. Kirjasto on nollattava ja rakennettava uudelleen äänitiedostoistasi."; "settings_smartrewind_max_interval_title" = "Älykäs kelausrajoitus"; "settings_support_discord_title" = "Liity Discord-palvelimellemme"; + +// Passkey Authentication +"passkey_unnamed_device" = "Nimetön laite"; +"passkey_delete_title" = "Poista pääsyavain"; +"passkey_delete_message" = "Haluatko varmasti poistaa tämän pääsyavaimen? Et voi enää käyttää sitä kirjautumiseen."; +"passkey_continue_button" = "Jatka pääsyavaimella"; +"passkey_signin_button" = "Kirjaudu pääsyavaimella"; +"apple_signin_link" = "Kirjaudu Applella"; +"apple_signin_title" = "Kirjaudu Applella"; +"apple_signin_subtitle" = "Käytä olemassa olevaa Apple ID:täsi kirjautumiseen tai tilin luomiseen."; +"email_title" = "Sähköposti"; +"passkey_registration_title" = "Luo tili"; +"auth_methods_section_title" = "Kirjautumistavat"; +"passkey_created" = "Luotu"; +"auth_method_added" = "Lisätty"; +"auth_method_primary" = "Ensisijainen"; + +/* Email Verification */ +"verify_email_title" = "Vahvista sähköpostisi"; +"verify_email_subtitle" = "Syötä 6-numeroinen koodi, joka lähetettiin osoitteeseen %@"; +"verify_button" = "Vahvista"; +"verify_didnt_receive" = "Etkö saanut koodia?"; +"verify_resend_button" = "Lähetä koodi uudelleen"; +"verify_resend_wait" = "Lähetä uudelleen %d sekunnin kuluttua"; +"continue_title" = "Jatka"; +"passkey_creating" = "Luodaan pääsyavainta..."; +"passkey_email_exists_title" = "Tili on olemassa"; +"passkey_email_exists_message" = "Tili tällä sähköpostilla on jo olemassa. Kirjaudu sisään olemassa olevalla pääsyavaimellasi tai Apple ID:lläsi."; +"passkey_signin_existing" = "Kirjaudu olemassa olevalla pääsyavaimella"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 30eef85b8..6277d791a 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continuer le dernier livre lu"; "watchapp_connect_error_title" = "Erreur de connexion"; "watchapp_connect_error_description" = "Il y a un problème de connexion à votre téléphone, veuillez réessayer plus tard"; +"watch_signin_with_iphone" = "Se connecter avec l'iPhone"; +"watch_signin_phone_required" = "Veuillez d'abord vous connecter sur votre iPhone, puis réessayez."; +"watch_signin_failed" = "Échec de la connexion"; "sleep_remaining_title" = "%@ restant jusqu'à la mise en veille"; "audio_source_title" = "Source audio"; "speed_title" = "vitesse"; @@ -253,6 +256,10 @@ "logout_title" = "Se déconnecter"; "delete_account_title" = "Supprimer le compte"; "account_title" = "Compte"; +"account_passkeys_title" = "Clés d'accès"; +"account_passkeys_description" = "Les clés d'accès vous permettent de vous connecter en toute sécurité avec Face ID ou Touch ID au lieu d'un mot de passe."; +"account_passkey_configured" = "Votre clé d'accès est synchronisée via le trousseau iCloud et fonctionne sur tous vos appareils Apple."; +"account_add_passkey_title" = "Ajouter une clé d'accès"; "benefits_cloudsync_title" = "Synchronisation cloud (Beta)"; "benefits_themesicons_title" = "Thèmes et icônes"; "benefits_supportus_title" = "Soutenez-nous"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ par %@, couverture de livre"; "voiceover_loading_book_cover" = "Chargement de la couverture"; "integrations_title" = "Intégrations"; +"File Path" = "Chemin du fichier"; +"Genres" = "Genres"; +"Overview" = "Aperçu"; +"Tags" = "Tags"; +"All" = "Tout"; +"Quick Action 1" = "Action rapide 1"; +"Quick Action 2" = "Action rapide 2"; +"Quick Action 3" = "Action rapide 3"; +"database_backup_available_message" = "La base de données est corrompue mais une sauvegarde récente est disponible. Voulez-vous restaurer à partir de la dernière sauvegarde ?"; +"database_restore_button" = "Restaurer depuis la sauvegarde"; +"database_reset_button" = "Réinitialiser la base de données"; +"database_restore_failed_message" = "La base de données est corrompue et la restauration de la sauvegarde a échoué. La bibliothèque devra être réinitialisée et reconstruite à partir de vos fichiers audio."; +"database_no_backup_message" = "La base de données est corrompue et aucune sauvegarde n'est disponible. La bibliothèque devra être réinitialisée et reconstruite à partir de vos fichiers audio."; "settings_smartrewind_max_interval_title" = "Limite de rembobinage intelligente"; "settings_support_discord_title" = "Rejoignez notre serveur Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Appareil sans nom"; +"passkey_delete_title" = "Supprimer la clé d'accès"; +"passkey_delete_message" = "Êtes-vous sûr de vouloir supprimer cette clé d'accès ? Vous ne pourrez plus vous connecter avec."; +"passkey_continue_button" = "Continuer avec une clé d'accès"; +"passkey_signin_button" = "Se connecter avec une clé d'accès"; +"apple_signin_link" = "Se connecter avec Apple"; +"apple_signin_title" = "Se connecter avec Apple"; +"apple_signin_subtitle" = "Utilisez votre identifiant Apple existant pour vous connecter ou créer un compte."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Créer un compte"; +"auth_methods_section_title" = "Méthodes de connexion"; +"passkey_created" = "Créée"; +"auth_method_added" = "Ajoutée"; +"auth_method_primary" = "Principale"; + +/* Email Verification */ +"verify_email_title" = "Vérifiez votre e-mail"; +"verify_email_subtitle" = "Entrez le code à 6 chiffres envoyé à %@"; +"verify_button" = "Vérifier"; +"verify_didnt_receive" = "Vous n'avez pas reçu le code ?"; +"verify_resend_button" = "Renvoyer le code"; +"verify_resend_wait" = "Renvoyer dans %d secondes"; +"continue_title" = "Continuer"; +"passkey_creating" = "Création de votre clé d'accès..."; +"passkey_email_exists_title" = "Le compte existe déjà"; +"passkey_email_exists_message" = "Un compte avec cet e-mail existe déjà. Veuillez vous connecter avec votre clé d'accès ou identifiant Apple existant."; +"passkey_signin_existing" = "Se connecter avec une clé existante"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 84ca45c91..5480558ee 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Folytassa a legutóbb lejátszott könyvet"; "watchapp_connect_error_title" = "Kapcsolódási hiba"; "watchapp_connect_error_description" = "Probléma a telefonhoz való csatlakozáskor, próbálkozzon később ismételten"; +"watch_signin_with_iphone" = "Bejelentkezés iPhone-nal"; +"watch_signin_phone_required" = "Kérjük, először jelentkezz be az iPhone-odon, majd próbáld újra."; +"watch_signin_failed" = "Bejelentkezés sikertelen"; "sleep_remaining_title" = "Alvó állapotig %@"; "audio_source_title" = "Hangforrás"; "speed_title" = "sebesség"; @@ -253,6 +256,10 @@ "logout_title" = "Kijelentkezés"; "delete_account_title" = "Fiók törlése"; "account_title" = "Fiók"; +"account_passkeys_title" = "Jelkulcsok"; +"account_passkeys_description" = "A jelkulcsok lehetővé teszik a biztonságos bejelentkezést Face ID-vel vagy Touch ID-vel jelszó helyett."; +"account_passkey_configured" = "A jelkulcsod szinkronizálva van az iCloud Kulcskarikán keresztül, és működik az összes Apple eszközödön."; +"account_add_passkey_title" = "Jelkulcs hozzáadása"; "benefits_cloudsync_title" = "Felhő szinkronizálás (Beta)"; "benefits_themesicons_title" = "Témák és ikonok"; "benefits_supportus_title" = "Támogass minket"; @@ -379,8 +386,46 @@ "voiceover_loading_book_cover" = "Könyv borító betöltése"; "integrations_title" = "Integrációk"; "File Path" = "Fájl útvonal"; -"Genres" = "Stílusok"; +"Genres" = "Műfajok"; "Overview" = "Áttekintés"; -"Tags" = "Cimkék"; +"Tags" = "Címkék"; +"All" = "Mind"; +"Quick Action 1" = "Gyors művelet 1"; +"Quick Action 2" = "Gyors művelet 2"; +"Quick Action 3" = "Gyors művelet 3"; +"database_backup_available_message" = "Az adatbázis sérült, de elérhető egy friss biztonsági mentés. Szeretné visszaállítani a legutóbbi biztonsági mentésből?"; +"database_restore_button" = "Visszaállítás biztonsági mentésből"; +"database_reset_button" = "Adatbázis visszaállítása"; +"database_restore_failed_message" = "Az adatbázis sérült, és a biztonsági mentés visszaállítása sikertelen volt. A könyvtárat alaphelyzetbe kell állítani és újra kell építeni a hangfájlokból."; +"database_no_backup_message" = "Az adatbázis sérült, és nincs elérhető biztonsági mentés. A könyvtárat alaphelyzetbe kell állítani és újra kell építeni a hangfájlokból."; "settings_smartrewind_max_interval_title" = "Intelligens visszatekerés korlátozása"; "settings_support_discord_title" = "Csatlakozz a Discord szerverünkhöz"; + +// Passkey Authentication +"passkey_unnamed_device" = "Névtelen eszköz"; +"passkey_delete_title" = "Jelszókulcs törlése"; +"passkey_delete_message" = "Biztosan törölni szeretnéd ezt a jelszókulcsot? Többé nem tudod használni a bejelentkezéshez."; +"passkey_continue_button" = "Folytatás jelszókulccsal"; +"passkey_signin_button" = "Bejelentkezés jelszókulccsal"; +"apple_signin_link" = "Bejelentkezés Apple-lel"; +"apple_signin_title" = "Bejelentkezés Apple-lel"; +"apple_signin_subtitle" = "Használd meglévő Apple ID-dat a bejelentkezéshez vagy fiók létrehozásához."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Fiók létrehozása"; +"auth_methods_section_title" = "Bejelentkezési módok"; +"passkey_created" = "Létrehozva"; +"auth_method_added" = "Hozzáadva"; +"auth_method_primary" = "Elsődleges"; + +/* Email Verification */ +"verify_email_title" = "E-mail megerősítése"; +"verify_email_subtitle" = "Add meg a(z) %@ címre küldött 6 számjegyű kódot"; +"verify_button" = "Megerősítés"; +"verify_didnt_receive" = "Nem kaptad meg a kódot?"; +"verify_resend_button" = "Kód újraküldése"; +"verify_resend_wait" = "Újraküldés %d másodperc múlva"; +"continue_title" = "Folytatás"; +"passkey_creating" = "Jelszókulcs létrehozása..."; +"passkey_email_exists_title" = "A fiók létezik"; +"passkey_email_exists_message" = "Ezzel az e-mail címmel már létezik fiók. Kérjük, jelentkezz be a meglévő jelszókulcsoddal vagy Apple ID-ddal."; +"passkey_signin_existing" = "Bejelentkezés meglévő jelszókulccsal"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index cb5d95361..546166f5c 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Riprendi l'ultimo libro riprodotto"; "watchapp_connect_error_title" = "Errore di connettività"; "watchapp_connect_error_description" = "Si è verificato un problema durante la connessione con il telefono, riprova più tardi"; +"watch_signin_with_iphone" = "Accedi con iPhone"; +"watch_signin_phone_required" = "Accedi prima sul tuo iPhone, poi riprova."; +"watch_signin_failed" = "Accesso non riuscito"; "sleep_remaining_title" = "%@ rimanente per lo spegnimento"; "audio_source_title" = "Sorgente Audio"; "speed_title" = "velocità"; @@ -253,6 +256,10 @@ "logout_title" = "Disconnettersi"; "delete_account_title" = "Eliminare l'account"; "account_title" = "Account"; +"account_passkeys_title" = "Passkey"; +"account_passkeys_description" = "Le passkey ti permettono di accedere in modo sicuro con Face ID o Touch ID invece di una password."; +"account_passkey_configured" = "La tua passkey è sincronizzata tramite Portachiavi iCloud e funziona su tutti i tuoi dispositivi Apple."; +"account_add_passkey_title" = "Aggiungi una passkey"; "benefits_cloudsync_title" = "Sincronizzazione cloud (Beta)"; "benefits_themesicons_title" = "Temi e icone"; "benefits_supportus_title" = "Supportaci"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ di %@, copertina del libro"; "voiceover_loading_book_cover" = "Caricamento copertina del libro"; "integrations_title" = "Integrazioni"; +"File Path" = "Percorso file"; +"Genres" = "Generi"; +"Overview" = "Panoramica"; +"Tags" = "Tag"; +"All" = "Tutto"; +"Quick Action 1" = "Azione rapida 1"; +"Quick Action 2" = "Azione rapida 2"; +"Quick Action 3" = "Azione rapida 3"; +"database_backup_available_message" = "Il database è danneggiato ma è disponibile un backup recente. Vuoi ripristinare dall'ultimo backup?"; +"database_restore_button" = "Ripristina da backup"; +"database_reset_button" = "Reimposta database"; +"database_restore_failed_message" = "Il database è danneggiato e il ripristino del backup non è riuscito. La libreria dovrà essere reimpostata e ricostruita dai tuoi file audio."; +"database_no_backup_message" = "Il database è danneggiato e nessun backup è disponibile. La libreria dovrà essere reimpostata e ricostruita dai tuoi file audio."; "settings_smartrewind_max_interval_title" = "Limite di riavvolgimento intelligente"; "settings_support_discord_title" = "Unisciti al nostro server Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispositivo senza nome"; +"passkey_delete_title" = "Elimina passkey"; +"passkey_delete_message" = "Sei sicuro di voler rimuovere questa passkey? Non potrai più accedere con essa."; +"passkey_continue_button" = "Continua con passkey"; +"passkey_signin_button" = "Accedi con passkey"; +"apple_signin_link" = "Accedi con Apple"; +"apple_signin_title" = "Accedi con Apple"; +"apple_signin_subtitle" = "Usa il tuo ID Apple esistente per accedere o creare un account."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Crea account"; +"auth_methods_section_title" = "Metodi di accesso"; +"passkey_created" = "Creata"; +"auth_method_added" = "Aggiunto"; +"auth_method_primary" = "Principale"; + +/* Email Verification */ +"verify_email_title" = "Verifica la tua e-mail"; +"verify_email_subtitle" = "Inserisci il codice a 6 cifre inviato a %@"; +"verify_button" = "Verifica"; +"verify_didnt_receive" = "Non hai ricevuto il codice?"; +"verify_resend_button" = "Invia di nuovo"; +"verify_resend_wait" = "Invia di nuovo tra %d secondi"; +"continue_title" = "Continua"; +"passkey_creating" = "Creazione della passkey..."; +"passkey_email_exists_title" = "Account esistente"; +"passkey_email_exists_message" = "Esiste già un account con questa e-mail. Accedi con la tua passkey o ID Apple esistente."; +"passkey_signin_existing" = "Accedi con passkey esistente"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index 7019f2b64..82eb75254 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "前回再生していたブックを続きから再生"; "watchapp_connect_error_title" = "接続エラー"; "watchapp_connect_error_description" = "iPhoneとの接続に問題が発生しました。あとでやり直してください。"; +"watch_signin_with_iphone" = "iPhoneでサインイン"; +"watch_signin_phone_required" = "まずiPhoneでサインインしてから、もう一度お試しください。"; +"watch_signin_failed" = "サインインに失敗しました"; "sleep_remaining_title" = "あと%@でスリープ"; "audio_source_title" = "オーディオソース"; "speed_title" = "再生速度"; @@ -253,6 +256,10 @@ "logout_title" = "ログアウト"; "delete_account_title" = "アカウントを削除"; "account_title" = "アカウント"; +"account_passkeys_title" = "パスキー"; +"account_passkeys_description" = "パスキーを使用すると、パスワードの代わりにFace IDまたはTouch IDで安全にサインインできます。"; +"account_passkey_configured" = "パスキーはiCloudキーチェーンを通じて同期され、すべてのAppleデバイスで使用できます。"; +"account_add_passkey_title" = "パスキーを追加"; "benefits_cloudsync_title" = "クラウド同期(ベータ版)"; "benefits_themesicons_title" = "テーマとアイコン"; "benefits_supportus_title" = "応援する"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ 著者%@、本の表紙"; "voiceover_loading_book_cover" = "本の表紙を読み込み中"; "integrations_title" = "統合"; +"File Path" = "ファイルパス"; +"Genres" = "ジャンル"; +"Overview" = "概要"; +"Tags" = "タグ"; +"All" = "すべて"; +"Quick Action 1" = "クイックアクション 1"; +"Quick Action 2" = "クイックアクション 2"; +"Quick Action 3" = "クイックアクション 3"; +"database_backup_available_message" = "データベースが破損していますが、最近のバックアップが利用可能です。最新のバックアップから復元しますか?"; +"database_restore_button" = "バックアップから復元"; +"database_reset_button" = "データベースをリセット"; +"database_restore_failed_message" = "データベースが破損しており、バックアップの復元に失敗しました。ライブラリをリセットしてオーディオファイルから再構築する必要があります。"; +"database_no_backup_message" = "データベースが破損しており、バックアップがありません。ライブラリをリセットしてオーディオファイルから再構築する必要があります。"; "settings_smartrewind_max_interval_title" = "スマート巻き戻し制限"; "settings_support_discord_title" = "Discordサーバーに参加する"; + +// Passkey Authentication +"passkey_unnamed_device" = "名前のないデバイス"; +"passkey_delete_title" = "パスキーを削除"; +"passkey_delete_message" = "このパスキーを削除してもよろしいですか?削除すると、このパスキーでサインインできなくなります。"; +"passkey_continue_button" = "パスキーで続ける"; +"passkey_signin_button" = "パスキーでサインイン"; +"apple_signin_link" = "Appleでサインイン"; +"apple_signin_title" = "Appleでサインイン"; +"apple_signin_subtitle" = "既存のApple IDでサインインまたはアカウントを作成します。"; +"email_title" = "メールアドレス"; +"passkey_registration_title" = "アカウントを作成"; +"auth_methods_section_title" = "サインイン方法"; +"passkey_created" = "作成済み"; +"auth_method_added" = "追加済み"; +"auth_method_primary" = "メイン"; + +/* Email Verification */ +"verify_email_title" = "メールアドレスを確認"; +"verify_email_subtitle" = "%@に送信された6桁のコードを入力してください"; +"verify_button" = "確認"; +"verify_didnt_receive" = "コードが届きませんか?"; +"verify_resend_button" = "コードを再送信"; +"verify_resend_wait" = "%d秒後に再送信"; +"continue_title" = "続ける"; +"passkey_creating" = "パスキーを作成中..."; +"passkey_email_exists_title" = "アカウントが存在します"; +"passkey_email_exists_message" = "このメールアドレスのアカウントは既に存在します。既存のパスキーまたはApple IDでサインインしてください。"; +"passkey_signin_existing" = "既存のパスキーでサインイン"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index a7effac8a..6e96aed4d 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Fortsett sist spilte bok"; "watchapp_connect_error_title" = "Tilkoblingsfeil"; "watchapp_connect_error_description" = "Det er et problem med å koble til telefonen din, prøv igjen senere"; +"watch_signin_with_iphone" = "Logg inn med iPhone"; +"watch_signin_phone_required" = "Vennligst logg inn på din iPhone først, og prøv deretter igjen."; +"watch_signin_failed" = "Innlogging mislyktes"; "sleep_remaining_title" = "%@ gjenstår av søvntimer"; "audio_source_title" = "Lydkilde"; "speed_title" = "hastighet"; @@ -253,6 +256,10 @@ "logout_title" = "Logg ut"; "delete_account_title" = "Slett konto"; "account_title" = "Konto"; +"account_passkeys_title" = "Passnøkler"; +"account_passkeys_description" = "Passnøkler lar deg logge inn sikkert med Face ID eller Touch ID i stedet for et passord."; +"account_passkey_configured" = "Passnøkkelen din synkroniseres via iCloud-nøkkelring og fungerer på alle Apple-enhetene dine."; +"account_add_passkey_title" = "Legg til en passnøkkel"; "benefits_cloudsync_title" = "Skysynkronisering (beta)"; "benefits_themesicons_title" = "Temaer og ikoner"; "benefits_supportus_title" = "Støtt oss"; @@ -378,5 +385,47 @@ Vi jobber hardt for å gi deg en sømløs opplevelse. Hvis mulig, kontakt oss p "voiceover_book_cover" = "%@ av %@, bokomslag"; "voiceover_loading_book_cover" = "Laster bokomslag"; "integrations_title" = "Integrasjoner"; +"File Path" = "Filsti"; +"Genres" = "Sjangre"; +"Overview" = "Oversikt"; +"Tags" = "Tagger"; +"All" = "Alle"; +"Quick Action 1" = "Hurtighandling 1"; +"Quick Action 2" = "Hurtighandling 2"; +"Quick Action 3" = "Hurtighandling 3"; +"database_backup_available_message" = "Databasen er skadet, men en nylig sikkerhetskopi er tilgjengelig. Vil du gjenopprette fra siste sikkerhetskopi?"; +"database_restore_button" = "Gjenopprett fra sikkerhetskopi"; +"database_reset_button" = "Tilbakestill database"; +"database_restore_failed_message" = "Databasen er skadet og gjenoppretting fra sikkerhetskopi mislyktes. Biblioteket må tilbakestilles og gjenoppbygges fra lydfilene dine."; +"database_no_backup_message" = "Databasen er skadet og ingen sikkerhetskopi er tilgjengelig. Biblioteket må tilbakestilles og gjenoppbygges fra lydfilene dine."; "settings_smartrewind_max_interval_title" = "Smart tilbakespolingsgrense"; "settings_support_discord_title" = "Bli med på vår Discord-server"; + +// Passkey Authentication +"passkey_unnamed_device" = "Enhet uten navn"; +"passkey_delete_title" = "Slett passordnøkkel"; +"passkey_delete_message" = "Er du sikker på at du vil slette denne passordnøkkelen? Du vil ikke lenger kunne bruke den til å logge inn."; +"passkey_continue_button" = "Fortsett med passordnøkkel"; +"passkey_signin_button" = "Logg inn med passordnøkkel"; +"apple_signin_link" = "Logg inn med Apple"; +"apple_signin_title" = "Logg inn med Apple"; +"apple_signin_subtitle" = "Bruk din eksisterende Apple-ID for å logge inn eller opprette en konto."; +"email_title" = "E-post"; +"passkey_registration_title" = "Opprett konto"; +"auth_methods_section_title" = "Innloggingsmetoder"; +"passkey_created" = "Opprettet"; +"auth_method_added" = "Lagt til"; +"auth_method_primary" = "Primær"; + +/* Email Verification */ +"verify_email_title" = "Bekreft e-posten din"; +"verify_email_subtitle" = "Skriv inn den 6-sifrede koden sendt til %@"; +"verify_button" = "Bekreft"; +"verify_didnt_receive" = "Mottok du ikke koden?"; +"verify_resend_button" = "Send kode på nytt"; +"verify_resend_wait" = "Send på nytt om %d sekunder"; +"continue_title" = "Fortsett"; +"passkey_creating" = "Oppretter passordnøkkelen din..."; +"passkey_email_exists_title" = "Konto finnes"; +"passkey_email_exists_message" = "En konto med denne e-posten finnes allerede. Vennligst logg inn med din eksisterende passordnøkkel eller Apple-ID i stedet."; +"passkey_signin_existing" = "Logg inn med eksisterende passordnøkkel"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index 01d294d63..eab542be4 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Doorgaan met laatst afgespeelde boek"; "watchapp_connect_error_title" = "Verbindingsfout"; "watchapp_connect_error_description" = "Er is een probleem met het verbinden met je telefoon, probeer het later opnieuw"; +"watch_signin_with_iphone" = "Log in met iPhone"; +"watch_signin_phone_required" = "Log eerst in op je iPhone en probeer het opnieuw."; +"watch_signin_failed" = "Inloggen mislukt"; "sleep_remaining_title" = "%@ resterend tot slapen"; "audio_source_title" = "Geluidsbron"; "speed_title" = "snelheid"; @@ -253,6 +256,10 @@ "logout_title" = "Uitloggen"; "delete_account_title" = "Account verwijderen"; "account_title" = "Rekening"; +"account_passkeys_title" = "Passkeys"; +"account_passkeys_description" = "Met passkeys kun je veilig inloggen met Face ID of Touch ID in plaats van een wachtwoord."; +"account_passkey_configured" = "Je passkey wordt gesynchroniseerd via iCloud-sleutelhanger en werkt op al je Apple-apparaten."; +"account_add_passkey_title" = "Voeg een passkey toe"; "benefits_cloudsync_title" = "Cloud synchronisatie (Beta)"; "benefits_themesicons_title" = "Thema's & Pictogrammen"; "benefits_supportus_title" = "Steun ons"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ door %@, boekomslag"; "voiceover_loading_book_cover" = "Boekomslag laden"; "integrations_title" = "Integraties"; +"File Path" = "Bestandspad"; +"Genres" = "Genres"; +"Overview" = "Overzicht"; +"Tags" = "Tags"; +"All" = "Alles"; +"Quick Action 1" = "Snelle actie 1"; +"Quick Action 2" = "Snelle actie 2"; +"Quick Action 3" = "Snelle actie 3"; +"database_backup_available_message" = "De database is beschadigd maar een recente back-up is beschikbaar. Wil je herstellen vanaf de laatste back-up?"; +"database_restore_button" = "Herstel vanaf back-up"; +"database_reset_button" = "Database resetten"; +"database_restore_failed_message" = "De database is beschadigd en het herstellen van de back-up is mislukt. De bibliotheek moet worden gereset en opnieuw worden opgebouwd vanuit je audiobestanden."; +"database_no_backup_message" = "De database is beschadigd en er is geen back-up beschikbaar. De bibliotheek moet worden gereset en opnieuw worden opgebouwd vanuit je audiobestanden."; "settings_smartrewind_max_interval_title" = "Slimme terugspoellimiet"; "settings_support_discord_title" = "Word lid van onze Discord-server"; + +// Passkey Authentication +"passkey_unnamed_device" = "Naamloos apparaat"; +"passkey_delete_title" = "Passkey verwijderen"; +"passkey_delete_message" = "Weet je zeker dat je deze passkey wilt verwijderen? Je kunt deze niet meer gebruiken om in te loggen."; +"passkey_continue_button" = "Doorgaan met passkey"; +"passkey_signin_button" = "Inloggen met passkey"; +"apple_signin_link" = "Inloggen met Apple"; +"apple_signin_title" = "Inloggen met Apple"; +"apple_signin_subtitle" = "Gebruik je bestaande Apple ID om in te loggen of een account aan te maken."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Account aanmaken"; +"auth_methods_section_title" = "Inlogmethoden"; +"passkey_created" = "Aangemaakt"; +"auth_method_added" = "Toegevoegd"; +"auth_method_primary" = "Primair"; + +/* Email Verification */ +"verify_email_title" = "Verifieer je e-mail"; +"verify_email_subtitle" = "Voer de 6-cijferige code in die naar %@ is gestuurd"; +"verify_button" = "Verifiëren"; +"verify_didnt_receive" = "Code niet ontvangen?"; +"verify_resend_button" = "Code opnieuw verzenden"; +"verify_resend_wait" = "Opnieuw verzenden over %d seconden"; +"continue_title" = "Doorgaan"; +"passkey_creating" = "Je passkey wordt aangemaakt..."; +"passkey_email_exists_title" = "Account bestaat al"; +"passkey_email_exists_message" = "Er bestaat al een account met dit e-mailadres. Log in met je bestaande passkey of Apple ID."; +"passkey_signin_existing" = "Inloggen met bestaande passkey"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 2139001e6..d4d1d0df1 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Ostatnio odtwarzana książka"; "watchapp_connect_error_title" = "Błąd Połączenia"; "watchapp_connect_error_description" = "Wystąpił problem z połączeniem z telefonem, spróbuj ponownie później"; +"watch_signin_with_iphone" = "Zaloguj się przez iPhone"; +"watch_signin_phone_required" = "Najpierw zaloguj się na swoim iPhonie, a następnie spróbuj ponownie."; +"watch_signin_failed" = "Logowanie nie powiodło się"; "sleep_remaining_title" = "%@ pozostałe do uśpienia"; "audio_source_title" = "Źródło Dźwięku"; "speed_title" = "prędkość"; @@ -253,6 +256,10 @@ "logout_title" = "Wyloguj"; "delete_account_title" = "Usuń konto"; "account_title" = "Konto"; +"account_passkeys_title" = "Klucze dostępu"; +"account_passkeys_description" = "Klucze dostępu pozwalają bezpiecznie logować się za pomocą Face ID lub Touch ID zamiast hasła."; +"account_passkey_configured" = "Twój klucz dostępu jest synchronizowany przez Pęk kluczy iCloud i działa na wszystkich Twoich urządzeniach Apple."; +"account_add_passkey_title" = "Dodaj klucz dostępu"; "benefits_cloudsync_title" = "Synchronizacja w chmurze (Beta)"; "benefits_themesicons_title" = "Motywy i ikony"; "benefits_supportus_title" = "Wspieraj nas"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ autorstwa %@, okładka książki"; "voiceover_loading_book_cover" = "Ładowanie okładki książki"; "integrations_title" = "Integracje"; +"File Path" = "Ścieżka pliku"; +"Genres" = "Gatunki"; +"Overview" = "Przegląd"; +"Tags" = "Tagi"; +"All" = "Wszystko"; +"Quick Action 1" = "Szybka akcja 1"; +"Quick Action 2" = "Szybka akcja 2"; +"Quick Action 3" = "Szybka akcja 3"; +"database_backup_available_message" = "Baza danych jest uszkodzona, ale dostępna jest ostatnia kopia zapasowa. Czy chcesz przywrócić z ostatniej kopii zapasowej?"; +"database_restore_button" = "Przywróć z kopii zapasowej"; +"database_reset_button" = "Zresetuj bazę danych"; +"database_restore_failed_message" = "Baza danych jest uszkodzona i przywracanie kopii zapasowej nie powiodło się. Biblioteka będzie musiała zostać zresetowana i odbudowana z plików audio."; +"database_no_backup_message" = "Baza danych jest uszkodzona i nie jest dostępna żadna kopia zapasowa. Biblioteka będzie musiała zostać zresetowana i odbudowana z plików audio."; "settings_smartrewind_max_interval_title" = "Limit inteligentnego przewijania"; "settings_support_discord_title" = "Dołącz do naszego serwera Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Urządzenie bez nazwy"; +"passkey_delete_title" = "Usuń klucz dostępu"; +"passkey_delete_message" = "Czy na pewno chcesz usunąć ten klucz dostępu? Nie będziesz mógł go używać do logowania."; +"passkey_continue_button" = "Kontynuuj z kluczem dostępu"; +"passkey_signin_button" = "Zaloguj się kluczem dostępu"; +"apple_signin_link" = "Zaloguj się przez Apple"; +"apple_signin_title" = "Zaloguj się przez Apple"; +"apple_signin_subtitle" = "Użyj istniejącego Apple ID, aby się zalogować lub utworzyć konto."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Utwórz konto"; +"auth_methods_section_title" = "Metody logowania"; +"passkey_created" = "Utworzono"; +"auth_method_added" = "Dodano"; +"auth_method_primary" = "Główna"; + +/* Email Verification */ +"verify_email_title" = "Zweryfikuj swój e-mail"; +"verify_email_subtitle" = "Wprowadź 6-cyfrowy kod wysłany na %@"; +"verify_button" = "Zweryfikuj"; +"verify_didnt_receive" = "Nie otrzymałeś kodu?"; +"verify_resend_button" = "Wyślij kod ponownie"; +"verify_resend_wait" = "Wyślij ponownie za %d sekund"; +"continue_title" = "Kontynuuj"; +"passkey_creating" = "Tworzenie klucza dostępu..."; +"passkey_email_exists_title" = "Konto istnieje"; +"passkey_email_exists_message" = "Konto z tym adresem e-mail już istnieje. Zaloguj się za pomocą istniejącego klucza dostępu lub Apple ID."; +"passkey_signin_existing" = "Zaloguj się istniejącym kluczem dostępu"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index f9b8aca15..a9ae2b651 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continuar o último livro reproduzido"; "watchapp_connect_error_title" = "Erro de conexão"; "watchapp_connect_error_description" = "Há um problema de conexão com o seu telefone, tente novamente mais tarde"; +"watch_signin_with_iphone" = "Entrar com iPhone"; +"watch_signin_phone_required" = "Por favor, entre primeiro no seu iPhone e tente novamente."; +"watch_signin_failed" = "Falha no login"; "sleep_remaining_title" = "%@ restante até dormir"; "audio_source_title" = "Fonte de Áudio"; "speed_title" = "velocidade"; @@ -253,6 +256,10 @@ "logout_title" = "Sair"; "delete_account_title" = "Deletar conta"; "account_title" = "Conta"; +"account_passkeys_title" = "Chaves de acesso"; +"account_passkeys_description" = "As chaves de acesso permitem que você faça login com segurança usando Face ID ou Touch ID em vez de uma senha."; +"account_passkey_configured" = "Sua chave de acesso é sincronizada pelo Chaveiro do iCloud e funciona em todos os seus dispositivos Apple."; +"account_add_passkey_title" = "Adicionar uma chave de acesso"; "benefits_cloudsync_title" = "Sincronização na nuvem (Beta)"; "benefits_themesicons_title" = "Temas e Ícones"; "benefits_supportus_title" = "Apoie-nos"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ de %@, capa do livro"; "voiceover_loading_book_cover" = "Carregando capa do livro"; "integrations_title" = "Integrações"; +"File Path" = "Caminho do arquivo"; +"Genres" = "Gêneros"; +"Overview" = "Visão geral"; +"Tags" = "Tags"; +"All" = "Tudo"; +"Quick Action 1" = "Ação rápida 1"; +"Quick Action 2" = "Ação rápida 2"; +"Quick Action 3" = "Ação rápida 3"; +"database_backup_available_message" = "O banco de dados está corrompido, mas há um backup recente disponível. Deseja restaurar a partir do backup mais recente?"; +"database_restore_button" = "Restaurar do backup"; +"database_reset_button" = "Redefinir banco de dados"; +"database_restore_failed_message" = "O banco de dados está corrompido e a restauração do backup falhou. A biblioteca precisará ser redefinida e reconstruída a partir dos seus arquivos de áudio."; +"database_no_backup_message" = "O banco de dados está corrompido e nenhum backup está disponível. A biblioteca precisará ser redefinida e reconstruída a partir dos seus arquivos de áudio."; "settings_smartrewind_max_interval_title" = "Limite de retrocesso inteligente"; "settings_support_discord_title" = "Junte-se ao nosso servidor Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispositivo sem nome"; +"passkey_delete_title" = "Excluir chave-senha"; +"passkey_delete_message" = "Tem certeza de que deseja remover esta chave-senha? Você não poderá mais entrar com ela."; +"passkey_continue_button" = "Continuar com chave-senha"; +"passkey_signin_button" = "Entrar com chave-senha"; +"apple_signin_link" = "Entrar com a Apple"; +"apple_signin_title" = "Entrar com a Apple"; +"apple_signin_subtitle" = "Use seu ID Apple existente para entrar ou criar uma conta."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Criar conta"; +"auth_methods_section_title" = "Métodos de login"; +"passkey_created" = "Criada"; +"auth_method_added" = "Adicionado"; +"auth_method_primary" = "Principal"; + +/* Email Verification */ +"verify_email_title" = "Verifique seu e-mail"; +"verify_email_subtitle" = "Digite o código de 6 dígitos enviado para %@"; +"verify_button" = "Verificar"; +"verify_didnt_receive" = "Não recebeu o código?"; +"verify_resend_button" = "Reenviar código"; +"verify_resend_wait" = "Reenviar em %d segundos"; +"continue_title" = "Continuar"; +"passkey_creating" = "Criando sua chave-senha..."; +"passkey_email_exists_title" = "Conta já existe"; +"passkey_email_exists_message" = "Já existe uma conta com este e-mail. Entre com sua chave-senha ou ID Apple existente."; +"passkey_signin_existing" = "Entrar com chave-senha existente"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 5c2f8a179..24a02943c 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continuar o último livro reproduzido"; "watchapp_connect_error_title" = "Erro de ligação"; "watchapp_connect_error_description" = "Há um problema de ligação ao seu telemóvel, tentar novamente mais tarde"; +"watch_signin_with_iphone" = "Iniciar sessão com iPhone"; +"watch_signin_phone_required" = "Por favor, inicie sessão primeiro no seu iPhone e tente novamente."; +"watch_signin_failed" = "Falha ao iniciar sessão"; "sleep_remaining_title" = "%@ restante até adormecer"; "audio_source_title" = "Fonte de Áudio"; "speed_title" = "velocidade"; @@ -253,6 +256,10 @@ "logout_title" = "Sair"; "delete_account_title" = "Apagar conta"; "account_title" = "Conta"; +"account_passkeys_title" = "Chaves de acesso"; +"account_passkeys_description" = "As chaves de acesso permitem-lhe iniciar sessão de forma segura com Face ID ou Touch ID em vez de uma palavra-passe."; +"account_passkey_configured" = "A sua chave de acesso é sincronizada através do Porta-chaves iCloud e funciona em todos os seus dispositivos Apple."; +"account_add_passkey_title" = "Adicionar uma chave de acesso"; "benefits_cloudsync_title" = "Sincronização na nuvem (Beta)"; "benefits_themesicons_title" = "Temas e Ícones"; "benefits_supportus_title" = "Apoie-nos"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ de %@, capa do livro"; "voiceover_loading_book_cover" = "A carregar capa do livro"; "integrations_title" = "Integrações"; +"File Path" = "Caminho do ficheiro"; +"Genres" = "Géneros"; +"Overview" = "Visão geral"; +"Tags" = "Etiquetas"; +"All" = "Tudo"; +"Quick Action 1" = "Ação rápida 1"; +"Quick Action 2" = "Ação rápida 2"; +"Quick Action 3" = "Ação rápida 3"; +"database_backup_available_message" = "A base de dados está corrompida mas existe uma cópia de segurança recente disponível. Deseja restaurar a partir da última cópia de segurança?"; +"database_restore_button" = "Restaurar da cópia de segurança"; +"database_reset_button" = "Repor base de dados"; +"database_restore_failed_message" = "A base de dados está corrompida e o restauro da cópia de segurança falhou. A biblioteca terá de ser reposta e reconstruída a partir dos seus ficheiros de áudio."; +"database_no_backup_message" = "A base de dados está corrompida e não existe nenhuma cópia de segurança disponível. A biblioteca terá de ser reposta e reconstruída a partir dos seus ficheiros de áudio."; "settings_smartrewind_max_interval_title" = "Limite de retrocesso inteligente"; "settings_support_discord_title" = "Junte-se ao nosso servidor Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispositivo sem nome"; +"passkey_delete_title" = "Eliminar chave de acesso"; +"passkey_delete_message" = "Tem a certeza de que pretende remover esta chave de acesso? Não poderá iniciar sessão com ela."; +"passkey_continue_button" = "Continuar com chave de acesso"; +"passkey_signin_button" = "Iniciar sessão com chave de acesso"; +"apple_signin_link" = "Iniciar sessão com a Apple"; +"apple_signin_title" = "Iniciar sessão com a Apple"; +"apple_signin_subtitle" = "Utilize o seu ID Apple existente para iniciar sessão ou criar uma conta."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Criar conta"; +"auth_methods_section_title" = "Métodos de início de sessão"; +"passkey_created" = "Criada"; +"auth_method_added" = "Adicionado"; +"auth_method_primary" = "Principal"; + +/* Email Verification */ +"verify_email_title" = "Verifique o seu e-mail"; +"verify_email_subtitle" = "Introduza o código de 6 dígitos enviado para %@"; +"verify_button" = "Verificar"; +"verify_didnt_receive" = "Não recebeu o código?"; +"verify_resend_button" = "Reenviar código"; +"verify_resend_wait" = "Reenviar em %d segundos"; +"continue_title" = "Continuar"; +"passkey_creating" = "A criar a sua chave de acesso..."; +"passkey_email_exists_title" = "A conta já existe"; +"passkey_email_exists_message" = "Já existe uma conta com este e-mail. Inicie sessão com a sua chave de acesso ou ID Apple existente."; +"passkey_signin_existing" = "Iniciar sessão com chave existente"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index acc579426..21291c028 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Continuați ultima carte derulata"; "watchapp_connect_error_title" = "Eroare de conectivitate"; "watchapp_connect_error_description" = "Există o problemă de conectare la telefonul dvs., încercați din nou mai târziu"; +"watch_signin_with_iphone" = "Autentificare cu iPhone"; +"watch_signin_phone_required" = "Te rugăm să te autentifici mai întâi pe iPhone, apoi încearcă din nou."; +"watch_signin_failed" = "Autentificare eșuată"; "sleep_remaining_title" = "%@Timp rămas pana la somn"; "audio_source_title" = "Sursă audio"; "speed_title" = "Viteza"; @@ -253,6 +256,10 @@ "logout_title" = "Deconectați-vă"; "delete_account_title" = "Șterge cont"; "account_title" = "Cont"; +"account_passkeys_title" = "Chei de acces"; +"account_passkeys_description" = "Cheile de acces îți permit să te autentifici în siguranță cu Face ID sau Touch ID în loc de o parolă."; +"account_passkey_configured" = "Cheia ta de acces este sincronizată prin Portchei iCloud și funcționează pe toate dispozitivele tale Apple."; +"account_add_passkey_title" = "Adaugă o cheie de acces"; "benefits_cloudsync_title" = "Sincronizare cloud (Beta)"; "benefits_themesicons_title" = "Teme și icoane"; "benefits_supportus_title" = "Ajuta-ne"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ de %@, coperta cărții"; "voiceover_loading_book_cover" = "Se încarcă coperta cărții"; "integrations_title" = "Integrări"; +"File Path" = "Cale fișier"; +"Genres" = "Genuri"; +"Overview" = "Prezentare generală"; +"Tags" = "Etichete"; +"All" = "Tot"; +"Quick Action 1" = "Acțiune rapidă 1"; +"Quick Action 2" = "Acțiune rapidă 2"; +"Quick Action 3" = "Acțiune rapidă 3"; +"database_backup_available_message" = "Baza de date este coruptă, dar o copie de rezervă recentă este disponibilă. Doriți să restaurați din ultima copie de rezervă?"; +"database_restore_button" = "Restaurare din copie de rezervă"; +"database_reset_button" = "Resetare bază de date"; +"database_restore_failed_message" = "Baza de date este coruptă și restaurarea copiei de rezervă a eșuat. Biblioteca va trebui resetată și reconstruită din fișierele audio."; +"database_no_backup_message" = "Baza de date este coruptă și nu este disponibilă nicio copie de rezervă. Biblioteca va trebui resetată și reconstruită din fișierele audio."; "settings_smartrewind_max_interval_title" = "Limită de derulare înapoi inteligentă"; "settings_support_discord_title" = "Alătură-te serverului nostru Discord"; + +// Passkey Authentication +"passkey_unnamed_device" = "Dispozitiv fără nume"; +"passkey_delete_title" = "Șterge cheia de acces"; +"passkey_delete_message" = "Sigur vrei să ștergi această cheie de acces? Nu o vei mai putea folosi pentru autentificare."; +"passkey_continue_button" = "Continuă cu cheia de acces"; +"passkey_signin_button" = "Autentificare cu cheia de acces"; +"apple_signin_link" = "Autentificare cu Apple"; +"apple_signin_title" = "Autentificare cu Apple"; +"apple_signin_subtitle" = "Folosește ID-ul Apple existent pentru a te autentifica sau a crea un cont."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Creează cont"; +"auth_methods_section_title" = "Metode de autentificare"; +"passkey_created" = "Creat"; +"auth_method_added" = "Adăugat"; +"auth_method_primary" = "Principal"; + +/* Email Verification */ +"verify_email_title" = "Verifică-ți e-mailul"; +"verify_email_subtitle" = "Introdu codul din 6 cifre trimis la %@"; +"verify_button" = "Verifică"; +"verify_didnt_receive" = "Nu ai primit codul?"; +"verify_resend_button" = "Retrimite codul"; +"verify_resend_wait" = "Retrimite în %d secunde"; +"continue_title" = "Continuă"; +"passkey_creating" = "Se creează cheia de acces..."; +"passkey_email_exists_title" = "Contul există"; +"passkey_email_exists_message" = "Un cont cu acest e-mail există deja. Te rugăm să te autentifici cu cheia de acces sau ID-ul Apple existent."; +"passkey_signin_existing" = "Autentificare cu cheia de acces existentă"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index a9f9013e8..340ed39c2 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Продолжить последнюю прослушанную книгу"; "watchapp_connect_error_title" = "Ошибка подключения"; "watchapp_connect_error_description" = "Не удаётся подключиться к вашему устройству, попробуйте позже."; +"watch_signin_with_iphone" = "Войти через iPhone"; +"watch_signin_phone_required" = "Сначала войдите на iPhone, затем повторите попытку."; +"watch_signin_failed" = "Ошибка входа"; "sleep_remaining_title" = "До сна осталось %@"; "audio_source_title" = "Источник звука"; "speed_title" = "Скорость"; @@ -253,6 +256,10 @@ "logout_title" = "Выйти"; "delete_account_title" = "Удалить аккаунт"; "account_title" = "Аккаунт"; +"account_passkeys_title" = "Ключи доступа"; +"account_passkeys_description" = "Ключи доступа позволяют безопасно входить с помощью Face ID или Touch ID вместо пароля."; +"account_passkey_configured" = "Ваш ключ доступа синхронизируется через Связку ключей iCloud и работает на всех ваших устройствах Apple."; +"account_add_passkey_title" = "Добавить ключ доступа"; "benefits_cloudsync_title" = "Облачная синхронизация (Beta)"; "benefits_themesicons_title" = "Темы и иконки"; "benefits_supportus_title" = "Поддержите нас"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ от %@, обложка книги"; "voiceover_loading_book_cover" = "Загрузка обложки книги"; "integrations_title" = "Интеграции"; +"File Path" = "Путь к файлу"; +"Genres" = "Жанры"; +"Overview" = "Обзор"; +"Tags" = "Теги"; +"All" = "Все"; +"Quick Action 1" = "Быстрое действие 1"; +"Quick Action 2" = "Быстрое действие 2"; +"Quick Action 3" = "Быстрое действие 3"; +"database_backup_available_message" = "База данных повреждена, но доступна недавняя резервная копия. Хотите восстановить из последней резервной копии?"; +"database_restore_button" = "Восстановить из резервной копии"; +"database_reset_button" = "Сбросить базу данных"; +"database_restore_failed_message" = "База данных повреждена, и восстановление из резервной копии не удалось. Библиотеку придётся сбросить и перестроить из ваших аудиофайлов."; +"database_no_backup_message" = "База данных повреждена, и резервная копия недоступна. Библиотеку придётся сбросить и перестроить из ваших аудиофайлов."; "settings_smartrewind_max_interval_title" = "Ограничение смарт-перемотки"; "settings_support_discord_title" = "Присоединяйтесь к нашему Discord-серверу"; + +// Passkey Authentication +"passkey_unnamed_device" = "Устройство без имени"; +"passkey_delete_title" = "Удалить ключ доступа"; +"passkey_delete_message" = "Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа."; +"passkey_continue_button" = "Продолжить с ключом доступа"; +"passkey_signin_button" = "Войти с ключом доступа"; +"apple_signin_link" = "Войти через Apple"; +"apple_signin_title" = "Войти через Apple"; +"apple_signin_subtitle" = "Используйте существующий Apple ID для входа или создания учётной записи."; +"email_title" = "Эл. почта"; +"passkey_registration_title" = "Создать учётную запись"; +"auth_methods_section_title" = "Способы входа"; +"passkey_created" = "Создан"; +"auth_method_added" = "Добавлен"; +"auth_method_primary" = "Основной"; + +/* Email Verification */ +"verify_email_title" = "Подтвердите эл. почту"; +"verify_email_subtitle" = "Введите 6-значный код, отправленный на %@"; +"verify_button" = "Подтвердить"; +"verify_didnt_receive" = "Не получили код?"; +"verify_resend_button" = "Отправить код повторно"; +"verify_resend_wait" = "Повторить через %d сек."; +"continue_title" = "Продолжить"; +"passkey_creating" = "Создание ключа доступа..."; +"passkey_email_exists_title" = "Учётная запись существует"; +"passkey_email_exists_message" = "Учётная запись с таким адресом эл. почты уже существует. Войдите с помощью существующего ключа доступа или Apple ID."; +"passkey_signin_existing" = "Войти с существующим ключом доступа"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 2efac748b..898420cd3 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Pokračovať v naposledy prehrávanej knihe"; "watchapp_connect_error_title" = "Chyba pripojenia"; "watchapp_connect_error_description" = "Pri pripojení k telefónu došlo k problémom, vyskúšajte to prosím neskôr"; +"watch_signin_with_iphone" = "Prihlásiť sa cez iPhone"; +"watch_signin_phone_required" = "Najprv sa prihláste na svojom iPhone a potom to skúste znova."; +"watch_signin_failed" = "Prihlásenie zlyhalo"; "sleep_remaining_title" = "Do spánku ostáva %@"; "audio_source_title" = "Zdroj zvuku"; "speed_title" = "rýchlosť"; @@ -253,6 +256,10 @@ "logout_title" = "Odhlásiť sa"; "delete_account_title" = "Odstránenie účtu"; "account_title" = "Účet"; +"account_passkeys_title" = "Prístupové kľúče"; +"account_passkeys_description" = "Prístupové kľúče vám umožňujú bezpečne sa prihlásiť pomocou Face ID alebo Touch ID namiesto hesla."; +"account_passkey_configured" = "Váš prístupový kľúč je synchronizovaný cez iCloud Keychain a funguje na všetkých vašich zariadeniach Apple."; +"account_add_passkey_title" = "Pridať prístupový kľúč"; "benefits_cloudsync_title" = "Cloudová synchronizácia (Beta)"; "benefits_themesicons_title" = "Témy a ikony"; "benefits_supportus_title" = "Podporte nás"; @@ -378,5 +385,47 @@ Usilovne pracujeme na poskytovaní bezproblémového zážitku, ak je to možné "voiceover_book_cover" = "%@ od %@, obálka knihy"; "voiceover_loading_book_cover" = "Načítavam obálku knihy"; "integrations_title" = "Integrácie"; +"File Path" = "Cesta k súboru"; +"Genres" = "Žánre"; +"Overview" = "Prehľad"; +"Tags" = "Štítky"; +"All" = "Všetko"; +"Quick Action 1" = "Rýchla akcia 1"; +"Quick Action 2" = "Rýchla akcia 2"; +"Quick Action 3" = "Rýchla akcia 3"; +"database_backup_available_message" = "Databáza je poškodená, ale je k dispozícii nedávna záloha. Chcete obnoviť z poslednej zálohy?"; +"database_restore_button" = "Obnoviť zo zálohy"; +"database_reset_button" = "Resetovať databázu"; +"database_restore_failed_message" = "Databáza je poškodená a obnovenie zo zálohy zlyhalo. Knižnica bude musieť byť resetovaná a znovu vytvorená z vašich audio súborov."; +"database_no_backup_message" = "Databáza je poškodená a nie je k dispozícii žiadna záloha. Knižnica bude musieť byť resetovaná a znovu vytvorená z vašich audio súborov."; "settings_smartrewind_max_interval_title" = "Inteligentný limit pretáčania"; "settings_support_discord_title" = "Pripojte sa k nášmu Discord serveru"; + +// Passkey Authentication +"passkey_unnamed_device" = "Nepomenované zariadenie"; +"passkey_delete_title" = "Odstrániť prístupový kľúč"; +"passkey_delete_message" = "Naozaj chcete odstrániť tento prístupový kľúč? Nebudete sa s ním môcť prihlásiť."; +"passkey_continue_button" = "Pokračovať s prístupovým kľúčom"; +"passkey_signin_button" = "Prihlásiť sa prístupovým kľúčom"; +"apple_signin_link" = "Prihlásiť sa cez Apple"; +"apple_signin_title" = "Prihlásiť sa cez Apple"; +"apple_signin_subtitle" = "Použite svoje existujúce Apple ID na prihlásenie alebo vytvorenie účtu."; +"email_title" = "E-mail"; +"passkey_registration_title" = "Vytvoriť účet"; +"auth_methods_section_title" = "Metódy prihlásenia"; +"passkey_created" = "Vytvorený"; +"auth_method_added" = "Pridané"; +"auth_method_primary" = "Primárna"; + +/* Email Verification */ +"verify_email_title" = "Overte svoj e-mail"; +"verify_email_subtitle" = "Zadajte 6-miestny kód zaslaný na %@"; +"verify_button" = "Overiť"; +"verify_didnt_receive" = "Nedostali ste kód?"; +"verify_resend_button" = "Znovu odoslať kód"; +"verify_resend_wait" = "Znovu odoslať za %d sekúnd"; +"continue_title" = "Pokračovať"; +"passkey_creating" = "Vytváranie prístupového kľúča..."; +"passkey_email_exists_title" = "Účet existuje"; +"passkey_email_exists_message" = "Účet s týmto e-mailom už existuje. Prihláste sa pomocou existujúceho prístupového kľúča alebo Apple ID."; +"passkey_signin_existing" = "Prihlásiť sa existujúcim prístupovým kľúčom"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index b8caf764b..1496b9bbd 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Fortsätt senast spelade bok"; "watchapp_connect_error_title" = "Anslutningsfel"; "watchapp_connect_error_description" = "Där är problem med att ansluta till din telefon, vänligen försök igen senare"; +"watch_signin_with_iphone" = "Logga in med iPhone"; +"watch_signin_phone_required" = "Logga in på din iPhone först och försök sedan igen."; +"watch_signin_failed" = "Inloggningen misslyckades"; "sleep_remaining_title" = "%@ återstår tills viloläge"; "audio_source_title" = "Ljudkälla"; "speed_title" = "hastighet"; @@ -253,6 +256,10 @@ "logout_title" = "Logga ut"; "delete_account_title" = "Radera konto"; "account_title" = "konto"; +"account_passkeys_title" = "Lösenordsnycklar"; +"account_passkeys_description" = "Lösenordsnycklar låter dig logga in säkert med Face ID eller Touch ID istället för ett lösenord."; +"account_passkey_configured" = "Din lösenordsnyckel synkroniseras via iCloud-nyckelring och fungerar på alla dina Apple-enheter."; +"account_add_passkey_title" = "Lägg till en lösenordsnyckel"; "benefits_cloudsync_title" = "Molnsynkronisering (Beta)"; "benefits_themesicons_title" = "Teman och ikoner"; "benefits_supportus_title" = "Stöd oss"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ av %@, bokomslag"; "voiceover_loading_book_cover" = "Laddar bokomslag"; "integrations_title" = "Integrationer"; +"File Path" = "Filsökväg"; +"Genres" = "Genrer"; +"Overview" = "Översikt"; +"Tags" = "Taggar"; +"All" = "Alla"; +"Quick Action 1" = "Snabbåtgärd 1"; +"Quick Action 2" = "Snabbåtgärd 2"; +"Quick Action 3" = "Snabbåtgärd 3"; +"database_backup_available_message" = "Databasen är skadad men en nylig säkerhetskopia finns tillgänglig. Vill du återställa från den senaste säkerhetskopian?"; +"database_restore_button" = "Återställ från säkerhetskopia"; +"database_reset_button" = "Återställ databas"; +"database_restore_failed_message" = "Databasen är skadad och återställning från säkerhetskopia misslyckades. Biblioteket måste återställas och byggas om från dina ljudfiler."; +"database_no_backup_message" = "Databasen är skadad och ingen säkerhetskopia finns tillgänglig. Biblioteket måste återställas och byggas om från dina ljudfiler."; "settings_smartrewind_max_interval_title" = "Smart återspolningsgräns"; "settings_support_discord_title" = "Gå med i vår Discord-server"; + +// Passkey Authentication +"passkey_unnamed_device" = "Namnlös enhet"; +"passkey_delete_title" = "Radera lösenordsnyckel"; +"passkey_delete_message" = "Är du säker på att du vill radera denna lösenordsnyckel? Du kommer inte längre kunna använda den för att logga in."; +"passkey_continue_button" = "Fortsätt med lösenordsnyckel"; +"passkey_signin_button" = "Logga in med lösenordsnyckel"; +"apple_signin_link" = "Logga in med Apple"; +"apple_signin_title" = "Logga in med Apple"; +"apple_signin_subtitle" = "Använd ditt befintliga Apple-ID för att logga in eller skapa ett konto."; +"email_title" = "E-post"; +"passkey_registration_title" = "Skapa konto"; +"auth_methods_section_title" = "Inloggningsmetoder"; +"passkey_created" = "Skapad"; +"auth_method_added" = "Tillagd"; +"auth_method_primary" = "Primär"; + +/* Email Verification */ +"verify_email_title" = "Verifiera din e-post"; +"verify_email_subtitle" = "Ange den 6-siffriga koden som skickades till %@"; +"verify_button" = "Verifiera"; +"verify_didnt_receive" = "Fick du inte koden?"; +"verify_resend_button" = "Skicka koden igen"; +"verify_resend_wait" = "Skicka igen om %d sekunder"; +"continue_title" = "Fortsätt"; +"passkey_creating" = "Skapar din lösenordsnyckel..."; +"passkey_email_exists_title" = "Kontot finns"; +"passkey_email_exists_message" = "Ett konto med denna e-postadress finns redan. Logga in med din befintliga lösenordsnyckel eller Apple-ID istället."; +"passkey_signin_existing" = "Logga in med befintlig lösenordsnyckel"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 4a717a95e..b047f31ad 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "En son oynatılan kitaba devam et"; "watchapp_connect_error_title" = "Bağlantı Hatası"; "watchapp_connect_error_description" = "Telefonunuza bağlanmada bir sorun var, lütfen daha sonra tekrar deneyin"; +"watch_signin_with_iphone" = "iPhone ile giriş yap"; +"watch_signin_phone_required" = "Lütfen önce iPhone'unuzda oturum açın, ardından tekrar deneyin."; +"watch_signin_failed" = "Oturum açma başarısız"; "sleep_remaining_title" = "uykuya %@ kaldı"; "audio_source_title" = "Ses Kaynağı"; "speed_title" = "hız"; @@ -253,6 +256,10 @@ "logout_title" = "Çıkış Yap"; "delete_account_title" = "Hesabı sil"; "account_title" = "Hesap"; +"account_passkeys_title" = "Geçiş anahtarları"; +"account_passkeys_description" = "Geçiş anahtarları, parola yerine Face ID veya Touch ID ile güvenli bir şekilde oturum açmanızı sağlar."; +"account_passkey_configured" = "Geçiş anahtarınız iCloud Anahtar Zinciri aracılığıyla senkronize edilir ve tüm Apple cihazlarınızda çalışır."; +"account_add_passkey_title" = "Geçiş anahtarı ekle"; "benefits_cloudsync_title" = "Bulut senkronizasyonu (Beta)"; "benefits_themesicons_title" = "Temalar ve Simgeler"; "benefits_supportus_title" = "Bizi destekle"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ tarafından %@, kitap kapağı"; "voiceover_loading_book_cover" = "Kitap kapağı yükleniyor"; "integrations_title" = "Entegrasyonlar"; +"File Path" = "Dosya Yolu"; +"Genres" = "Türler"; +"Overview" = "Genel Bakış"; +"Tags" = "Etiketler"; +"All" = "Tümü"; +"Quick Action 1" = "Hızlı Eylem 1"; +"Quick Action 2" = "Hızlı Eylem 2"; +"Quick Action 3" = "Hızlı Eylem 3"; +"database_backup_available_message" = "Veritabanı bozuk ancak yakın tarihli bir yedek mevcut. En son yedekten geri yüklemek ister misiniz?"; +"database_restore_button" = "Yedekten Geri Yükle"; +"database_reset_button" = "Veritabanını Sıfırla"; +"database_restore_failed_message" = "Veritabanı bozuk ve yedek geri yükleme başarısız oldu. Kitaplığın sıfırlanması ve ses dosyalarınızdan yeniden oluşturulması gerekecek."; +"database_no_backup_message" = "Veritabanı bozuk ve yedek mevcut değil. Kitaplığın sıfırlanması ve ses dosyalarınızdan yeniden oluşturulması gerekecek."; "settings_smartrewind_max_interval_title" = "Akıllı Geri Sarma Sınırı"; "settings_support_discord_title" = "Discord sunucumuza katılın"; + +// Passkey Authentication +"passkey_unnamed_device" = "Adsız Cihaz"; +"passkey_delete_title" = "Geçiş anahtarını sil"; +"passkey_delete_message" = "Bu geçiş anahtarını silmek istediğinizden emin misiniz? Artık giriş yapmak için kullanamayacaksınız."; +"passkey_continue_button" = "Geçiş anahtarıyla devam et"; +"passkey_signin_button" = "Geçiş anahtarıyla giriş yap"; +"apple_signin_link" = "Apple ile giriş yap"; +"apple_signin_title" = "Apple ile giriş yap"; +"apple_signin_subtitle" = "Giriş yapmak veya hesap oluşturmak için mevcut Apple Kimliğinizi kullanın."; +"email_title" = "E-posta"; +"passkey_registration_title" = "Hesap oluştur"; +"auth_methods_section_title" = "Giriş yöntemleri"; +"passkey_created" = "Oluşturuldu"; +"auth_method_added" = "Eklendi"; +"auth_method_primary" = "Birincil"; + +/* Email Verification */ +"verify_email_title" = "E-postanızı doğrulayın"; +"verify_email_subtitle" = "%@ adresine gönderilen 6 haneli kodu girin"; +"verify_button" = "Doğrula"; +"verify_didnt_receive" = "Kodu almadınız mı?"; +"verify_resend_button" = "Kodu tekrar gönder"; +"verify_resend_wait" = "%d saniye sonra tekrar gönder"; +"continue_title" = "Devam"; +"passkey_creating" = "Geçiş anahtarınız oluşturuluyor..."; +"passkey_email_exists_title" = "Hesap mevcut"; +"passkey_email_exists_message" = "Bu e-posta adresine sahip bir hesap zaten var. Lütfen mevcut geçiş anahtarınız veya Apple Kimliğiniz ile giriş yapın."; +"passkey_signin_existing" = "Mevcut geçiş anahtarıyla giriş yap"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 7bcc9046b..113fd09df 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "Продовжити відтворення попередньої книги"; "watchapp_connect_error_title" = "Помилка з'єднання"; "watchapp_connect_error_description" = "Виникла проблема з підключенням до телефону, будь ласка, спробуйте пізніше"; +"watch_signin_with_iphone" = "Увійти через iPhone"; +"watch_signin_phone_required" = "Спочатку увійдіть на своєму iPhone, а потім спробуйте знову."; +"watch_signin_failed" = "Помилка входу"; "sleep_remaining_title" = "%@ залишилось до сну"; "audio_source_title" = "Джерело аудіо"; "speed_title" = "швидкість"; @@ -253,6 +256,10 @@ "logout_title" = "Вийти"; "delete_account_title" = "Видалити аккаунт"; "account_title" = "Обліковий запис"; +"account_passkeys_title" = "Ключі доступу"; +"account_passkeys_description" = "Ключі доступу дозволяють безпечно входити за допомогою Face ID або Touch ID замість пароля."; +"account_passkey_configured" = "Ваш ключ доступу синхронізується через Зв'язку ключів iCloud і працює на всіх ваших пристроях Apple."; +"account_add_passkey_title" = "Додати ключ доступу"; "benefits_cloudsync_title" = "Хмарна синхронізація (Beta)"; "benefits_themesicons_title" = "Теми та значки"; "benefits_supportus_title" = "Підтримайте нас"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ від %@, обкладинка книги"; "voiceover_loading_book_cover" = "Завантаження обкладинки книги"; "integrations_title" = "Інтеграції"; +"File Path" = "Шлях до файлу"; +"Genres" = "Жанри"; +"Overview" = "Огляд"; +"Tags" = "Теги"; +"All" = "Усе"; +"Quick Action 1" = "Швидка дія 1"; +"Quick Action 2" = "Швидка дія 2"; +"Quick Action 3" = "Швидка дія 3"; +"database_backup_available_message" = "База даних пошкоджена, але доступна нещодавня резервна копія. Бажаєте відновити з останньої резервної копії?"; +"database_restore_button" = "Відновити з резервної копії"; +"database_reset_button" = "Скинути базу даних"; +"database_restore_failed_message" = "База даних пошкоджена і відновлення з резервної копії не вдалося. Бібліотеку потрібно буде скинути та перебудувати з ваших аудіофайлів."; +"database_no_backup_message" = "База даних пошкоджена і резервна копія недоступна. Бібліотеку потрібно буде скинути та перебудувати з ваших аудіофайлів."; "settings_smartrewind_max_interval_title" = "Розумне обмеження перемотування"; "settings_support_discord_title" = "Приєднуйтесь до нашого Discord-сервера"; + +// Passkey Authentication +"passkey_unnamed_device" = "Пристрій без назви"; +"passkey_delete_title" = "Видалити ключ доступу"; +"passkey_delete_message" = "Ви впевнені, що хочете видалити цей ключ доступу? Ви більше не зможете використовувати його для входу."; +"passkey_continue_button" = "Продовжити з ключем доступу"; +"passkey_signin_button" = "Увійти з ключем доступу"; +"apple_signin_link" = "Увійти через Apple"; +"apple_signin_title" = "Увійти через Apple"; +"apple_signin_subtitle" = "Використовуйте наявний Apple ID для входу або створення облікового запису."; +"email_title" = "Ел. пошта"; +"passkey_registration_title" = "Створити обліковий запис"; +"auth_methods_section_title" = "Способи входу"; +"passkey_created" = "Створено"; +"auth_method_added" = "Додано"; +"auth_method_primary" = "Основний"; + +/* Email Verification */ +"verify_email_title" = "Підтвердіть ел. пошту"; +"verify_email_subtitle" = "Введіть 6-значний код, надісланий на %@"; +"verify_button" = "Підтвердити"; +"verify_didnt_receive" = "Не отримали код?"; +"verify_resend_button" = "Надіслати код повторно"; +"verify_resend_wait" = "Повторити через %d сек."; +"continue_title" = "Продовжити"; +"passkey_creating" = "Створення ключа доступу..."; +"passkey_email_exists_title" = "Обліковий запис існує"; +"passkey_email_exists_message" = "Обліковий запис з цією адресою ел. пошти вже існує. Увійдіть за допомогою наявного ключа доступу або Apple ID."; +"passkey_signin_existing" = "Увійти з наявним ключем доступу"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index bf6efca21..91ab171f6 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -145,6 +145,9 @@ "siri_activity_title" = "继续上次播放的有声书"; "watchapp_connect_error_title" = "连接错误"; "watchapp_connect_error_description" = "连接手机时出现问题,请稍后重试"; +"watch_signin_with_iphone" = "使用 iPhone 登录"; +"watch_signin_phone_required" = "请先在 iPhone 上登录,然后重试。"; +"watch_signin_failed" = "登录失败"; "sleep_remaining_title" = "剩余%@直到停止"; "audio_source_title" = "音源"; "speed_title" = "速度"; @@ -253,6 +256,10 @@ "logout_title" = "登出"; "delete_account_title" = "删除帐户"; "account_title" = "帐户"; +"account_passkeys_title" = "通行密钥"; +"account_passkeys_description" = "通行密钥让您可以使用面容 ID 或触控 ID 安全登录,无需密码。"; +"account_passkey_configured" = "您的通行密钥通过 iCloud 钥匙串同步,可在所有 Apple 设备上使用。"; +"account_add_passkey_title" = "添加通行密钥"; "benefits_cloudsync_title" = "云同步(Beta)"; "benefits_themesicons_title" = "主题和图标"; "benefits_supportus_title" = "支持我们"; @@ -378,5 +385,47 @@ "voiceover_book_cover" = "%@ 作者 %@,图书封面"; "voiceover_loading_book_cover" = "正在加载图书封面"; "integrations_title" = "集成"; +"File Path" = "文件路径"; +"Genres" = "类型"; +"Overview" = "概览"; +"Tags" = "标签"; +"All" = "全部"; +"Quick Action 1" = "快捷操作 1"; +"Quick Action 2" = "快捷操作 2"; +"Quick Action 3" = "快捷操作 3"; +"database_backup_available_message" = "数据库已损坏,但有最近的备份可用。是否要从最新备份恢复?"; +"database_restore_button" = "从备份恢复"; +"database_reset_button" = "重置数据库"; +"database_restore_failed_message" = "数据库已损坏,备份恢复失败。资料库需要重置并从您的音频文件重新构建。"; +"database_no_backup_message" = "数据库已损坏且没有可用的备份。资料库需要重置并从您的音频文件重新构建。"; "settings_smartrewind_max_interval_title" = "智能倒带限制"; "settings_support_discord_title" = "加入我们的 Discord 服务器"; + +// Passkey Authentication +"passkey_unnamed_device" = "未命名设备"; +"passkey_delete_title" = "删除通行密钥"; +"passkey_delete_message" = "确定要删除此通行密钥吗?删除后将无法使用它登录。"; +"passkey_continue_button" = "使用通行密钥继续"; +"passkey_signin_button" = "使用通行密钥登录"; +"apple_signin_link" = "通过 Apple 登录"; +"apple_signin_title" = "通过 Apple 登录"; +"apple_signin_subtitle" = "使用您现有的 Apple ID 登录或创建账户。"; +"email_title" = "电子邮件"; +"passkey_registration_title" = "创建账户"; +"auth_methods_section_title" = "登录方式"; +"passkey_created" = "已创建"; +"auth_method_added" = "已添加"; +"auth_method_primary" = "主要"; + +/* Email Verification */ +"verify_email_title" = "验证您的电子邮件"; +"verify_email_subtitle" = "输入发送至 %@ 的6位验证码"; +"verify_button" = "验证"; +"verify_didnt_receive" = "没有收到验证码?"; +"verify_resend_button" = "重新发送验证码"; +"verify_resend_wait" = "%d 秒后重新发送"; +"continue_title" = "继续"; +"passkey_creating" = "正在创建通行密钥..."; +"passkey_email_exists_title" = "账户已存在"; +"passkey_email_exists_message" = "使用此电子邮件的账户已存在。请使用现有的通行密钥或 Apple ID 登录。"; +"passkey_signin_existing" = "使用现有通行密钥登录"; diff --git a/BookPlayerTests/Mocks/AccountServiceMock.swift b/BookPlayerTests/Mocks/AccountServiceMock.swift index 9f3c41277..0dbabd8dc 100644 --- a/BookPlayerTests/Mocks/AccountServiceMock.swift +++ b/BookPlayerTests/Mocks/AccountServiceMock.swift @@ -11,6 +11,12 @@ import Foundation import RevenueCat class AccountServiceMock: AccountServiceProtocol { + func handlePasskeyLogin(response: BookPlayerKit.PasskeyLoginResponse) async throws { } + + func loginWithTransferredCredentials(token: String, accountId: String, email: String, hasSubscription: Bool, donationMade: Bool) async throws -> BookPlayerKit.Account? { + return nil + } + func getAnonymousId() -> String? { return nil } diff --git a/BookPlayerWatch/BP+ErrorAlerts.swift b/BookPlayerWatch/BP+ErrorAlerts.swift index e034b6fa7..37405dab6 100644 --- a/BookPlayerWatch/BP+ErrorAlerts.swift +++ b/BookPlayerWatch/BP+ErrorAlerts.swift @@ -35,14 +35,17 @@ struct LocalizedAlertError: LocalizedError { var recoverySuggestion: String? init?(error: Error?) { - if let localizedError = error as? LocalizedError { - self.errorDescription = localizedError.errorDescription + guard let error else { return nil } + + if let localizedError = error as? LocalizedError, + let description = localizedError.errorDescription { + self.errorDescription = description self.recoverySuggestion = localizedError.recoverySuggestion - } else if let error { + } else { + // Fallback to localizedDescription for non-LocalizedError types + // or when errorDescription is nil self.errorDescription = error.localizedDescription self.recoverySuggestion = nil - } else { - return nil } } } diff --git a/BookPlayerWatch/CoreServices.swift b/BookPlayerWatch/CoreServices.swift index c58de3006..49e8ba797 100644 --- a/BookPlayerWatch/CoreServices.swift +++ b/BookPlayerWatch/CoreServices.swift @@ -17,6 +17,7 @@ class CoreServices: ObservableObject { let playbackService: PlaybackServiceProtocol let playerManager: PlayerManager let playerLoaderService: PlayerLoaderService + let watchConnectivityService: WatchConnectivityService @Published var hasSyncEnabled = false @@ -27,7 +28,8 @@ class CoreServices: ObservableObject { libraryService: LibraryService, playbackService: PlaybackServiceProtocol, playerManager: PlayerManager, - playerLoaderService: PlayerLoaderService + playerLoaderService: PlayerLoaderService, + watchConnectivityService: WatchConnectivityService ) { self.dataManager = dataManager self.accountService = accountService @@ -37,6 +39,7 @@ class CoreServices: ObservableObject { self.hasSyncEnabled = accountService.hasSyncEnabled() self.playerManager = playerManager self.playerLoaderService = playerLoaderService + self.watchConnectivityService = watchConnectivityService } func checkAndReloadIfSyncIsEnabled() { diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index e691ff895..7ffc48013 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -98,7 +98,8 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { libraryService: libraryService, playbackService: playbackService, playerManager: playerManager, - playerLoaderService: playerLoaderService + playerLoaderService: playerLoaderService, + watchConnectivityService: ExtensionDelegate.contextManager.watchConnectivityService ) self.coreServices = coreServices diff --git a/BookPlayerWatch/Settings/Login/LoginView.swift b/BookPlayerWatch/Settings/Login/LoginView.swift index 0a277fc5d..ac2ae59a4 100644 --- a/BookPlayerWatch/Settings/Login/LoginView.swift +++ b/BookPlayerWatch/Settings/Login/LoginView.swift @@ -15,7 +15,7 @@ struct LoginView: View { @Binding var account: Account? @State private var isLoading = false @State private var error: Error? - + var body: some View { List { Text("BookPlayer Pro") @@ -29,6 +29,7 @@ struct LoginView: View { .listRowBackground(Color.clear) Spacer(minLength: Spacing.S2) .listRowBackground(Color.clear) + SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.email] } onCompletion: { result in @@ -65,6 +66,20 @@ struct LoginView: View { } .frame(maxHeight: 45) .listRowBackground(Color.clear) + Spacer(minLength: Spacing.S2) + .listRowBackground(Color.clear) + Button { + signInWithiPhone() + } label: { + HStack { + Image(systemName: "iphone") + Text("watch_signin_with_iphone".localized) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle) + .listRowBackground(Color.clear) Spacer(minLength: Spacing.M) .listRowBackground(Color.clear) } @@ -85,4 +100,29 @@ struct LoginView: View { } } } + + private func signInWithiPhone() { + Task { + do { + isLoading = true + + let authResponse = try await coreServices.watchConnectivityService.requestAuthFromiPhone() + + let account = try await coreServices.accountService.loginWithTransferredCredentials( + token: authResponse.token, + accountId: authResponse.accountId, + email: authResponse.email, + hasSubscription: authResponse.hasSubscription, + donationMade: authResponse.donationMade + ) + + isLoading = false + self.account = account + coreServices.checkAndReloadIfSyncIsEnabled() + } catch { + isLoading = false + self.error = error + } + } + } } diff --git a/BookPlayerWatch/WatchConnectivityService.swift b/BookPlayerWatch/WatchConnectivityService.swift index 9c1fc7147..23911927e 100644 --- a/BookPlayerWatch/WatchConnectivityService.swift +++ b/BookPlayerWatch/WatchConnectivityService.swift @@ -11,15 +11,29 @@ import WatchConnectivity public enum WatchConnectivityError: LocalizedError { case notReachable + case notSignedInOnPhone + case authTransferFailed(String) public var errorDescription: String? { switch self { case .notReachable: return "watchapp_connect_error_description".localized + case .notSignedInOnPhone: + return "watch_signin_phone_required".localized + case .authTransferFailed(let reason): + return "watch_signin_failed".localized + ": \(reason)" } } } +public struct WatchAuthResponse { + public let token: String + public let email: String + public let accountId: String + public let hasSubscription: Bool + public let donationMade: Bool +} + public class WatchConnectivityService: NSObject, WCSessionDelegate { public var didReceiveData: ((Data) -> Void)? public var didReceiveContext: (([String: Any]) -> Void)? @@ -117,3 +131,51 @@ extension WatchConnectivityService { self.didReceiveData?(messageData) } } + +// MARK: - Auth Transfer from iPhone + +extension WatchConnectivityService { + /// Request authentication credentials from iPhone + public func requestAuthFromiPhone() async throws -> WatchAuthResponse { + guard let session = validReachableSession, + session.activationState == .activated, + session.isReachable else { + throw WatchConnectivityError.notReachable + } + + return try await withCheckedThrowingContinuation { continuation in + let message: [String: Any] = ["command": "requestAuth"] + + session.sendMessage(message, replyHandler: { reply in + if let error = reply["error"] as? String { + switch error { + case "notSignedIn": + continuation.resume(throwing: WatchConnectivityError.notSignedInOnPhone) + default: + continuation.resume(throwing: WatchConnectivityError.authTransferFailed(error)) + } + return + } + + guard let token = reply["token"] as? String, + let email = reply["email"] as? String, + let accountId = reply["accountId"] as? String else { + continuation.resume(throwing: WatchConnectivityError.authTransferFailed("invalidResponse")) + return + } + + let response = WatchAuthResponse( + token: token, + email: email, + accountId: accountId, + hasSubscription: reply["hasSubscription"] as? Bool ?? false, + donationMade: reply["donationMade"] as? Bool ?? false + ) + + continuation.resume(returning: response) + }, errorHandler: { error in + continuation.resume(throwing: WatchConnectivityError.authTransferFailed(error.localizedDescription)) + }) + } + } +} diff --git a/Shared/BookPlayerError.swift b/Shared/BookPlayerError.swift index 4d79d348d..dd2f43c5f 100644 --- a/Shared/BookPlayerError.swift +++ b/Shared/BookPlayerError.swift @@ -11,6 +11,7 @@ import Foundation public enum BookPlayerError: Error { case runtimeError(String) case networkError(String) + case networkErrorWithCode(message: String, code: String) case cancelledTask case emptyResponse } @@ -24,6 +25,8 @@ extension BookPlayerError: LocalizedError { return "Empty network response" case .networkError(let message): return message + case .networkErrorWithCode(let message, _): + return message case .cancelledTask: return "Concurrent task was cancelled" } diff --git a/Shared/Network/NetworkClient.swift b/Shared/Network/NetworkClient.swift index 45de6779d..509344425 100644 --- a/Shared/Network/NetworkClient.swift +++ b/Shared/Network/NetworkClient.swift @@ -52,7 +52,11 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { /// Keychain service for the access token let keychain: KeychainServiceProtocol /// response decoder - private let decoder: JSONDecoder = JSONDecoder() + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() public init(keychain: KeychainServiceProtocol = KeychainService()) { self.keychain = keychain @@ -168,7 +172,11 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { switch httpURLResponse.statusCode { case 400...499: let error = try self.decoder.decode(ErrorResponse.self, from: data) - throw BookPlayerError.networkError(error.message) + if let code = error.error { + throw BookPlayerError.networkErrorWithCode(message: error.message, code: code) + } else { + throw BookPlayerError.networkError(error.message) + } default: guard !data.isEmpty else { guard @@ -206,7 +214,7 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { if let parameters = parameters { switch method { - case .post, .put, .delete: + case .post, .put, .delete, .patch: request.httpBody = try JSONSerialization.data(withJSONObject: parameters) case .get: break @@ -251,4 +259,5 @@ public class NetworkClient: NetworkClientProtocol, BPLogger { /// Default error message structure struct ErrorResponse: Decodable { let message: String + let error: String? } diff --git a/Shared/Network/NetworkProvider.swift b/Shared/Network/NetworkProvider.swift index fff94165d..ae297072c 100644 --- a/Shared/Network/NetworkProvider.swift +++ b/Shared/Network/NetworkProvider.swift @@ -8,7 +8,7 @@ import Foundation -class NetworkProvider { +public class NetworkProvider { public let client: NetworkClientProtocol public init(client: NetworkClientProtocol = NetworkClient()) { diff --git a/Shared/Network/NetworkUtils.swift b/Shared/Network/NetworkUtils.swift index 41eae61c8..ec71bcc41 100644 --- a/Shared/Network/NetworkUtils.swift +++ b/Shared/Network/NetworkUtils.swift @@ -19,6 +19,7 @@ public enum HTTPMethod: String { case post = "POST" case put = "PUT" case delete = "DELETE" + case patch = "PATCH" } /// Protocol representing an empty response. Use `T.emptyValue()` to get an instance. diff --git a/Shared/Services/Account/AccountService.swift b/Shared/Services/Account/AccountService.swift index e26f3cbb9..7fecec39c 100644 --- a/Shared/Services/Account/AccountService.swift +++ b/Shared/Services/Account/AccountService.swift @@ -90,6 +90,17 @@ public protocol AccountServiceProtocol { func logout() throws func deleteAccount() async throws -> String + func handlePasskeyLogin(response: PasskeyLoginResponse) async throws + + /// Handle credentials transferred from iPhone to Watch + func loginWithTransferredCredentials( + token: String, + accountId: String, + email: String, + hasSubscription: Bool, + donationMade: Bool + ) async throws -> Account? + func getSecondOnboarding() async throws -> T } @@ -385,6 +396,49 @@ public final class AccountService: AccountServiceProtocol { return self.getAccount() } + public func handlePasskeyLogin(response: PasskeyLoginResponse) async throws { + // Store the token + try self.keychain.set(response.token, key: .token) + + // Use revenuecat_id for RevenueCat (Apple ID if exists, otherwise public_id) + let userId = response.revenuecatId + let (customerInfo, _) = try await Purchases.shared.logIn(userId) + UserDefaults.sharedDefaults.set(userId, forKey: "rcUserId") + + // Update local account with subscription status from server + let existingDonationMade = self.getAccount()?.donationMade ?? false + self.updateAccount( + id: userId, + email: response.email, + donationMade: existingDonationMade || !customerInfo.nonSubscriptions.isEmpty, + hasSubscription: !customerInfo.activeSubscriptions.isEmpty + ) + } + + public func loginWithTransferredCredentials( + token: String, + accountId: String, + email: String, + hasSubscription: Bool, + donationMade: Bool + ) async throws -> Account? { + // Store the token + try self.keychain.set(token, key: .token) + // Log in to RevenueCat + let (customerInfo, _) = try await Purchases.shared.logIn(accountId) + UserDefaults.sharedDefaults.set(accountId, forKey: "rcUserId") + + // Update local account + self.updateAccount( + id: accountId, + email: email, + donationMade: donationMade || !customerInfo.nonSubscriptions.isEmpty, + hasSubscription: hasSubscription || !customerInfo.activeSubscriptions.isEmpty + ) + + return self.getAccount() + } + public func loginIfUserExists(delegate: PurchasesDelegate) { guard let account = self.getAccount(), !account.id.isEmpty else { Purchases.shared.delegate = delegate diff --git a/Shared/Services/Account/PasskeyModels.swift b/Shared/Services/Account/PasskeyModels.swift new file mode 100644 index 000000000..651563677 --- /dev/null +++ b/Shared/Services/Account/PasskeyModels.swift @@ -0,0 +1,218 @@ +// +// PasskeyModels.swift +// BookPlayer +// +// Created by Claude on 1/9/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import Foundation + +// MARK: - Email Verification Models + +public struct EmailVerificationSendResponse: Decodable { + public let success: Bool + public let expiresIn: Int + public let message: String? + + enum CodingKeys: String, CodingKey { + case success + case expiresIn = "expires_in" + case message + } +} + +public struct EmailVerificationCheckResponse: Decodable { + public let verified: Bool + public let verificationToken: String? + public let message: String? + + enum CodingKeys: String, CodingKey { + case verified + case verificationToken = "verification_token" + case message + } +} + +// MARK: - Registration Models + +public struct PasskeyRegistrationOptions: Decodable { + public let challenge: String + public let userId: String + public let rpId: String + public let rpName: String + public let timeout: Int + public let userName: String + public let userDisplayName: String + public let excludeCredentials: [PasskeyCredentialDescriptor]? + + enum CodingKeys: String, CodingKey { + case challenge + case userId = "user_id" + case rpId = "rp_id" + case rpName = "rp_name" + case timeout + case userName = "user_name" + case userDisplayName = "user_display_name" + case excludeCredentials = "exclude_credentials" + } +} + +public struct PasskeyCredentialDescriptor: Decodable { + public let id: String + public let type: String + public let transports: [String]? +} + +// MARK: - Authentication Models + +public struct PasskeyAuthenticationOptions: Decodable { + public let challenge: String + public let timeout: Int + public let rpId: String + public let allowCredentials: [PasskeyCredentialDescriptor]? + + enum CodingKeys: String, CodingKey { + case challenge + case timeout + case rpId = "rp_id" + case allowCredentials = "allow_credentials" + } +} + +// MARK: - Response Models + +public struct PasskeyLoginResponse: Decodable { + public let email: String + public let token: String + public let publicId: String + public let revenuecatId: String + public let hasSubscription: Bool + + enum CodingKeys: String, CodingKey { + case email + case token + case publicId = "public_id" + case revenuecatId = "revenuecat_id" + case hasSubscription = "has_subscription" + } +} + +// MARK: - Credential Management Models + +public struct PasskeyInfo: Decodable, Identifiable { + public let id: Int + public let deviceName: String? + public let deviceType: String + public let backedUp: Bool + public let lastUsedAt: Date? + public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id = "id_passkey" + case deviceName = "device_name" + case deviceType = "device_type" + case backedUp = "backed_up" + case lastUsedAt = "last_used_at" + case createdAt = "created_at" + } +} + +public struct PasskeyListResponse: Decodable { + public let passkeys: [PasskeyInfo] +} + +public struct AuthMethodInfo: Decodable, Identifiable { + public let id: Int + public let type: String + public let isPrimary: Bool + public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case type + case isPrimary = "is_primary" + case createdAt = "created_at" + } +} + +public struct AuthMethodListResponse: Decodable { + public let methods: [AuthMethodInfo] +} + +public struct PasskeySuccessResponse: Decodable { + public let success: Bool + public let message: String? +} + +// MARK: - Error Models + +public enum PasskeyError: LocalizedError { + case registrationFailed(String) + case authenticationFailed(String) + case challengeExpired + case userCancelled + case platformNotSupported + case noCredentialAvailable + case serverError(String) + case cannotDeleteLastAuthMethod + case emailVerificationFailed(String) + case emailVerificationRequired + case verificationCodeExpired + case tooManyAttempts + case emailAlreadyRegistered + + public var errorDescription: String? { + switch self { + case .registrationFailed(let message): + return "Passkey registration failed: \(message)" + case .authenticationFailed(let message): + return "Passkey authentication failed: \(message)" + case .challengeExpired: + return "The authentication challenge has expired. Please try again." + case .userCancelled: + return nil // Silent cancellation + case .platformNotSupported: + return "Passkeys are not supported on this device." + case .noCredentialAvailable: + return "No passkey found for this account." + case .serverError(let message): + return "Server error: \(message)" + case .cannotDeleteLastAuthMethod: + return "You must have at least one sign-in method." + case .emailVerificationFailed(let message): + return message + case .emailVerificationRequired: + return "Please verify your email first." + case .verificationCodeExpired: + return "Verification code expired. Please request a new one." + case .tooManyAttempts: + return "Too many attempts. Please try again later." + case .emailAlreadyRegistered: + return "An account with this email already exists." + } + } +} + +// MARK: - Base64URL Extensions + +public extension Data { + init?(base64URLEncoded string: String) { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + while base64.count % 4 != 0 { + base64.append("=") + } + + self.init(base64Encoded: base64) + } + + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +}