diff --git a/fastlane/metadata/android/en-US/changelogs/46.txt b/fastlane/metadata/android/en-US/changelogs/46.txt index 6b8a7e10..067e156b 100644 --- a/fastlane/metadata/android/en-US/changelogs/46.txt +++ b/fastlane/metadata/android/en-US/changelogs/46.txt @@ -2,3 +2,6 @@ * The released apk files also include apk files built for older Android KitKat (version 4.4.x). * Fix issue #309 Toolbar icons are not aligned. * PR #311 Add search button to profile view to find tweets from current user (thanks @alotbsol555). +* PR #319 Allow to specify custom LibreTranslate instances (thanks @zihu12). + The list can be reordered, instances can be added or removed from the list. + An API key can be set for each instance. diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt index 6b8a7e10..067e156b 100644 --- a/fastlane/metadata/android/en-US/changelogs/default.txt +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -2,3 +2,6 @@ * The released apk files also include apk files built for older Android KitKat (version 4.4.x). * Fix issue #309 Toolbar icons are not aligned. * PR #311 Add search button to profile view to find tweets from current user (thanks @alotbsol555). +* PR #319 Allow to specify custom LibreTranslate instances (thanks @zihu12). + The list can be reordered, instances can be added or removed from the list. + An API key can be set for each instance. diff --git a/lib/constants.dart b/lib/constants.dart index 06b7a36c..5a4a3d54 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -42,6 +42,8 @@ const optionThemeMode = 'theme.mode'; const optionThemeTrueBlack = 'theme.true_black'; const optionThemeColorScheme = 'theme.color_scheme'; +const optionTranslators = 'translators'; + const optionTweetsHideSensitive = 'tweets.hide_sensitive'; const optionUserTrendsLocations = 'trends.locations'; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index bd68c090..24f1047b 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -109,6 +109,7 @@ class MessageLookup extends MessageLookupByLibrary { "an_update_for_fritter_is_available": MessageLookupByLibrary.simpleMessage( "An update for Squawker is available! 🚀"), + "api_key": MessageLookupByLibrary.simpleMessage("API key"), "app_info": MessageLookupByLibrary.simpleMessage("App Info"), "are_you_sure": MessageLookupByLibrary.simpleMessage("Are you sure?"), "are_you_sure_you_want_to_delete_the_subscription_group_name_of_group": @@ -285,6 +286,8 @@ class MessageLookup extends MessageLookupByLibrary { "let_the_developers_know_if_something_is_broken": MessageLookupByLibrary.simpleMessage( "Let the developers know if something\'s broken"), + "libre_translate_host": + MessageLookupByLibrary.simpleMessage("LibreTranslate host"), "licenses": MessageLookupByLibrary.simpleMessage("Licenses"), "light": MessageLookupByLibrary.simpleMessage("Light"), "live": MessageLookupByLibrary.simpleMessage("LIVE"), @@ -481,6 +484,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "To import subscriptions from an existing Twitter/X account, enter your username below."), "toggle_all": MessageLookupByLibrary.simpleMessage("Toggle All"), + "translator_label": MessageLookupByLibrary.simpleMessage("Translator"), + "translators_description": MessageLookupByLibrary.simpleMessage( + "Use custom LibreTranslate instances"), + "translators_label": + MessageLookupByLibrary.simpleMessage("Translators"), "trending": MessageLookupByLibrary.simpleMessage("Trending"), "trends": MessageLookupByLibrary.simpleMessage("Trends"), "true_black": MessageLookupByLibrary.simpleMessage("True Black?"), diff --git a/lib/generated/intl/messages_fr.dart b/lib/generated/intl/messages_fr.dart index 4117a216..64e7dc41 100644 --- a/lib/generated/intl/messages_fr.dart +++ b/lib/generated/intl/messages_fr.dart @@ -115,6 +115,7 @@ class MessageLookup extends MessageLookupByLibrary { "an_update_for_fritter_is_available": MessageLookupByLibrary.simpleMessage( "Une mise à jour pour Squawker est disponible ! 🚀"), + "api_key": MessageLookupByLibrary.simpleMessage("Clé d\'API"), "app_info": MessageLookupByLibrary.simpleMessage("Infos sur l’app"), "are_you_sure": MessageLookupByLibrary.simpleMessage("Êtes-vous sûr ?"), "are_you_sure_you_want_to_delete_the_subscription_group_name_of_group": @@ -297,6 +298,8 @@ class MessageLookup extends MessageLookupByLibrary { "let_the_developers_know_if_something_is_broken": MessageLookupByLibrary.simpleMessage( "Faites savoir aux développeurs si quelque chose est défectueux"), + "libre_translate_host": + MessageLookupByLibrary.simpleMessage("Hôte LibreTranslate"), "licenses": MessageLookupByLibrary.simpleMessage("Licences"), "light": MessageLookupByLibrary.simpleMessage("Clair"), "live": MessageLookupByLibrary.simpleMessage("EN DIRECT"), @@ -498,6 +501,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "Pour importer des abonnements depuis un compte Twitter/X existant, saisissez votre nom d\'utilisateur ci-dessous."), "toggle_all": MessageLookupByLibrary.simpleMessage("Tout basculer"), + "translator_label": MessageLookupByLibrary.simpleMessage("Traducteur"), + "translators_description": MessageLookupByLibrary.simpleMessage( + "Spécifier les hôtes LibreTranslate"), + "translators_label": + MessageLookupByLibrary.simpleMessage("Traducteurs"), "trending": MessageLookupByLibrary.simpleMessage("Tendances"), "trends": MessageLookupByLibrary.simpleMessage("Tendances"), "true_black": MessageLookupByLibrary.simpleMessage("Vrai noir ?"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 10e3af66..75158e78 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2946,6 +2946,56 @@ class L10n { ); } + /// `Translators` + String get translators_label { + return Intl.message( + 'Translators', + name: 'translators_label', + desc: '', + args: [], + ); + } + + /// `Use custom LibreTranslate instances` + String get translators_description { + return Intl.message( + 'Use custom LibreTranslate instances', + name: 'translators_description', + desc: '', + args: [], + ); + } + + /// `Translator` + String get translator_label { + return Intl.message( + 'Translator', + name: 'translator_label', + desc: '', + args: [], + ); + } + + /// `LibreTranslate host` + String get libre_translate_host { + return Intl.message( + 'LibreTranslate host', + name: 'libre_translate_host', + desc: '', + args: [], + ); + } + + /// `API key` + String get api_key { + return Intl.message( + 'API key', + name: 'api_key', + desc: '', + args: [], + ); + } + /// `Proxy` String get proxy_label { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 63210c65..ba82c8c0 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -571,6 +571,16 @@ "@allow_background_play_other_apps_label": {}, "allow_background_play_other_apps_description": "Allow other apps to play in the background", "@allow_background_play_other_apps_description": {}, + "translators_label": "Translators", + "@translators_label": {}, + "translators_description": "Use custom LibreTranslate instances", + "@translators_description": {}, + "translator_label": "Translator", + "@translator_label": {}, + "libre_translate_host": "LibreTranslate host", + "@libre_translate_host": {}, + "api_key": "API key", + "@api_key": {}, "proxy_label": "Proxy", "@proxy_label": {}, "proxy_description": "Proxy for all requests", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index b5a24a8e..bd00fe22 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -601,6 +601,16 @@ "@allow_background_play_other_apps_label": {}, "allow_background_play_other_apps_description": "Permettre aux apps externes de jouer en arrière-plan", "@allow_background_play_other_apps_description": {}, + "translators_label": "Traducteurs", + "@translators_label": {}, + "translators_description": "Spécifier les hôtes LibreTranslate", + "@translators_description": {}, + "translator_label": "Traducteur", + "@translator_label": {}, + "libre_translate_host": "Hôte LibreTranslate", + "@libre_translate_host": {}, + "api_key": "Clé d'API", + "@api_key": {}, "proxy_label": "Proxy", "@proxy_label": {}, "proxy_description": "Proxy pour toutes les requêtes", diff --git a/lib/profile/profile.dart b/lib/profile/profile.dart index a16adb0b..d698a1f5 100644 --- a/lib/profile/profile.dart +++ b/lib/profile/profile.dart @@ -526,7 +526,7 @@ class _ProfileScreenBodyState extends State with TickerProvid IconButton( icon: const Icon(Symbols.search), color: Colors.white, - onPressed: () => pushNamedRoute(context, routeSearch, SearchArguments(1, focusInputOnOpen: true, query: 'from:@${(user.screenName!)} ')), + onPressed: () => pushNamedRoute(context, routeSearch, SearchArguments(1, focusInputOnOpen: true, query: 'from:@${user.screenName!} ')), ), FollowButton(user: UserSubscription.fromUser(user), color: Colors.white), ], diff --git a/lib/settings/_general.dart b/lib/settings/_general.dart index fd350008..491db554 100644 --- a/lib/settings/_general.dart +++ b/lib/settings/_general.dart @@ -2,6 +2,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:flutter_swipe_action_cell/flutter_swipe_action_cell.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:squawker/client/app_http_client.dart'; import 'package:squawker/constants.dart'; @@ -12,6 +13,7 @@ import 'package:squawker/profile/profile.dart'; import 'package:squawker/ui/errors.dart'; import 'package:squawker/utils/iterables.dart'; import 'package:squawker/utils/misc.dart'; +import 'package:squawker/utils/translation.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pref/pref.dart'; @@ -198,6 +200,27 @@ class SettingsGeneralFragment extends StatelessWidget { subtitle: Text(L10n.of(context).share_base_url_description), dialog: _createShareBaseDialog(context), ), + PrefChevron( + title: Text(L10n.of(context).translators_label), + subtitle: Text(L10n.of(context).translators_description), + onTap: () async { + BasePrefService prefs = PrefService.of(context); + List> translationHosts = TranslationAPI.readTranslationHosts(translationHosts: prefs.get(optionTranslators)); + var result = await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return Dialog( + child: TranslatorsList(translationHosts), + ); + } + ); + if (result == true) { + String s = TranslationAPI.updateTranslationHosts(translationHosts); + prefs.set(optionTranslators, s); + } + }, + ), PrefDialogButton( title: Text(L10n.of(context).proxy_label), subtitle: Text(L10n.of(context).proxy_description), @@ -541,3 +564,249 @@ class _DynamicTextfieldState extends State { ); } } + +class TranslatorsList extends StatefulWidget { + final List> initialValue; + + TranslatorsList(this.initialValue, {super.key}); + + @override + State createState() => _TranslatorsListState(); +} + +class _TranslatorsListState extends State { + + late List> _translationHosts; + + @override + void initState() { + super.initState(); + _translationHosts = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: Theme.of(context).secondaryHeaderColor, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + padding: EdgeInsets.all(10), + child: Column( + children: [ + Text(L10n.current.translators_label, + style: TextStyle(fontSize: Theme.of(context).textTheme.headlineMedium!.fontSize) + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 100, + child: + PrefButton( + child: Icon(Symbols.add), + onTap: () async { + Map trHost = { + 'host': null, + 'api_key': null + }; + var result = await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return Dialog( + child: Translator(trHost), + ); + } + ); + if (result == true) { + setState(() { + _translationHosts.add(trHost); + }); + } + } + ), + ) + ], + ), + Expanded( + child: ReorderableListView.builder( + shrinkWrap: true, + itemCount: _translationHosts.length, + itemBuilder: (BuildContext context, int index) { + return SwipeActionCell( + key: Key(_translationHosts[index]['host']), + trailingActions: [ + SwipeAction( + title: L10n.current.delete, + onTap: (CompletionHandler handler) async { + setState(() { + _translationHosts.removeAt(index); + }); + }, + color: Colors.red + ), + ], + child: Card( + child: ListTile( + title: Text(_translationHosts[index]['host'], + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: Theme.of(context).textTheme.labelMedium!.fontSize)), + trailing: IconButton( + icon: const Icon(Symbols.edit, size: 20), + onPressed: () async { + Map trHost = _translationHosts[index]; + var result = await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return Dialog( + child: Translator(trHost), + ); + } + ); + if (result == true) { + setState(() { + }); + } + } + ), + ) + )); + }, + onReorder: (oldIndex, newIndex) async { + Map trHost = _translationHosts.removeAt(oldIndex); + if (oldIndex < newIndex) { + _translationHosts.insert(newIndex - 1, trHost); + } else { + _translationHosts.insert(newIndex, trHost); + } + } + ) + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 100, + child: + ElevatedButton( + child: Text(L10n.current.cancel), + onPressed: () { + Navigator.pop(context, null); + } + ), + ), + SizedBox( + width: 100, + child: + ElevatedButton( + child:Text(L10n.current.save), + onPressed: () { + Navigator.pop(context, true); + } + ), + ) + ], + ), + ] + ) + ); + } + +} + +class Translator extends StatefulWidget { + final Map translationHost; + + Translator(this.translationHost, {super.key}); + + @override + State createState() => _TranslatorState(); +} + +class _TranslatorState extends State { + + late bool _saveEnabled; + late TextEditingController controllerHost; + late TextEditingController controllerApiKey; + + @override + void initState() { + super.initState(); + _saveEnabled = widget.translationHost['host']?.isNotEmpty ?? false; + controllerHost = TextEditingController(text: widget.translationHost['host']); + controllerApiKey = TextEditingController(text: widget.translationHost['api_key']); + } + + @override + Widget build(BuildContext context) { + MediaQueryData mediaQuery = MediaQuery.of(context); + return Container( + decoration: BoxDecoration( + shape: BoxShape.rectangle, + color: Theme.of(context).secondaryHeaderColor, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + padding: EdgeInsets.all(10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(L10n.current.translator_label, + style: TextStyle(fontSize: Theme.of(context).textTheme.headlineMedium!.fontSize) + ), + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controllerHost, + decoration: InputDecoration(hintText: L10n.current.libre_translate_host, hintStyle: TextStyle(color: Theme.of(context).disabledColor)), + onChanged: (String text) { + setState(() { + _saveEnabled = text.trim().isNotEmpty; + }); + }, + ), + ), + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controllerApiKey, + decoration: InputDecoration(hintText: L10n.current.api_key, hintStyle: TextStyle(color: Theme.of(context).disabledColor)), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 100, + child: + ElevatedButton( + child: Text(L10n.current.cancel), + onPressed: () { + Navigator.pop(context, null); + } + ), + ), + SizedBox( + width: 100, + child: + ElevatedButton( + child:Text(L10n.current.save, style: TextStyle(color: _saveEnabled ? Theme.of(context).textTheme.labelMedium!.color : Theme.of(context).disabledColor)), + onPressed: () { + if (!_saveEnabled) { + return; + } + widget.translationHost['host'] = controllerHost.text; + widget.translationHost['api_key'] = controllerApiKey.text.isEmpty ? null : controllerApiKey.text; + Navigator.pop(context, true); + } + ), + ) + ], + ), + ] + ) + ); + } +} diff --git a/lib/utils/translation.dart b/lib/utils/translation.dart index 74a8c645..cdcf88fc 100644 --- a/lib/utils/translation.dart +++ b/lib/utils/translation.dart @@ -34,47 +34,88 @@ class TranslationAPI { // translate.astian.org, translate.foxhaven.cyou, trans.zillyhuhn.com // other possibilities working but badly translated: // translate.terraprint.co - static final translation_hosts = ['libretranslate.de', 'translate.fedilab.app', 'translate.argosopentech.com']; + static final default_translation_hosts = [ + { + 'host': 'libretranslate.de', + 'api_key': null + }, + { + 'host': 'translate.fedilab.app', + 'api_key': null + }, + { + 'host': 'translate.argosopentech.com', + 'api_key': null + } + ]; + static List>? _translation_hosts; static int current_translation_host_idx = 0; // Random().nextInt(translation_hosts.length); static Map langCodeReplace = { 'iw': 'he' }; - static String translationHost() { - return translation_hosts[current_translation_host_idx]; + static int translationHostsLength() { + _translation_hosts ??= default_translation_hosts; + return _translation_hosts!.length; + } + + static Map translationHost() { + _translation_hosts ??= default_translation_hosts; + return _translation_hosts![current_translation_host_idx]; } - static String nextTranslationHost() { + static Map nextTranslationHost() { + _translation_hosts ??= default_translation_hosts; current_translation_host_idx++; - if (current_translation_host_idx == translation_hosts.length) { + if (current_translation_host_idx == _translation_hosts!.length) { current_translation_host_idx = 0; } return translationHost(); } + static String updateTranslationHosts(List> translationHosts) { + _translation_hosts = translationHosts; + return jsonEncode(_translation_hosts); + } + + static List> readTranslationHosts({String? translationHosts}) { + if (translationHosts != null) { + List> lst = []; + for (dynamic item in jsonDecode(translationHosts)) { + lst.add(item); + } + return lst; + } + if (_translation_hosts == null) { + return default_translation_hosts; + } + return _translation_hosts!; + } + static Future getSupportedLanguages() async { var key = 'translation.supported_languages'; return cacheRequest(key, () async { int connectTries = 0; String? errorMessage; - while (connectTries < translation_hosts.length) { + while (connectTries < translationHostsLength()){ + String trHost = translationHost()['host']; try { - var response = await AppHttpClient.httpGet(Uri.https(translationHost(), '/languages')).timeout(const Duration(seconds: 3)); + var response = await AppHttpClient.httpGet(Uri.https(trHost, '/languages')).timeout(const Duration(seconds: 3)); TranslationAPIResult rsp = await parseResponse(response); if (rsp.success) { return rsp; } else { - errorMessage ??= 'get supported languages error ${rsp.errorMessage} from host ${translationHost()}'; - log.warning('get supported languages error ${rsp.errorMessage} from host ${translationHost()}'); + errorMessage ??= 'get supported languages error ${rsp.errorMessage} from host $trHost'; + log.warning('get supported languages error ${rsp.errorMessage} from host $trHost'); } } on TimeoutException { - errorMessage ??= 'get supported languages timeout from host ${translationHost()}'; - log.warning('get supported languages timeout from host ${translationHost()}'); + errorMessage ??= 'get supported languages timeout from host $trHost'; + log.warning('get supported languages timeout from host $trHost'); } catch (exc) { - errorMessage ??= 'get supported languages error from ${translationHost()}\n${exc.toString()}'; - log.warning('get supported languages error from ${translationHost()}\n${exc.toString()}'); + errorMessage ??= 'get supported languages error from $trHost\n${exc.toString()}'; + log.warning('get supported languages error from $trHost\n${exc.toString()}'); } nextTranslationHost(); connectTries++; @@ -102,24 +143,29 @@ class TranslationAPI { var res = await cacheRequest(key, () async { int connectTries = 0; String? errorMessage; - while (connectTries < translation_hosts.length) { + while (connectTries < translationHostsLength()) { + String trHost = translationHost()['host']; + var data = { + ...formData, + 'api_key': translationHost()['api_key'] + }; try { - var response = await AppHttpClient.httpPost(Uri.https(translationHost(), '/translate'), - body: jsonEncode(formData), headers: {'Content-Type': 'application/json'}).timeout(const Duration(seconds: 3)); + var response = await AppHttpClient.httpPost(Uri.https(trHost, '/translate'), + body: jsonEncode(data), headers: {'Content-Type': 'application/json'}).timeout(const Duration(seconds: 3)); TranslationAPIResult rsp = await parseResponse(response); if (rsp.success) { return rsp; } else { - errorMessage ??= 'translate error ${rsp.errorMessage} from host ${translationHost()}'; - log.warning('translate error ${rsp.errorMessage} from host ${translationHost()}'); + errorMessage ??= 'translate error ${rsp.errorMessage} from host $trHost'; + log.warning('translate error ${rsp.errorMessage} from host $trHost'); } } on TimeoutException { - errorMessage ??= 'translate timeout from host ${translationHost()}'; - log.warning('translate timeout from host ${translationHost()}'); + errorMessage ??= 'translate timeout from host $trHost'; + log.warning('translate timeout from host $trHost'); } catch (exc) { - errorMessage ??= 'translate error from ${translationHost()}\n${exc.toString()}'; - log.warning('translate error from ${translationHost()}\n${exc.toString()}'); + errorMessage ??= 'translate error from $trHost\n${exc.toString()}'; + log.warning('translate error from $trHost\n${exc.toString()}'); } nextTranslationHost(); connectTries++; @@ -173,7 +219,7 @@ class TranslationAPI { switch (response.statusCode) { case 400: - RegExp languageNotSupported = RegExp(r"^\w+\ is\ not\ supported$"); + RegExp languageNotSupported = RegExp(r"^\w+ is not supported$"); var error = body['error']; if (languageNotSupported.hasMatch(error)) {