diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index e4717abfd..18f7f273d 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -87,7 +87,6 @@ 9F7D93942980063100EE6B7A /* AppAccount in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7D93932980063100EE6B7A /* AppAccount */; }; 9F7D939A29805DBD00EE6B7A /* AccountSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7D939929805DBD00EE6B7A /* AccountSettingView.swift */; }; 9FA6FD6229C04A8800E2312C /* TranslationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA6FD6129C04A8800E2312C /* TranslationSettingsView.swift */; }; - 9FAD85832971BF7200496AB1 /* Secret.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9FAD85822971BF7200496AB1 /* Secret.plist */; }; 9FAD858B29743F7400496AB1 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAD858A29743F7400496AB1 /* ShareViewController.swift */; }; 9FAD858E29743F7400496AB1 /* (null) in Resources */ = {isa = PBXBuildFile; }; 9FAD859229743F7400496AB1 /* IceCubesShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9FAD858829743F7400496AB1 /* IceCubesShareExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -271,7 +270,6 @@ 9F7D939529800B0300EE6B7A /* IceCubesApp-release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "IceCubesApp-release.xcconfig"; sourceTree = ""; }; 9F7D939929805DBD00EE6B7A /* AccountSettingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingView.swift; sourceTree = ""; }; 9FA6FD6129C04A8800E2312C /* TranslationSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TranslationSettingsView.swift; sourceTree = ""; }; - 9FAD85822971BF7200496AB1 /* Secret.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Secret.plist; sourceTree = ""; }; 9FAD858829743F7400496AB1 /* IceCubesShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = IceCubesShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 9FAD858A29743F7400496AB1 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 9FAD858F29743F7400496AB1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -602,7 +600,6 @@ 9FAE4AC8293774FF00772766 /* Info.plist */, 9F398AB429360A5800A889F2 /* App */, 9F398AB529360A6100A889F2 /* Resources */, - 9FAD85822971BF7200496AB1 /* Secret.plist */, ); path = IceCubesApp; sourceTree = ""; @@ -960,7 +957,6 @@ 9F18801829AE477F00D85459 /* favorite.wav in Resources */, 9F24EEB829360C330042359D /* Preview Assets.xcassets in Resources */, 069709A8298C87B5006E4CB5 /* OpenDyslexic-Regular.otf in Resources */, - 9FAD85832971BF7200496AB1 /* Secret.plist in Resources */, 9F18801229AE477F00D85459 /* tabSelection.wav in Resources */, 9F18801429AE477F00D85459 /* bookmark.wav in Resources */, 9F18801629AE477F00D85459 /* refresh.wav in Resources */, diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index 4724eb6bb..592f8285e 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -31,7 +31,9 @@ struct SettingsTabs: View { @Binding var popToRootTab: Tab let isModal: Bool - + + @State private var startingPoint: SettingsStartingPoint? = nil + var body: some View { NavigationStack(path: $routerPath.path) { Form { @@ -64,6 +66,32 @@ struct SettingsTabs: View { } .withAppRouter() .withSheetDestinations(sheetDestinations: $routerPath.presentedSheet) + .onAppear { + startingPoint = RouterPath.settingsStartingPoint + RouterPath.settingsStartingPoint = nil + } + .navigationDestination(item: $startingPoint) { targetView in + switch targetView { + case .display: + DisplaySettingsView() + case .haptic: + HapticSettingsView() + case .remoteTimelines: + RemoteTimelinesSettingView() + case .tagGroups: + TagsGroupSettingView() + case .recentTags: + RecenTagsSettingView() + case .content: + ContentSettingsView() + case .swipeActions: + SwipeActionsSettingsView() + case .tabAndSidebarEntries: + EmptyView() + case .translation: + TranslationSettingsView() + } + } } .onAppear { routerPath.client = client diff --git a/IceCubesApp/App/Tabs/Settings/TranslationSettingsView.swift b/IceCubesApp/App/Tabs/Settings/TranslationSettingsView.swift index e844cc01f..4a0c246f6 100644 --- a/IceCubesApp/App/Tabs/Settings/TranslationSettingsView.swift +++ b/IceCubesApp/App/Tabs/Settings/TranslationSettingsView.swift @@ -11,16 +11,13 @@ struct TranslationSettingsView: View { var body: some View { Form { - deepLToggle - if preferences.alwaysUseDeepl { + translationSelector + if preferences.preferredTranslationType == .useDeepl { Section("settings.translation.user-api-key") { deepLPicker SecureField("settings.translation.user-api-key", text: $apiKey) .textContentType(.password) } - .onAppear { - readValue() - } #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) #endif @@ -37,6 +34,7 @@ struct TranslationSettingsView: View { #endif } } + backgroundAPIKey autoDetectSection } .navigationTitle("settings.translation.navigation-title") @@ -48,19 +46,39 @@ struct TranslationSettingsView: View { writeNewValue() } .onAppear(perform: updatePrefs) + .onAppear(perform: readValue) } @ViewBuilder - private var deepLToggle: some View { + private var translationSelector: some View { @Bindable var preferences = preferences - Toggle(isOn: $preferences.alwaysUseDeepl) { - Text("settings.translation.always-deepl") + Picker("settings.translation.preferred-translation-type", selection: $preferences.preferredTranslationType) { + ForEach(allTTCases, id: \.self) { type in + Text(type.description).tag(type) + } } #if !os(visionOS) .listRowBackground(theme.primaryBackgroundColor) #endif } + var allTTCases: [TranslationType] { + TranslationType.allCases.filter { type in + if type != .useApple { + return true + } + #if canImport(_Translation_SwiftUI) + if #available(iOS 17.4, *) { + return true + } else { + return false + } + #else + return false + #endif + } + } + @ViewBuilder private var deepLPicker: some View { @Bindable var preferences = preferences @@ -80,6 +98,34 @@ struct TranslationSettingsView: View { } footer: { Text("settings.translation.auto-detect-post-language-footer") } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + } + + @ViewBuilder + private var backgroundAPIKey: some View { + if preferences.preferredTranslationType != .useDeepl, + !apiKey.isEmpty + { + Section { + Text("The DeepL API Key is still stored!") + if preferences.preferredTranslationType == .useServerIfPossible { + Text("It can however still be used as a fallback for your instance's translation service.") + } + Button(role: .destructive) { + withAnimation { + writeNewValue(value: "") + readValue() + } + } label: { + Text("action.delete") + } + } + #if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) + #endif + } } private func writeNewValue() { @@ -91,11 +137,7 @@ struct TranslationSettingsView: View { } private func readValue() { - if let apiKey = DeepLUserAPIHandler.readIfAllowed() { - self.apiKey = apiKey - } else { - apiKey = "" - } + apiKey = DeepLUserAPIHandler.readKey() } private func updatePrefs() { diff --git a/IceCubesApp/Resources/Localization/Localizable.xcstrings b/IceCubesApp/Resources/Localization/Localizable.xcstrings index 46e0bc2a5..f1c094fd0 100644 --- a/IceCubesApp/Resources/Localization/Localizable.xcstrings +++ b/IceCubesApp/Resources/Localization/Localizable.xcstrings @@ -18412,6 +18412,126 @@ } } }, + "action.cancel" : { + "comment" : "MARK: Common strings", + "extractionState" : "stale", + "localizations" : { + "be" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скасаваць" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel·la" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "eu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utzi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "취소" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuleer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İptal Et" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Відміна" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, "action.delete" : { "extractionState" : "manual", "localizations" : { @@ -22024,6 +22144,23 @@ } } }, + "DeepL couldn't be reached!\nIs the API Key correct?" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DeepL konnte nicht erreicht werden! Ist der API-Schlüssel korrekt?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DeepL couldn't be reached!\nIs the API Key correct?" + } + } + } + }, "design.tag.n-posts-from-n-participants %lld %lld" : { "comment" : "MARK: Package: DesignSystem", "extractionState" : "manual", @@ -30807,6 +30944,17 @@ } } }, + "Instance" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instanz" + } + } + } + }, "instance.info.domains" : { "comment" : "MARK: Instances", "localizations" : { @@ -32342,6 +32490,20 @@ } } }, + "It can however still be used as a fallback for your instance's translation service." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er kann aber auch als Fallback für den Übersetzungsservice deiner Instanz dienen." + } + } + } + }, + "Key" : { + "extractionState" : "manual" + }, "list.edit.isExclusive" : { "extractionState" : "manual", "localizations" : { @@ -59866,124 +60028,6 @@ } } }, - "settings.translation.always-deepl" : { - "localizations" : { - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Always Translate using DeepL" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Always Translate using DeepL" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Immer mit DeepL übersetzen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Always Translate using DeepL" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Always Translate using DeepL" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traducir siempre con DeepL" - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Itzuli beti DeepL erabiliz" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Always Translate using DeepL" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Traduci sempre con DeepL" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "DeepLを使用して常に翻訳する" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "항상 DeepL을 통해 번역" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Oversett alltid med DeepL" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vertaal altijd met DeepL" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zawsze tłumacz przy pomocy DeepL" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sempre traduzir utilizando DeepL" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Her Zaman DeepL kullanarak Çevir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Завжди перекладати за допомогою DeepL" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "总是使用 DeepL 翻译" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "一概用 DeepL 翻譯" - } - } - } - }, "settings.translation.api-key-type" : { "localizations" : { "be" : { @@ -60460,120 +60504,136 @@ "localizations" : { "be" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "This feature requires a DeepL API key" } }, "ca" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "This feature requires a DeepL API key" } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Für diese Funktion ist ein DeepL-API-Schlüssel erforderlich." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "This feature requires a DeepL API key" + "value" : "This feature requires a DeepL API Key" } }, "en-GB" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "This feature requires a DeepL API key" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Esta funcionalidad requiere una clave API de DeepL" } }, "eu" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Ezaugarri honek DeepL API gako bat behar du" } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "This feature requires a DeepL API key" } }, "it" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Questa funzione richiede una chiave API DeepL" } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "この機能には DeepL APIキーが必要です" } }, "ko" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "이 기능을 사용하려면 DeepL에서 발급받은 API 키가 있어야 합니다." } }, "nb" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Denne funksjonen krever en DeepL API-nøkkel" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Deze functionaliteit vereist een DeepL API-sleutel" } }, "pl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Ta funkcja wymaga klucza DeepL API" } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Esta funcionalidade requer uma chave de API DeepL" } }, "tr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Bu özellik bir DeepL API anahtarı gerektirir" } }, "uk" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Ця функція потребує ключа DeepL API key" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "该功能需要 DeepL API 密钥" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "本功能需有 DeepL API 密鑰" } } } }, + "settings.translation.preferred-translation-type" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Übersetzungs-Service" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Translation Service" + } + } + } + }, "settings.translation.user-api-key" : { "localizations" : { "be" : { @@ -76826,6 +76886,28 @@ } } }, + "The DeepL API Key is still stored!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der DeepL-API-Schlüssel ist noch gespeichert!" + } + } + } + }, + "The Translation Service of your Instance couldn't be reached!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Übersetzung-Service deiner Instanz konnte nicht erreicht werden!" + } + } + } + }, "timeline-new-posts %lld" : { "comment" : "Turkish does not have \"-ler\" on words in the sentence, so it follows the singular word.", "extractionState" : "manual", @@ -81149,126 +81231,6 @@ } } } - }, - "action.cancel" : { - "comment" : "MARK: Common strings", - "extractionState" : "stale", - "localizations" : { - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скасаваць" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel·la" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abbrechen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" - } - }, - "en-GB" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancel" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Utzi" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuler" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annulla" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャンセル" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "취소" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Avbryt" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuleer" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anuluj" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İptal Et" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відміна" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" - } - } - } }, "Visibility" : { "localizations" : { @@ -81292,4 +81254,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/IceCubesApp/Secret.plist b/IceCubesApp/Secret.plist deleted file mode 100644 index 0a3d92585..000000000 --- a/IceCubesApp/Secret.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - DEEPL_SECRET - NICE_TRY_AGAIN - - diff --git a/Packages/Account/Sources/Account/AccountDetailViewModel.swift b/Packages/Account/Sources/Account/AccountDetailViewModel.swift index 81c855a45..d5bb7b089 100644 --- a/Packages/Account/Sources/Account/AccountDetailViewModel.swift +++ b/Packages/Account/Sources/Account/AccountDetailViewModel.swift @@ -280,7 +280,7 @@ import SwiftUI isLoadingTranslation = true } - let userAPIKey = DeepLUserAPIHandler.readIfAllowed() + let userAPIKey = DeepLUserAPIHandler.readKeyIfAllowed() let userAPIFree = UserPreferences.shared.userDeeplAPIFree let deeplClient = DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIFree) diff --git a/Packages/Env/Sources/Env/DeepLUserAPIHandler.swift b/Packages/Env/Sources/Env/DeepLUserAPIHandler.swift index 410265f1b..7225bf257 100644 --- a/Packages/Env/Sources/Env/DeepLUserAPIHandler.swift +++ b/Packages/Env/Sources/Env/DeepLUserAPIHandler.swift @@ -23,22 +23,30 @@ public enum DeepLUserAPIHandler { } } - public static func readIfAllowed() -> String? { - guard UserPreferences.shared.alwaysUseDeepl else { return nil } + public static func readKeyIfAllowed() -> String? { + guard UserPreferences.shared.preferredTranslationType == .useDeepl else { return nil } - return readValue() + return readKeyInternal() } - private static func readValue() -> String? { + public static func readKey() -> String { + return readKeyInternal() ?? "" + } + + private static func readKeyInternal() -> String? { keychain.synchronizable = true return keychain.get(key) } public static func deactivateToggleIfNoKey() { - UserPreferences.shared.alwaysUseDeepl = shouldAlwaysUseDeepl + if UserPreferences.shared.preferredTranslationType == .useDeepl { + if readKeyInternal() == nil { + UserPreferences.shared.preferredTranslationType = .useServerIfPossible + } + } } public static var shouldAlwaysUseDeepl: Bool { - readIfAllowed() != nil + readKeyIfAllowed() != nil } } diff --git a/Packages/Env/Sources/Env/Router.swift b/Packages/Env/Sources/Env/Router.swift index b0e325131..6827d2952 100644 --- a/Packages/Env/Sources/Env/Router.swift +++ b/Packages/Env/Sources/Env/Router.swift @@ -115,6 +115,18 @@ public enum SheetDestination: Identifiable, Hashable { } } +public enum SettingsStartingPoint { + case display + case haptic + case remoteTimelines + case tagGroups + case recentTags + case content + case swipeActions + case tabAndSidebarEntries + case translation +} + @MainActor @Observable public class RouterPath { public var client: Client? @@ -123,6 +135,8 @@ public enum SheetDestination: Identifiable, Hashable { public var path: [RouterDestination] = [] public var presentedSheet: SheetDestination? + public static var settingsStartingPoint: SettingsStartingPoint? = nil + public init() {} public func navigate(to: RouterDestination) { diff --git a/Packages/Env/Sources/Env/TranslationType.swift b/Packages/Env/Sources/Env/TranslationType.swift new file mode 100644 index 000000000..70631c1e1 --- /dev/null +++ b/Packages/Env/Sources/Env/TranslationType.swift @@ -0,0 +1,18 @@ +import SwiftUI + +public enum TranslationType: String, CaseIterable { + case useServerIfPossible + case useDeepl + case useApple + + public var description: LocalizedStringKey { + switch self { + case .useServerIfPossible: + "Instance" + case .useDeepl: + "DeepL" + case .useApple: + "Apple Translate" + } + } +} diff --git a/Packages/Env/Sources/Env/UserPreferences.swift b/Packages/Env/Sources/Env/UserPreferences.swift index b7a55ef45..9233e26af 100644 --- a/Packages/Env/Sources/Env/UserPreferences.swift +++ b/Packages/Env/Sources/Env/UserPreferences.swift @@ -25,7 +25,7 @@ import SwiftUI @AppStorage("app_require_alt_text") public var appRequireAltText = false @AppStorage("autoplay_video") public var autoPlayVideo = true @AppStorage("mute_video") public var muteVideo = true - @AppStorage("always_use_deepl") public var alwaysUseDeepl = false + @AppStorage("preferred_translation_type") public var preferredTranslationType = TranslationType.useServerIfPossible @AppStorage("user_deepl_api_free") public var userDeeplAPIFree = true @AppStorage("auto_detect_post_language") public var autoDetectPostLanguage = true @@ -60,10 +60,33 @@ import SwiftUI @AppStorage("show_reply_indentation") public var showReplyIndentation: Bool = true @AppStorage("show_account_popover") public var showAccountPopover: Bool = true - + @AppStorage("sidebar_expanded") public var isSidebarExpanded: Bool = false - init() {} + init() { + prepareTranslationType() + } + + private func prepareTranslationType() { + let sharedDefault = UserDefaults.standard + if let alwaysUseDeepl = (sharedDefault.object(forKey: "always_use_deepl") as? Bool) { + if alwaysUseDeepl { + preferredTranslationType = .useDeepl + } + sharedDefault.removeObject(forKey: "always_use_deepl") + } + #if canImport(_Translation_SwiftUI) + if #unavailable(iOS 17.4), + preferredTranslationType == .useApple + { + preferredTranslationType = .useServerIfPossible + } + #else + if preferredTranslationType == .useApple { + preferredTranslationType = .useServerIfPossible + } + #endif + } } public static let sharedDefault = UserDefaults(suiteName: "group.com.thomasricouard.IceCubesApp") @@ -185,9 +208,9 @@ import SwiftUI } } - public var alwaysUseDeepl: Bool { + public var preferredTranslationType: TranslationType { didSet { - storage.alwaysUseDeepl = alwaysUseDeepl + storage.preferredTranslationType = preferredTranslationType } } @@ -328,7 +351,7 @@ import SwiftUI storage.showAccountPopover = showAccountPopover } } - + public var isSidebarExpanded: Bool { didSet { storage.isSidebarExpanded = isSidebarExpanded @@ -482,7 +505,7 @@ import SwiftUI appDefaultPostsSensitive = storage.appDefaultPostsSensitive appRequireAltText = storage.appRequireAltText autoPlayVideo = storage.autoPlayVideo - alwaysUseDeepl = storage.alwaysUseDeepl + preferredTranslationType = storage.preferredTranslationType userDeeplAPIFree = storage.userDeeplAPIFree autoDetectPostLanguage = storage.autoDetectPostLanguage inAppBrowserReaderView = storage.inAppBrowserReaderView diff --git a/Packages/Network/Sources/Network/DeepLClient.swift b/Packages/Network/Sources/Network/DeepLClient.swift index 9a2c0394d..d3ba47a83 100644 --- a/Packages/Network/Sources/Network/DeepLClient.swift +++ b/Packages/Network/Sources/Network/DeepLClient.swift @@ -12,20 +12,8 @@ public struct DeepLClient: Sendable { "https://api\(deeplUserAPIFree && (deeplUserAPIKey != nil) ? "-free" : "").deepl.com/v2/translate" } - private var APIKey: String { - if let deeplUserAPIKey { - return deeplUserAPIKey - } - - if let path = Bundle.main.path(forResource: "Secret", ofType: "plist") { - let secret = NSDictionary(contentsOfFile: path) - return secret?["DEEPL_SECRET"] as? String ?? "" - } - return "" - } - private var authorizationHeaderValue: String { - "DeepL-Auth-Key \(APIKey)" + "DeepL-Auth-Key \(deeplUserAPIKey ?? "")" } public struct Response: Decodable { @@ -49,26 +37,22 @@ public struct DeepLClient: Sendable { } public func request(target: String, text: String) async throws -> Translation { - do { - var components = URLComponents(string: endpoint)! - var queryItems: [URLQueryItem] = [] - queryItems.append(.init(name: "text", value: text)) - queryItems.append(.init(name: "target_lang", value: target.uppercased())) - components.queryItems = queryItems - var request = URLRequest(url: components.url!) - request.httpMethod = "POST" - request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization") - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let (result, _) = try await URLSession.shared.data(for: request) - let response = try decoder.decode(Response.self, from: result) - if let translation = response.translations.first { - return .init(content: translation.text.removingPercentEncoding ?? "", - detectedSourceLanguage: translation.detectedSourceLanguage, - provider: "DeepL.com") - } - throw DeepLError.notFound - } catch { - throw error + var components = URLComponents(string: endpoint)! + var queryItems: [URLQueryItem] = [] + queryItems.append(.init(name: "text", value: text)) + queryItems.append(.init(name: "target_lang", value: target.uppercased())) + components.queryItems = queryItems + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let (result, _) = try await URLSession.shared.data(for: request) + let response = try decoder.decode(Response.self, from: result) + if let translation = response.translations.first { + return .init(content: translation.text.removingPercentEncoding ?? "", + detectedSourceLanguage: translation.detectedSourceLanguage, + provider: "DeepL.com") } + throw DeepLError.notFound } } diff --git a/Packages/StatusKit/Sources/StatusKit/Editor/Components/MediaEditView.swift b/Packages/StatusKit/Sources/StatusKit/Editor/Components/MediaEditView.swift index 0e68ac287..fabb31796 100644 --- a/Packages/StatusKit/Sources/StatusKit/Editor/Components/MediaEditView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Editor/Components/MediaEditView.swift @@ -161,7 +161,7 @@ extension StatusEditor { private func translateDescription() async -> String? { isTranslating = true - let userAPIKey = DeepLUserAPIHandler.readIfAllowed() + let userAPIKey = DeepLUserAPIHandler.readKeyIfAllowed() let userAPIFree = UserPreferences.shared.userDeeplAPIFree let deeplClient = DeepLClient(userAPIKey: userAPIKey, userAPIFree: userAPIFree) let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift index 791b541e1..056aae635 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift @@ -5,6 +5,19 @@ import Foundation import Models import Network import SwiftUI +#if canImport(_Translation_SwiftUI) + import Translation + + extension View { + func addTranslateView(isPresented: Binding, text: String) -> some View { + if #available(iOS 17.4, *) { + return self.translationPresentation(isPresented: isPresented, text: text) + } else { + return self + } + } + } +#endif @MainActor public struct StatusRowView: View { @@ -15,6 +28,7 @@ public struct StatusRowView: View { @Environment(\.accessibilityVoiceOverEnabled) private var accessibilityVoiceOverEnabled @Environment(\.isStatusFocused) private var isFocused @Environment(\.indentationLevel) private var indentationLevel + @Environment(RouterPath.self) private var routerPath: RouterPath @Environment(QuickLook.self) private var quickLook @Environment(Theme.self) private var theme @@ -219,6 +233,23 @@ public struct StatusRowView: View { StatusDataControllerProvider.shared.dataController(for: viewModel.finalStatus, client: viewModel.client) ) + .alert("DeepL couldn't be reached!\nIs the API Key correct?", isPresented: $viewModel.deeplTranslationError) { + Button("alert.button.ok", role: .cancel) {} + Button("settings.general.translate") { + RouterPath.settingsStartingPoint = .translation + routerPath.presentedSheet = .settings + } + } + .alert("The Translation Service of your Instance couldn't be reached!", isPresented: $viewModel.instanceTranslationError) { + Button("alert.button.ok", role: .cancel) {} + Button("settings.general.translate") { + RouterPath.settingsStartingPoint = .translation + routerPath.presentedSheet = .settings + } + } + #if canImport(_Translation_SwiftUI) + .addTranslateView(isPresented: $viewModel.showAppleTranslation, text: viewModel.finalStatus.content.asRawText) + #endif } @ViewBuilder diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift index ad47c332f..03946b98c 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift @@ -31,6 +31,18 @@ import SwiftUI var translation: Translation? var isLoadingTranslation: Bool = false var showDeleteAlert: Bool = false + var showAppleTranslation = false + var preferredTranslationType = TranslationType.useServerIfPossible { + didSet { + if oldValue != preferredTranslationType { + translation = nil + showAppleTranslation = false + } + } + } + + var deeplTranslationError = false + var instanceTranslationError = false private(set) var actionsAccountsFetched: Bool = false var favoriters: [Account] = [] @@ -297,25 +309,43 @@ import SwiftUI } func translate(userLang: String) async { - withAnimation { - isLoadingTranslation = true - } - if !alwaysTranslateWithDeepl { - do { - // We first use instance translation API if available. - let translation: Translation = try await client.post(endpoint: Statuses.translate(id: finalStatus.id, - lang: userLang)) - withAnimation { - self.translation = translation - isLoadingTranslation = false - } - - return - } catch {} + updatePreferredTranslation() + if preferredTranslationType == .useApple { + showAppleTranslation = true + return } - // If not or fail we use Ice Cubes own DeepL client. - await translateWithDeepL(userLang: userLang) + if preferredTranslationType != .useDeepl { + await translateWithInstance(userLang: userLang) + + if translation == nil { + await translateWithDeepL(userLang: userLang) + } + } else { + await translateWithDeepL(userLang: userLang) + + if translation == nil { + await translateWithInstance(userLang: userLang) + } + } + + var hasShown = false +#if canImport(_Translation_SwiftUI) + if translation == nil, + #available(iOS 17.4, *) { + showAppleTranslation = true + hasShown = true + } +#endif + + if !hasShown, + translation == nil { + if preferredTranslationType == .useDeepl { + deeplTranslationError = true + } else { + instanceTranslationError = true + } + } } func translateWithDeepL(userLang: String) async { @@ -331,6 +361,20 @@ import SwiftUI isLoadingTranslation = false } } + + func translateWithInstance(userLang: String) async { + withAnimation { + isLoadingTranslation = true + } + + let translation: Translation? = try? await client.post(endpoint: Statuses.translate(id: finalStatus.id, + lang: userLang)) + + withAnimation { + self.translation = translation + isLoadingTranslation = false + } + } private func getDeepLClient() -> DeepLClient { let userAPIfree = UserPreferences.shared.userDeeplAPIFree @@ -339,11 +383,17 @@ import SwiftUI } private var userAPIKey: String? { - DeepLUserAPIHandler.readIfAllowed() + DeepLUserAPIHandler.readKey() } - var alwaysTranslateWithDeepl: Bool { - DeepLUserAPIHandler.shouldAlwaysUseDeepl + func updatePreferredTranslation() { + if DeepLUserAPIHandler.shouldAlwaysUseDeepl { + preferredTranslationType = .useDeepl + } else if UserPreferences.shared.preferredTranslationType == .useApple { + preferredTranslationType = .useApple + } else { + preferredTranslationType = .useServerIfPossible + } } func fetchRemoteStatus() async -> Bool { diff --git a/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowTranslateView.swift b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowTranslateView.swift index a1a2fed4f..222f17698 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowTranslateView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/Subviews/StatusRowTranslateView.swift @@ -35,7 +35,8 @@ struct StatusRowTranslateView: View { } } - var body: some View { + @ViewBuilder + var translateButton: some View { if !isInCaptureMode, !isCompact, let userLang = preferences.serverPreferences?.postLanguage, @@ -54,8 +55,22 @@ struct StatusRowTranslateView: View { } .buttonStyle(.borderless) } + } + + @ViewBuilder + var generalTranslateButton: some View { + translateButton + } + + var body: some View { + generalTranslateButton + .onChange(of: preferences.preferredTranslationType) { _, _ in + withAnimation { + _ = viewModel.updatePreferredTranslation() + } + } - if let translation = viewModel.translation, !viewModel.isLoadingTranslation { + if let translation = viewModel.translation, !viewModel.isLoadingTranslation, preferences.preferredTranslationType != .useApple { GroupBox { VStack(alignment: .leading, spacing: 4) { Text(translation.content.asSafeMarkdownAttributedString)