From 17b1f6e5b0cd050890e4642ad8004f2b12207fe3 Mon Sep 17 00:00:00 2001 From: Imran Remtulla <30463115+ImranR98@users.noreply.github.com> Date: Sat, 26 Nov 2022 23:53:11 -0500 Subject: [PATCH] Internationalization (#131) Replaced hardcoded English strings with locale-based variables based on the [easy_localization](https://pub.dev/packages/easy_localization) Flutter plugin. --- assets/translations/en.json | 216 ++++++++++++++++++++++ lib/app_sources/github.dart | 24 +-- lib/components/generated_form.dart | 7 +- lib/components/generated_form_modal.dart | 5 +- lib/custom_errors.dart | 31 ++-- lib/main.dart | 31 ++-- lib/mass_app_sources/githubstars.dart | 9 +- lib/pages/add_app.dart | 37 ++-- lib/pages/apps.dart | 138 ++++++++------ lib/pages/home.dart | 11 +- lib/pages/import_export.dart | 106 +++++++---- lib/pages/settings.dart | 86 +++++---- lib/providers/apps_provider.dart | 41 ++-- lib/providers/logs_provider.dart | 5 +- lib/providers/notifications_provider.dart | 62 +++---- lib/providers/settings_provider.dart | 4 +- lib/providers/source_provider.dart | 15 +- pubspec.lock | 26 +++ pubspec.yaml | 6 +- 19 files changed, 595 insertions(+), 265 deletions(-) create mode 100644 assets/translations/en.json diff --git a/assets/translations/en.json b/assets/translations/en.json new file mode 100644 index 0000000..6471e2c --- /dev/null +++ b/assets/translations/en.json @@ -0,0 +1,216 @@ +{ + "invalidURLForSource": "Not a valid {} App URL", + "noReleaseFound": "Could not find a suitable release", + "noVersionFound": "Could not determine release version", + "urlMatchesNoSource": "URL does not match a known source", + "cantInstallOlderVersion": "Cannot install an older version of an App", + "appIdMismatch": "Downloaded package ID does not match existing App ID", + "functionNotImplemented": "This class has not implemented this function", + "placeholder": "Placeholder", + "someErrors": "Some Errors Occurred", + "unexpectedError": "Unexpected Error", + "ok": "Okay", + "and": "and", + "startedBgUpdateTask": "Started BG update check task", + "bgUpdateIgnoreAfterIs": "Bg update ignoreAfter is {}", + "startedActualBGUpdateCheck": "Started actual BG update checking", + "bgUpdateTaskFinished": "Finished BG update check task", + "firstRun": "This is the first ever run of Obtainium", + "settingUpdateCheckIntervalTo": "Setting update interval to {}", + "githubPATLabel": "GitHub Personal Access Token (Increases Rate Limit)", + "githubPATHint": "PAT must be in this format: username:token", + "githubPATFormat": "username:token", + "githubPATLinkText": "'About GitHub PATs", + "includePrereleases": "Include prereleases", + "fallbackToOlderReleases": "Fallback to older releases", + "filterReleaseTitlesByRegEx": "Filter Release Titles by Regular Expression", + "invalidRegEx": "Invalid regular expression", + "noDescription": "No description", + "cancel": "Cancel", + "continue": "Continue", + "requiredInBrackets": "(Required)", + "dropdownNoOptsError": "ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT", + "colour": "Colour", + "githubStarredRepos": "GitHub Starred Repos", + "uname": "Username", + "wrongArgNum": "Wrong number of arguments provided", + "xIsTrackOnly": "{} is Track-Only", + "source": "Source", + "app": "App", + "appsFromSourceAreTrackOnly": "Apps from this source are 'Track-Only'.' ", + "youPickedTrackOnly": "You have selected the 'Track-Only' option.", + "trackOnlyAppDescription": "The App will be tracked for updates, but Obtainium will not be able to download or install it.", + "cancelled": "Cancelled", + "appAlreadyAdded": "App already added", + "addApp": "Add App", + "appSourceURL": "App Source URL", + "error": "Error", + "add": "Add", + "searchSomeSourcesLabel": "Search (Some Sources Only)", + "search": "Search", + "additionalOptsFor": "Additional Options for {}", + "supportedSourcesBelow": "Supported Sources:", + "trackOnlyInBrackets": "(Track-Only)", + "searchableInBrackets": "(Searchable)", + "appsString": "Apps", + "noApps": "No Apps", + "noAppsForFilter": "No Apps for Filter", + "byX": "By {}", + "percentProgress": "Progress: {}%", + "pleaseWait": "Please Wait...", + "updateAvailable": "Update Available", + "estimateInBracketsShort": "(Est.)", + "notInstalled": "Not Installed", + "estimateInBrackets": "(Estimate)", + "selectAll": "Select All", + "deselectN": "Deselect {}", + "xWillBeRemovedButRemainInstalled": "{} will be removed from Obtainium but remain installed on device.", + "removeSelectedAppsQuestion": "Remove Selected Apps?", + "removeSelectedApps": "Remove Selected Apps", + "updateX": "Update {}", + "installX": "Install {}", + "markXTrackOnlyAsUpdated": "Mark {}\n(Track-Only)\nas Updated", + "changeX": "Change {}", + "installUpdateApps": "Install/Update Apps", + "installUpdateSelectedApps": "Install/Update Selected Apps", + "onlyAppliesToInstalledAndOutdatedApps": "'Only applies to installed but out of date Apps", + "markXSelectedAppsAsUpdated": "Mark {} Selected Apps as Updated?", + "no": "No", + "yes": "Yes", + "markSelectedAppsUpdated": "Mark Selected Apps as Updated", + "pinToTop": "Pin to top", + "unpinFromTop": "Unpin from top", + "resetInstallStatusForSelectedAppsQuestion": "Reset Install Status for Selected Apps?", + "installStatusOfXWillBeResetExplanation": "The install status of any selected Apps will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.", + "shareSelectedAppURLs": "Share Selected App URLs", + "resetInstallStatus": "Reset Install Status", + "more": "More", + "removeOutdatedFilter": "Remove Out-of-Date App Filter", + "showOutdatedOnly": "Show Out-of-Date Apps Only", + "filter": "Filter", + "filterActive": "Filter *", + "filterApps": "Filter Apps", + "appName": "App Name", + "author": "Author", + "upToDateApps": "Up to Date Apps", + "nonInstalledApps": "Non-Installed Apps", + "importExport": "Import/Export", + "settings": "Settings", + "exportedTo": "Exported to {}", + "obtainiumExport": "Obtainium Export", + "invalidInput": "Invalid input", + "importedX": "Imported {}", + "obtainiumImport": "Obtainium Import", + "importFromURLList": "Import from URL List", + "searchQuery": "Search Query", + "appURLList": "App URL List", + "line": "Line", + "searchX": "Search {}", + "noResults": "No results found", + "importX": "Import {}", + "importedAppsIdDisclaimer": "Imported Apps may incorrectly show as \"Not Installed\".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.", + "importErrors": "Import Errors", + "importedXOfYApps": "{} of {} Apps imported.", + "followingURLsHadErrors": "The following URLs had errors:", + "okay": "Okay", + "selectURL": "Select URL", + "selectURLs": "Select URLs", + "pick": "Pick", + "theme": "Theme", + "dark": "Dark", + "light": "Light", + "followSystem": "Follow System", + "obtainium": "Obtainium", + "materialYou": "Material You", + "appSortBy": "App Sort By", + "authorName": "Author/Name", + "nameAuthor": "Name/Author", + "asAdded": "As Added", + "appSortOrder": "App Sort Order", + "ascending": "Ascending", + "descending": "Descending", + "bgUpdateCheckInterval": "Background Update Checking Interval", + "neverManualOnly": "Never - Manual Only", + "appearance": "Appearance", + "showWebInAppView": "Show Source Webpage in App View", + "pinUpdates": "Pin Updates to Top of Apps View", + "updates": "Updated", + "sourceSpecific": "Source-Specific", + "appSource": "App Source", + "noLogs": "No Logs", + "appLogs": "App Logs", + "close": "Close", + "share": "Share", + "appNotFound": "App not found", + "obtainiumExportHyphenatedLowercase": "obtainium-export", + "pickAnAPK": "Pick an APK", + "appHasMoreThanOnePackage": "{} has more than one package:", + "deviceSupportsXArch": "Your device supports the {} CPU architecture.", + "deviceSupportsFollowingArchs": "Your device supports the following CPU architectures:", + "warning": "Warning", + "sourceIsXButPackageFromYPrompt": "The App source is '{}' but the release package comes from '{}'. Continue?", + "updatesAvailable": "Updates Available", + "updatesAvailableNotifDescription": "Notifies the user that updates are available for one or more Apps tracked by Obtainium", + "noNewUpdates": "No new updates.", + "xHasAnUpdate": "{} has an update.", + "appsUpdated": "Apps Updated", + "appsUpdatedNotifDescription": "Notifies the user that updates to one or more Apps were applied in the background", + "xWasUpdatedToY": "{} was updated to {}.", + "errorCheckingUpdates": "Error Checking for Updates", + "errorCheckingUpdatesNotifDescription": "A notification that shows when background update checking fails", + "appsRemoved": "Apps Removed", + "appsRemovedNotifDescription": "Notifies the user that one or more Apps were removed due to errors while loading them", + "xWasRemovedDueToErrorY": "{} was removed due to this error: {}", + "completeAppInstallation": "Complete App Installation", + "obtainiumMustBeOpenToInstallApps": "Obtainium must be open to install Apps", + "completeAppInstallationNotifDescription": "Asks the user to return to Obtainium to finish installing an App", + "checkingForUpdates": "Checking for Updates", + "checkingForUpdatesNotifDescription": "Transient notification that appears when checking for updates", + "pleaseAllowInstallPerm": "Please allow Obtainium to install Apps", + "trackOnly": "Track-Only", + "errorWithHttpStatusCode": "Error {}", + "tooManyRequestsTryAgainInMinutes": { + "one": "Too many requests (rate limited) - try again in {} minute", + "other": "Too many requests (rate limited) - try again in {} minutes" + }, + "bgUpdateGotErrorRetryInMinutes": { + "one": "BG update checking encountered a {}, will schedule a retry check in {} minute", + "other": "BG update checking encountered a {}, will schedule a retry check in {} minutes" + }, + "bgCheckFoundUpdatesWillNotifyIfNeeded": { + "one": "BG update checking found {} update - will notify user if needed", + "other": "BG update checking found {} updates - will notify user if needed" + }, + "apps": { + "one": "{} App", + "other": "{} Apps" + }, + "url": { + "one": "{} URL", + "other": "{} URLs" + }, + "minute": { + "one": "{} Minute", + "other": "{} Minutes" + }, + "hour": { + "one": "{} Hour", + "other": "{} Hours" + }, + "day": { + "one": "{} Day", + "other": "{} Days" + }, + "clearedNLogsBeforeXAfterY": { + "one": "Cleared {n} log (before = {before}, after = {after})", + "other": "Cleared {n} logs (before = {before}, after = {after})" + }, + "xAndNMoreUpdatesAvailable": { + "one": "{} and {} more app have updated.", + "other": "{} and {} more apps have updates." + }, + "xAndNMoreUpdatesInstalled": { + "one": "{} and {} more app were updated.", + "other": "{} and {} more apps were updated." + } +} \ No newline at end of file diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index 3224b72..5f821bf 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; import 'package:obtainium/components/generated_form.dart'; @@ -15,7 +16,7 @@ class GitHub extends AppSource { additionalSourceSpecificSettingFormItems = [ GeneratedFormItem( - label: 'GitHub Personal Access Token (Increases Rate Limit)', + label: tr('githubPATLabel'), id: 'github-creds', required: false, additionalValidators: [ @@ -26,13 +27,13 @@ class GitHub extends AppSource { .where((element) => element.trim().isNotEmpty) .length != 2) { - return 'PAT must be in this format: username:token'; + return tr('githubPATHint'); } } return null; } ], - hint: 'username:token', + hint: tr('githubPATFormat'), belowWidgets: [ const SizedBox( height: 8, @@ -43,9 +44,9 @@ class GitHub extends AppSource { 'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', mode: LaunchMode.externalApplication); }, - child: const Text( - 'About GitHub PATs', - style: TextStyle( + child: Text( + tr('githubPATLinkText'), + style: const TextStyle( decoration: TextDecoration.underline, fontSize: 12), )) ]) @@ -53,15 +54,16 @@ class GitHub extends AppSource { additionalSourceAppSpecificFormItems = [ [ - GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool) + GeneratedFormItem( + label: tr('includePrereleases'), type: FormItemType.bool) ], [ GeneratedFormItem( - label: 'Fallback to older releases', type: FormItemType.bool) + label: tr('fallbackToOlderReleases'), type: FormItemType.bool) ], [ GeneratedFormItem( - label: 'Filter Release Titles by Regular Expression', + label: tr('filterReleaseTitlesByRegEx'), type: FormItemType.string, required: false, additionalValidators: [ @@ -72,7 +74,7 @@ class GitHub extends AppSource { try { RegExp(value); } catch (e) { - return 'Invalid regular expression'; + return tr('invalidRegEx'); } return null; } @@ -184,7 +186,7 @@ class GitHub extends AppSource { urlsWithDescriptions.addAll({ e['html_url'] as String: e['description'] != null ? e['description'] as String - : 'No description' + : tr('noDescription') }); } return urlsWithDescriptions; diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart index df252ab..a00872f 100644 --- a/lib/components/generated_form.dart +++ b/lib/components/generated_form.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; enum FormItemType { string, bool } @@ -108,7 +109,7 @@ class _GeneratedFormState extends State { maxLines: e.value.max <= 1 ? 1 : e.value.max, validator: (value) { if (e.value.required && (value == null || value.trim().isEmpty)) { - return '${e.value.label} (required)'; + return '${e.value.label} ${tr('requiredInBrackets')}'; } for (var validator in e.value.additionalValidators) { String? result = validator(value); @@ -122,10 +123,10 @@ class _GeneratedFormState extends State { } else if (e.value.type == FormItemType.string && e.value.opts != null) { if (e.value.opts!.isEmpty) { - return const Text('ERROR: DROPDOWN MUST HAVE AT LEAST ONE OPT.'); + return Text(tr('dropdownNoOptsError')); } return DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Colour'), + decoration: InputDecoration(labelText: tr('colour')), value: values[row.key][e.key], items: e.value.opts! .map((e) => DropdownMenuItem(value: e, child: Text(e))) diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart index faea40c..5ea70ff 100644 --- a/lib/components/generated_form_modal.dart +++ b/lib/components/generated_form_modal.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/generated_form.dart'; @@ -64,7 +65,7 @@ class _GeneratedFormModalState extends State { onPressed: () { Navigator.of(context).pop(null); }, - child: const Text('Cancel')), + child: Text(tr('cancel'))), TextButton( onPressed: !valid ? null @@ -74,7 +75,7 @@ class _GeneratedFormModalState extends State { Navigator.of(context).pop(values); } }, - child: const Text('Continue')) + child: Text(tr('continue'))) ], ); } diff --git a/lib/custom_errors.dart b/lib/custom_errors.dart index 14b785f..5e29ae7 100644 --- a/lib/custom_errors.dart +++ b/lib/custom_errors.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/providers/logs_provider.dart'; import 'package:provider/provider.dart'; @@ -18,46 +19,46 @@ class RateLimitError { @override String toString() => - 'Too many requests (rate limited) - try again in $remainingMinutes minutes'; + plural('tooManyRequestsTryAgainInMinutes', remainingMinutes); } class InvalidURLError extends ObtainiumError { - InvalidURLError(String sourceName) : super('Not a valid $sourceName App URL'); + InvalidURLError(String sourceName) + : super(tr('invalidURLForSource', args: [sourceName])); } class NoReleasesError extends ObtainiumError { - NoReleasesError() : super('Could not find a suitable release'); + NoReleasesError() : super(tr('noReleaseFound')); } class NoAPKError extends ObtainiumError { - NoAPKError() : super('Could not find a suitable release'); + NoAPKError() : super(tr('noReleaseFound')); } class NoVersionError extends ObtainiumError { - NoVersionError() : super('Could not determine release version'); + NoVersionError() : super(tr('noVersionFound')); } class UnsupportedURLError extends ObtainiumError { - UnsupportedURLError() : super('URL does not match a known source'); + UnsupportedURLError() : super(tr('urlMatchesNoSource')); } class DowngradeError extends ObtainiumError { - DowngradeError() : super('Cannot install an older version of an App'); + DowngradeError() : super(tr('cantInstallOlderVersion')); } class IDChangedError extends ObtainiumError { - IDChangedError() - : super('Downloaded package ID does not match existing App ID'); + IDChangedError() : super(tr('appIdMismatch')); } class NotImplementedError extends ObtainiumError { - NotImplementedError() : super('This class has not implemented this function'); + NotImplementedError() : super(tr('functionNotImplemented')); } class MultiAppMultiError extends ObtainiumError { Map> content = {}; - MultiAppMultiError() : super('Multiple Errors Placeholder', unexpected: true); + MultiAppMultiError() : super(tr('placeholder'), unexpected: true); add(String appId, String string) { var tempIds = content.remove(string); @@ -90,15 +91,15 @@ showError(dynamic e, BuildContext context) { return AlertDialog( scrollable: true, title: Text(e is MultiAppMultiError - ? 'Some Errors Occurred' - : 'Unexpected Error'), + ? tr('someErrors') + : tr('unexpectedError')), content: Text(e.toString()), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(null); }, - child: const Text('Ok')), + child: Text(tr('ok'))), ], ); }); @@ -107,7 +108,7 @@ showError(dynamic e, BuildContext context) { String list2FriendlyString(List list) { return list.length == 2 - ? '${list[0]} and ${list[1]}' + ? '${list[0]} ${tr('and')} ${list[1]}' : list .asMap() .entries diff --git a/lib/main.dart b/lib/main.dart index 4984540..ddf8725 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:provider/provider.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:android_alarm_manager_plus/android_alarm_manager_plus.dart'; +import 'package:easy_localization/easy_localization.dart'; const String currentVersion = '0.8.1'; const String currentReleaseTag = @@ -25,14 +26,14 @@ const int bgUpdateCheckAlarmId = 666; @pragma('vm:entry-point') Future bgUpdateCheck(int taskId, Map? params) async { LogsProvider logs = LogsProvider(); - logs.add('Started BG update check task'); + logs.add(tr('startedBgUpdateTask')); int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; WidgetsFlutterBinding.ensureInitialized(); await AndroidAlarmManager.initialize(); DateTime? ignoreAfter = ignoreAfterMicroseconds != null ? DateTime.fromMicrosecondsSinceEpoch(ignoreAfterMicroseconds) : null; - logs.add('Bg update ignoreAfter is $ignoreAfter'); + logs.add(tr('bgUpdateIgnoreAfterIs', args: [ignoreAfter.toString()])); var notificationsProvider = NotificationsProvider(); await notificationsProvider.notify(checkingUpdatesNotification); try { @@ -44,14 +45,14 @@ Future bgUpdateCheck(int taskId, Map? params) async { DateTime nextIgnoreAfter = DateTime.now(); String? err; try { - logs.add('Started actual BG update checking'); + logs.add(tr('startedActualBGUpdateCheck')); await appsProvider.checkUpdates( ignoreAppsCheckedAfter: ignoreAfter, throwErrorsForRetry: true); } catch (e) { if (e is RateLimitError || e is SocketException) { var remainingMinutes = e is RateLimitError ? e.remainingMinutes : 15; - logs.add( - 'BG update checking encountered a ${e.runtimeType}, will schedule a retry check in $remainingMinutes minutes'); + logs.add(plural('bgUpdateGotErrorRetryInMinutes', remainingMinutes, + args: [e.runtimeType.toString()])); AndroidAlarmManager.oneShot(Duration(minutes: remainingMinutes), Random().nextInt(pow(2, 31) as int), bgUpdateCheck, params: { 'ignoreAfterMicroseconds': nextIgnoreAfter.microsecondsSinceEpoch @@ -80,7 +81,7 @@ Future bgUpdateCheck(int taskId, Map? params) async { // cancelExisting: true); // } logs.add( - 'BG update checking found ${newUpdates.length} updates - will notify user if needed'); + plural('bgCheckFoundUpdatesWillNotifyIfNeeded', newUpdates.length)); if (newUpdates.isNotEmpty) { notificationsProvider.notify(UpdateNotification(newUpdates)); } @@ -91,13 +92,14 @@ Future bgUpdateCheck(int taskId, Map? params) async { notificationsProvider .notify(ErrorCheckingUpdatesNotification(e.toString())); } finally { - logs.add('Finished BG update check task'); + logs.add(tr('bgUpdateTaskFinished')); await notificationsProvider.cancel(checkingUpdatesNotification.id); } } void main() async { WidgetsFlutterBinding.ensureInitialized(); + await EasyLocalization.ensureInitialized(); if ((await DeviceInfoPlugin().androidInfo).version.sdkInt >= 29) { SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent), @@ -112,7 +114,11 @@ void main() async { Provider(create: (context) => NotificationsProvider()), Provider(create: (context) => LogsProvider()) ], - child: const Obtainium(), + child: EasyLocalization( + supportedLocales: const [Locale('en')], + path: 'assets/translations', + fallbackLocale: const Locale('en'), + child: const Obtainium()), )); } @@ -139,7 +145,7 @@ class _ObtainiumState extends State { } else { bool isFirstRun = settingsProvider.checkAndFlipFirstRun(); if (isFirstRun) { - logs.add('This is the first ever run of Obtainium'); + logs.add(tr('firstRun')); // If this is the first run, ask for notification permissions and add Obtainium to the Apps list Permission.notification.request(); appsProvider.saveApps([ @@ -161,8 +167,8 @@ class _ObtainiumState extends State { // Register the background update task according to the user's setting if (existingUpdateInterval != settingsProvider.updateInterval) { if (existingUpdateInterval != -1) { - logs.add( - 'Setting update interval to ${settingsProvider.updateInterval}'); + logs.add(tr('settingUpdateCheckIntervalTo', + args: [settingsProvider.updateInterval.toString()])); } existingUpdateInterval = settingsProvider.updateInterval; if (existingUpdateInterval == 0) { @@ -195,6 +201,9 @@ class _ObtainiumState extends State { } return MaterialApp( title: 'Obtainium', + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, theme: ThemeData( useMaterial3: true, colorScheme: settingsProvider.theme == ThemeSettings.dark diff --git a/lib/mass_app_sources/githubstars.dart b/lib/mass_app_sources/githubstars.dart index bc25621..d8ca40e 100644 --- a/lib/mass_app_sources/githubstars.dart +++ b/lib/mass_app_sources/githubstars.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:easy_localization/easy_localization.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/github.dart'; import 'package:obtainium/custom_errors.dart'; @@ -7,10 +8,10 @@ import 'package:obtainium/providers/source_provider.dart'; class GitHubStars implements MassAppUrlSource { @override - late String name = 'GitHub Starred Repos'; + late String name = tr('githubStarredRepos'); @override - late List requiredArgs = ['Username']; + late List requiredArgs = [tr('uname')]; Future> getOnePageOfUserStarredUrlsWithDescriptions( String username, int page) async { @@ -22,7 +23,7 @@ class GitHubStars implements MassAppUrlSource { urlsWithDescriptions.addAll({ e['html_url'] as String: e['description'] != null ? e['description'] as String - : 'No description' + : tr('noDescription') }); } return urlsWithDescriptions; @@ -36,7 +37,7 @@ class GitHubStars implements MassAppUrlSource { @override Future> getUrlsWithDescriptions(List args) async { if (args.length != requiredArgs.length) { - throw ObtainiumError('Wrong number of arguments provided'); + throw ObtainiumError(tr('wrongArgNum')); } Map urlsWithDescriptions = {}; var page = 1; diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index 7fc2c6d..dafbdc5 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; @@ -75,12 +76,15 @@ class _AddAppPageState extends State { context: context, builder: (BuildContext ctx) { return GeneratedFormModal( - title: - '${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only', + title: tr('xIsTrackOnly', args: [ + pickedSource!.enforceTrackOnly + ? tr('source') + : tr('app') + ]), items: const [], defaultValues: const [], message: - '${pickedSource!.enforceTrackOnly ? 'Apps from this source are \'Track-Only\'.' : 'You have selected the \'Track-Only\' option.'}\n\nThe App will be tracked for updates, but Obtainium will not be able to download or install it.', + '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}', ); }) == null) { @@ -100,14 +104,14 @@ class _AddAppPageState extends State { // ignore: use_build_context_synchronously var apkUrl = await appsProvider.confirmApkUrl(app, context); if (apkUrl == null) { - throw ObtainiumError('Cancelled'); + throw ObtainiumError(tr('cancelled')); } app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); var downloadedApk = await appsProvider.downloadApp(app); app.id = downloadedApk.appId; } if (appsProvider.apps.containsKey(app.id)) { - throw ObtainiumError('App already added'); + throw ObtainiumError(tr('appAlreadyAdded')); } if (app.trackOnly) { app.installedVersion = app.latestVersion; @@ -137,7 +141,7 @@ class _AddAppPageState extends State { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ - const CustomAppBar(title: 'Add App'), + CustomAppBar(title: tr('addApp')), SliverFillRemaining( child: Padding( padding: const EdgeInsets.all(16), @@ -151,7 +155,7 @@ class _AddAppPageState extends State { items: [ [ GeneratedFormItem( - label: 'App Source Url', + label: tr('appSourceURL'), additionalValidators: [ (value) { try { @@ -165,7 +169,7 @@ class _AddAppPageState extends State { ? e : e is ObtainiumError ? e.toString() - : 'Error'; + : tr('error'); } return null; } @@ -195,7 +199,7 @@ class _AddAppPageState extends State { !otherAdditionalDataIsValid) ? null : addApp, - child: const Text('Add')) + child: Text(tr('add'))) ], ), if (sourceProvider.sources @@ -218,7 +222,7 @@ class _AddAppPageState extends State { items: [ [ GeneratedFormItem( - label: 'Search (Some Sources Only)', + label: tr('searchSomeSourcesLabel'), required: false), ] ], @@ -281,7 +285,7 @@ class _AddAppPageState extends State { showError(e, context); }); }, - child: const Text('Search')) + child: Text(tr('search'))) ], ), if (pickedSource != null && @@ -301,7 +305,10 @@ class _AddAppPageState extends State { height: 64, ), Text( - 'Additional Options for ${pickedSource?.runtimeType}', + tr('additionalOptsFor', args: [ + pickedSource?.runtimeType.toString() ?? + tr('source') + ]), style: TextStyle( color: Theme.of(context).colorScheme.primary)), @@ -365,8 +372,8 @@ class _AddAppPageState extends State { const SizedBox( height: 48, ), - const Text( - 'Supported Sources:', + Text( + tr('supportedSourcesBelow'), ), const SizedBox( height: 8, @@ -379,7 +386,7 @@ class _AddAppPageState extends State { LaunchMode.externalApplication); }, child: Text( - '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}', + '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}', style: const TextStyle( decoration: TextDecoration.underline, diff --git a/lib/pages/apps.dart b/lib/pages/apps.dart index 20464f6..deaa5ba 100644 --- a/lib/pages/apps.dart +++ b/lib/pages/apps.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; @@ -191,7 +192,7 @@ class AppsPageState extends State { }); }, child: CustomScrollView(slivers: [ - const CustomAppBar(title: 'Apps'), + CustomAppBar(title: tr('appsString')), if (appsProvider.loadingApps || sortedApps.isEmpty) SliverFillRemaining( child: Center( @@ -199,8 +200,8 @@ class AppsPageState extends State { ? const CircularProgressIndicator() : Text( appsProvider.apps.isEmpty - ? 'No Apps' - : 'No Apps for Filter', + ? tr('noApps') + : tr('noAppsForFilter'), style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ))), @@ -244,14 +245,19 @@ class AppsPageState extends State { ? FontWeight.bold : FontWeight.normal), ), - subtitle: Text('By ${sortedApps[index].app.author}', + subtitle: Text(tr('byX', args: [sortedApps[index].app.author]), style: TextStyle( fontWeight: sortedApps[index].app.pinned ? FontWeight.bold : FontWeight.normal)), trailing: sortedApps[index].downloadProgress != null - ? Text( - 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%') + ? Text(tr('percentProgress', args: [ + sortedApps[index] + .downloadProgress + ?.toInt() + .toString() ?? + '100' + ])) : (sortedApps[index].app.installedVersion != null && sortedApps[index].app.installedVersion != sortedApps[index].app.latestVersion @@ -260,8 +266,8 @@ class AppsPageState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(appsProvider.areDownloadsRunning() - ? 'Please Wait...' - : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'), + ? tr('pleaseWait') + : '${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}'), SourceProvider() .getSource(sortedApps[index].app.url) .changeLogPageFromStandardUrl( @@ -292,7 +298,7 @@ class AppsPageState extends State { child: SizedBox( width: 80, child: Text( - '${sortedApps[index].app.installedVersion ?? 'Not Installed'} ${sortedApps[index].app.trackOnly == true ? '(Estimate)' : ''}', + '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.trackOnly == true ? ' ${tr('estimateInBrackets')}' : ''}', overflow: TextOverflow.fade, textAlign: TextAlign.end, )))), @@ -327,8 +333,8 @@ class AppsPageState extends State { color: Theme.of(context).colorScheme.primary, ), tooltip: selectedApps.isEmpty - ? 'Select All' - : 'Deselect ${selectedApps.length.toString()}'), + ? tr('selectAll') + : tr('deselectN', args: [selectedApps.length.toString()])), const VerticalDivider(), Expanded( child: Row( @@ -343,12 +349,15 @@ class AppsPageState extends State { context: context, builder: (BuildContext ctx) { return GeneratedFormModal( - title: 'Remove Selected Apps?', + title: tr('removeSelectedAppsQuestion'), items: const [], defaultValues: const [], initValid: true, - message: - '${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedApps.length == 1 ? 'it' : 'them'} manually.', + message: tr( + 'xWillBeRemovedButRemainInstalled', + args: [ + plural('apps', selectedApps.length) + ]), ); }).then((values) { if (values != null) { @@ -357,7 +366,7 @@ class AppsPageState extends State { } }); }, - tooltip: 'Remove Selected Apps', + tooltip: tr('removeSelectedApps'), icon: const Icon(Icons.delete_outline_outlined), ), IconButton( @@ -373,16 +382,20 @@ class AppsPageState extends State { List defaultValues = []; if (existingUpdateIdsAllOrSelected.isNotEmpty) { formInputs.add(GeneratedFormItem( - label: - 'Update ${existingUpdateIdsAllOrSelected.length} App${existingUpdateIdsAllOrSelected.length == 1 ? '' : 's'}', + label: tr('updateX', args: [ + plural('apps', + existingUpdateIdsAllOrSelected.length) + ]), type: FormItemType.bool, key: 'updates')); defaultValues.add('true'); } if (newInstallIdsAllOrSelected.isNotEmpty) { formInputs.add(GeneratedFormItem( - label: - 'Install ${newInstallIdsAllOrSelected.length} new App${newInstallIdsAllOrSelected.length == 1 ? '' : 's'}', + label: tr('installX', args: [ + plural('apps', + newInstallIdsAllOrSelected.length) + ]), type: FormItemType.bool, key: 'installs')); defaultValues @@ -390,8 +403,10 @@ class AppsPageState extends State { } if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { formInputs.add(GeneratedFormItem( - label: - 'Mark ${trackOnlyUpdateIdsAllOrSelected.length} Track-Only\nApp${trackOnlyUpdateIdsAllOrSelected.length == 1 ? '' : 's'} as Updated', + label: tr('markXTrackOnlyAsUpdated', args: [ + plural('apps', + trackOnlyUpdateIdsAllOrSelected.length) + ]), type: FormItemType.bool, key: 'trackonlies')); defaultValues @@ -405,8 +420,8 @@ class AppsPageState extends State { newInstallIdsAllOrSelected.length + trackOnlyUpdateIdsAllOrSelected.length; return GeneratedFormModal( - title: - 'Change $totalApps App${totalApps == 1 ? '' : 's'}', + title: tr('changeX', + args: [plural('apps', totalApps)]), items: formInputs.map((e) => [e]).toList(), defaultValues: defaultValues, initValid: true, @@ -459,8 +474,9 @@ class AppsPageState extends State { } }); }, - tooltip: - 'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps', + tooltip: selectedApps.isEmpty + ? tr('installUpdateApps') + : tr('installUpdateSelectedApps'), icon: const Icon( Icons.file_download_outlined, )), @@ -492,11 +508,15 @@ class AppsPageState extends State { (BuildContext ctx) { return AlertDialog( - title: Text( - 'Mark ${selectedApps.length} Selected Apps as Updated?'), - content: - const Text( - 'Only applies to installed but out of date Apps.'), + title: Text(tr( + 'markXSelectedAppsAsUpdated', + args: [ + selectedApps + .length + .toString() + ])), + content: Text( + tr('onlyAppliesToInstalledAndOutdatedApps')), actions: [ TextButton( onPressed: @@ -504,8 +524,8 @@ class AppsPageState extends State { Navigator.of(context) .pop(); }, - child: const Text( - 'No')), + child: Text( + tr('no'))), TextButton( onPressed: () { @@ -523,8 +543,8 @@ class AppsPageState extends State { Navigator.of(context) .pop(); }, - child: const Text( - 'Yes')) + child: Text( + tr('yes'))) ], ); }).whenComplete(() { @@ -534,7 +554,7 @@ class AppsPageState extends State { }); }, tooltip: - 'Mark Selected Apps as Updated', + tr('markSelectedAppsUpdated'), icon: const Icon(Icons.done)), IconButton( onPressed: () { @@ -549,8 +569,12 @@ class AppsPageState extends State { }).toList()); Navigator.of(context).pop(); }, - tooltip: - '${selectedApps.where((element) => element.pinned).isEmpty ? 'Pin to' : 'Unpin from'} top', + tooltip: selectedApps + .where((element) => + element.pinned) + .isEmpty + ? tr('pinToTop') + : tr('unpinFromTop'), icon: Icon(selectedApps .where((element) => element.pinned) @@ -568,11 +592,11 @@ class AppsPageState extends State { urls = urls.substring( 0, urls.length - 1); Share.share(urls, - subject: - '${selectedApps.length} Selected App URLs from Obtainium'); + subject: tr( + 'selectedAppURLsFromObtainium')); Navigator.of(context).pop(); }, - tooltip: 'Share Selected App URLs', + tooltip: tr('shareSelectedAppURLs'), icon: const Icon(Icons.share), ), IconButton( @@ -581,13 +605,19 @@ class AppsPageState extends State { context: context, builder: (BuildContext ctx) { return GeneratedFormModal( - title: - 'Reset Install Status for Selected Apps?', + title: tr( + 'resetInstallStatusForSelectedAppsQuestion'), items: const [], defaultValues: const [], initValid: true, - message: - 'The install status of ${selectedApps.length} App${selectedApps.length == 1 ? '' : 's'} will be reset.\n\nThis can help when the App version shown in Obtainium is incorrect due to failed updates or other issues.', + message: tr( + 'installStatusOfXWillBeResetExplanation', + args: [ + plural( + 'app', + selectedApps + .length) + ]), ); }).then((values) { if (values != null) { @@ -601,7 +631,7 @@ class AppsPageState extends State { Navigator.of(context).pop(); }); }, - tooltip: 'Reset Install Status', + tooltip: tr('resetInstallStatus'), icon: const Icon( Icons.restore_page_outlined), ), @@ -610,7 +640,7 @@ class AppsPageState extends State { ); }); }, - tooltip: 'More', + tooltip: tr('more'), icon: const Icon(Icons.more_horiz), ), ], @@ -628,8 +658,8 @@ class AppsPageState extends State { }); }, tooltip: currentFilterIsUpdatesOnly - ? 'Remove Out-of-Date App Filter' - : 'Show Out-of-Date Apps Only', + ? tr('removeOutdatedFilter') + : tr('showOutdatedOnly'), icon: Icon( currentFilterIsUpdatesOnly ? Icons.update_disabled_rounded @@ -641,7 +671,7 @@ class AppsPageState extends State { ? const SizedBox() : TextButton.icon( label: Text( - filter == null ? 'Filter' : 'Filter *', + filter == null ? tr('filter') : tr('filterActive'), style: TextStyle( fontWeight: filter == null ? FontWeight.normal @@ -652,22 +682,22 @@ class AppsPageState extends State { context: context, builder: (BuildContext ctx) { return GeneratedFormModal( - title: 'Filter Apps', + title: tr('filterApps'), items: [ [ GeneratedFormItem( - label: 'App Name', required: false), + label: tr('appName'), required: false), GeneratedFormItem( - label: 'Author', required: false) + label: tr('author'), required: false) ], [ GeneratedFormItem( - label: 'Up to Date Apps', + label: tr('upToDateApps'), type: FormItemType.bool) ], [ GeneratedFormItem( - label: 'Non-Installed Apps', + label: tr('nonInstalledApps'), type: FormItemType.bool) ] ], diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 0fdf77a..d0a7061 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,4 +1,5 @@ import 'package:animations/animations.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/pages/add_app.dart'; @@ -25,12 +26,12 @@ class _HomePageState extends State { List selectedIndexHistory = []; List pages = [ + NavigationPageItem(tr('appsString'), Icons.apps, + AppsPage(key: GlobalKey())), + NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()), NavigationPageItem( - 'Apps', Icons.apps, AppsPage(key: GlobalKey())), - NavigationPageItem('Add App', Icons.add, const AddAppPage()), - NavigationPageItem( - 'Import/Export', Icons.import_export, const ImportExportPage()), - NavigationPageItem('Settings', Icons.settings, const SettingsPage()) + tr('importExport'), Icons.import_export, const ImportExportPage()), + NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()) ]; @override diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index 5aa554f..17cea13 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; @@ -41,7 +42,7 @@ class _ImportExportPageState extends State { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ - const CustomAppBar(title: 'Import/Export'), + CustomAppBar(title: tr('importExport')), SliverFillRemaining( child: Padding( padding: @@ -63,10 +64,11 @@ class _ImportExportPageState extends State { .exportApps() .then((String path) { showError( - 'Exported to $path', context); + tr('exportedTo', args: [path]), + context); }); }, - child: const Text('Obtainium Export'))), + child: Text(tr('obtainiumExport')))), const SizedBox( width: 16, ), @@ -91,13 +93,15 @@ class _ImportExportPageState extends State { jsonDecode(data); } catch (e) { throw ObtainiumError( - 'Invalid input'); + tr('invalidInput')); } appsProvider .importApps(data) .then((value) { showError( - '$value App${value == 1 ? '' : 's'} Imported', + tr('importedX', args: [ + plural('apps', value) + ]), context); }); } else { @@ -111,7 +115,7 @@ class _ImportExportPageState extends State { }); }); }, - child: const Text('Obtainium Import'))) + child: Text(tr('obtainiumImport')))) ], ), if (importInProgress) @@ -138,11 +142,11 @@ class _ImportExportPageState extends State { context: context, builder: (BuildContext ctx) { return GeneratedFormModal( - title: 'Import from URL List', + title: tr('importFromURLList'), items: [ [ GeneratedFormItem( - label: 'App URL List', + label: tr('appURLList'), max: 7, additionalValidators: [ (String? value) { @@ -159,7 +163,7 @@ class _ImportExportPageState extends State { .getSource( lines[i]); } catch (e) { - return 'Line ${i + 1}: $e'; + return '${tr('line')} ${i + 1}: $e'; } } } @@ -182,7 +186,9 @@ class _ImportExportPageState extends State { .then((errors) { if (errors.isEmpty) { showError( - 'Imported ${urls.length} Apps', + tr('importedX', args: [ + plural('apps', urls.length) + ]), context); } else { showDialog( @@ -203,8 +209,8 @@ class _ImportExportPageState extends State { } }); }, - child: const Text( - 'Import from URL List', + child: Text( + tr('importFromURLList'), )), ...sourceProvider.sources .where((element) => element.canSearch) @@ -224,13 +230,17 @@ class _ImportExportPageState extends State { builder: (BuildContext ctx) { return GeneratedFormModal( - title: - 'Search ${source.runtimeType}', + title: tr('searchX', + args: [ + source + .runtimeType + .toString() + ]), items: [ [ GeneratedFormItem( - label: - '${source.runtimeType} Search Query') + label: tr( + 'searchQuery')) ] ], defaultValues: const [], @@ -272,7 +282,13 @@ class _ImportExportPageState extends State { if (errors.isEmpty) { // ignore: use_build_context_synchronously showError( - 'Imported ${selectedUrls.length} Apps', + tr('importedX', + args: [ + plural( + 'app', + selectedUrls + .length) + ]), context); } else { showDialog( @@ -291,7 +307,7 @@ class _ImportExportPageState extends State { } } else { throw ObtainiumError( - 'No results found'); + tr('noResults')); } } }() @@ -303,8 +319,9 @@ class _ImportExportPageState extends State { }); }); }, - child: Text( - 'Search ${source.runtimeType}')) + child: Text(tr('searchX', args: [ + source.runtimeType.toString() + ]))) ])) .toList(), ...sourceProvider.massUrlSources @@ -323,8 +340,10 @@ class _ImportExportPageState extends State { builder: (BuildContext ctx) { return GeneratedFormModal( - title: - 'Import ${source.name}', + title: tr('importX', + args: [ + source.name + ]), items: source .requiredArgs @@ -363,7 +382,13 @@ class _ImportExportPageState extends State { if (errors.isEmpty) { // ignore: use_build_context_synchronously showError( - 'Imported ${selectedUrls.length} Apps', + tr('importedX', + args: [ + plural( + 'app', + selectedUrls + .length) + ]), context); } else { showDialog( @@ -390,17 +415,17 @@ class _ImportExportPageState extends State { }); }); }, - child: Text('Import ${source.name}')) + child: Text( + tr('importX', args: [source.name]))) ])) .toList(), const Spacer(), const Divider( height: 32, ), - const Text( - 'Imported Apps may incorrectly show as "Not Installed".\nTo fix this, re-install them through Obtainium.\nThis should not affect App data.\n\nOnly affects URL and third-party import methods.', + Text(tr('importedAppsIdDisclaimer'), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontStyle: FontStyle.italic, fontSize: 12)), const SizedBox( height: 8, @@ -427,16 +452,19 @@ class _ImportErrorDialogState extends State { Widget build(BuildContext context) { return AlertDialog( scrollable: true, - title: const Text('Import Errors'), + title: Text(tr('importErrors')), content: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - '${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.', + tr('importedXOfYApps', args: [ + (widget.urlsLength - widget.errors.length).toString(), + widget.urlsLength.toString() + ]), style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 16), Text( - 'The following URLs had errors:', + tr('followingURLsHadErrors'), style: Theme.of(context).textTheme.bodyLarge, ), ...widget.errors.map((e) { @@ -459,7 +487,7 @@ class _ImportErrorDialogState extends State { onPressed: () { Navigator.of(context).pop(null); }, - child: const Text('Okay')) + child: Text(tr('okay'))) ], ); } @@ -505,8 +533,8 @@ class _UrlSelectionModalState extends State { Widget build(BuildContext context) { return AlertDialog( scrollable: true, - title: - Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'), + title: Text( + widget.onlyOneSelectionAllowed ? tr('selectURL') : tr('selectURLs')), content: Column(children: [ ...urlWithDescriptionSelections.keys.map((urlWithD) { return Row(children: [ @@ -564,7 +592,7 @@ class _UrlSelectionModalState extends State { onPressed: () { Navigator.of(context).pop(); }, - child: const Text('Cancel')), + child: Text(tr('cancel'))), TextButton( onPressed: urlWithDescriptionSelections.values.where((b) => b).isEmpty @@ -577,8 +605,14 @@ class _UrlSelectionModalState extends State { .toList()); }, child: Text(widget.onlyOneSelectionAllowed - ? 'Pick' - : 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) + ? tr('pick') + : tr('importX', args: [ + plural( + 'url', + urlWithDescriptionSelections.values + .where((b) => b) + .length) + ]))) ], ); } diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index bf4c6c1..8f8befb 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; @@ -26,20 +27,20 @@ class _SettingsPageState extends State { } var themeDropdown = DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Theme'), + decoration: InputDecoration(labelText: tr('theme')), value: settingsProvider.theme, - items: const [ + items: [ DropdownMenuItem( value: ThemeSettings.dark, - child: Text('Dark'), + child: Text(tr('dark')), ), DropdownMenuItem( value: ThemeSettings.light, - child: Text('Light'), + child: Text(tr('light')), ), DropdownMenuItem( value: ThemeSettings.system, - child: Text('Follow System'), + child: Text(tr('followSystem')), ) ], onChanged: (value) { @@ -49,16 +50,16 @@ class _SettingsPageState extends State { }); var colourDropdown = DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'Colour'), + decoration: InputDecoration(labelText: tr('colour')), value: settingsProvider.colour, - items: const [ + items: [ DropdownMenuItem( value: ColourSettings.basic, - child: Text('Obtainium'), + child: Text(tr('obtainium')), ), DropdownMenuItem( value: ColourSettings.materialYou, - child: Text('Material You'), + child: Text(tr('materialYou')), ) ], onChanged: (value) { @@ -68,20 +69,20 @@ class _SettingsPageState extends State { }); var sortDropdown = DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'App Sort By'), + decoration: InputDecoration(labelText: tr('appSortBy')), value: settingsProvider.sortColumn, - items: const [ + items: [ DropdownMenuItem( value: SortColumnSettings.authorName, - child: Text('Author/Name'), + child: Text(tr('authorName')), ), DropdownMenuItem( value: SortColumnSettings.nameAuthor, - child: Text('Name/Author'), + child: Text(tr('nameAuthor')), ), DropdownMenuItem( value: SortColumnSettings.added, - child: Text('As Added'), + child: Text(tr('asAdded')), ) ], onChanged: (value) { @@ -91,16 +92,16 @@ class _SettingsPageState extends State { }); var orderDropdown = DropdownButtonFormField( - decoration: const InputDecoration(labelText: 'App Sort Order'), + decoration: InputDecoration(labelText: tr('appSortOrder')), value: settingsProvider.sortOrder, - items: const [ + items: [ DropdownMenuItem( value: SortOrderSettings.ascending, - child: Text('Ascending'), + child: Text(tr('ascending')), ), DropdownMenuItem( value: SortOrderSettings.descending, - child: Text('Descending'), + child: Text(tr('descending')), ), ], onChanged: (value) { @@ -110,8 +111,7 @@ class _SettingsPageState extends State { }); var intervalDropdown = DropdownButtonFormField( - decoration: const InputDecoration( - labelText: 'Background Update Checking Interval'), + decoration: InputDecoration(labelText: tr('bgUpdateCheckInterval')), value: settingsProvider.updateInterval, items: updateIntervals.map((e) { int displayNum = (e < 60 @@ -120,15 +120,13 @@ class _SettingsPageState extends State { ? e / 60 : e / 1440) .round(); - var displayUnit = (e < 60 - ? 'Minute' - : e < 1440 - ? 'Hour' - : 'Day'); - String display = e == 0 - ? 'Never - Manual Only' - : '$displayNum $displayUnit${displayNum == 1 ? '' : 's'}'; + ? tr('neverManualOnly') + : (e < 60 + ? plural('minute', displayNum) + : e < 1440 + ? plural('hour', displayNum) + : plural('day', displayNum)); return DropdownMenuItem(value: e, child: Text(display)); }).toList(), onChanged: (value) { @@ -167,7 +165,7 @@ class _SettingsPageState extends State { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ - const CustomAppBar(title: 'Settings'), + CustomAppBar(title: tr('settings')), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -177,7 +175,7 @@ class _SettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Appearance', + tr('appearance'), style: TextStyle( color: Theme.of(context).colorScheme.primary), ), @@ -200,7 +198,7 @@ class _SettingsPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Show Source Webpage in App View'), + Text(tr('showWebInAppView')), Switch( value: settingsProvider.showAppWebpage, onChanged: (value) { @@ -212,7 +210,7 @@ class _SettingsPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Pin Updates to Top of Apps View'), + Text(tr('pinUpdates')), Switch( value: settingsProvider.pinUpdates, onChanged: (value) { @@ -225,7 +223,7 @@ class _SettingsPageState extends State { ), height16, Text( - 'Updates', + tr('updates'), style: TextStyle( color: Theme.of(context).colorScheme.primary), ), @@ -234,7 +232,7 @@ class _SettingsPageState extends State { height: 48, ), Text( - 'Source-Specific', + tr('sourceSpecific'), style: TextStyle( color: Theme.of(context).colorScheme.primary), ), @@ -256,15 +254,15 @@ class _SettingsPageState extends State { mode: LaunchMode.externalApplication); }, icon: const Icon(Icons.code), - label: const Text( - 'App Source', + label: Text( + tr('appSource'), ), ), TextButton.icon( onPressed: () { context.read().get().then((logs) { if (logs.isEmpty) { - showError(ObtainiumError('No Logs'), context); + showError(ObtainiumError(tr('noLogs')), context); } else { showDialog( context: context, @@ -275,7 +273,7 @@ class _SettingsPageState extends State { }); }, icon: const Icon(Icons.bug_report_outlined), - label: const Text('App Logs')), + label: Text(tr('appLogs'))), ], ), height16, @@ -306,7 +304,7 @@ class _LogsDialogState extends State { .then((value) { setState(() { String l = value.map((e) => e.toString()).join('\n\n'); - logString = l.isNotEmpty ? l : 'No Logs'; + logString = l.isNotEmpty ? l : tr('noLogs'); }); }); } @@ -317,7 +315,7 @@ class _LogsDialogState extends State { return AlertDialog( scrollable: true, - title: const Text('Obtainium App Logs'), + title: Text(tr('appLogs')), content: Column( children: [ DropdownButtonFormField( @@ -325,7 +323,7 @@ class _LogsDialogState extends State { items: days .map((e) => DropdownMenuItem( value: e, - child: Text('$e Day${e == 1 ? '' : 's'}'), + child: Text(plural('day', e)), )) .toList(), onChanged: (d) { @@ -342,13 +340,13 @@ class _LogsDialogState extends State { onPressed: () { Navigator.of(context).pop(); }, - child: const Text('Close')), + child: Text(tr('close'))), TextButton( onPressed: () { - Share.share(logString ?? '', subject: 'Obtainium App Logs'); + Share.share(logString ?? '', subject: tr('appLogs')); Navigator.of(context).pop(); }, - child: const Text('Share')) + child: Text(tr('share'))) ], ); } diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index cc72844..a8542ba 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -105,7 +106,7 @@ class AppsProvider with ChangeNotifier { } if (response.statusCode != 200) { tempDownloadedFile.deleteSync(); - throw response.reasonPhrase ?? 'Unknown Error'; + throw response.reasonPhrase ?? tr('unexpectedError'); } tempDownloadedFile.renameSync(downloadedFile.path); } @@ -127,7 +128,8 @@ class AppsProvider with ChangeNotifier { notifyListeners(); } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) { Fluttertoast.showToast( - msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT); + msg: tr('percentProgress', args: [prog.toString()]), + toastLength: Toast.LENGTH_SHORT); } prevProg = prog; }); @@ -272,7 +274,7 @@ class AppsProvider with ChangeNotifier { // 2. That cannot be installed silently (IF no buildContext was given for interactive install) for (var id in appIds) { if (apps[id] == null) { - throw ObtainiumError('App not found'); + throw ObtainiumError(tr('appNotFound')); } String? apkUrl; if (!apps[id]!.app.trackOnly) { @@ -413,6 +415,7 @@ class AppsProvider with ChangeNotifier { if (installedInfo != null && app.installedVersion == null) { if (app.latestVersion.characters .where((p0) => [ + // TODO: Won't work for other charsets '0', '1', '2', @@ -601,13 +604,13 @@ class AppsProvider with ChangeNotifier { Future exportApps() async { Directory? exportDir = Directory('/storage/emulated/0/Download'); - String path = 'Downloads'; + String path = 'Downloads'; // TODO: Is this true on non-english phones? if (!exportDir.existsSync()) { exportDir = await getExternalStorageDirectory(); path = exportDir!.path; } File export = File( - '${exportDir.path}/obtainium-export-${DateTime.now().millisecondsSinceEpoch}.json'); + '${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json'); export.writeAsStringSync( jsonEncode(apps.values.map((e) => e.app.toJson()).toList())); return path; @@ -643,7 +646,7 @@ class AppsProvider with ChangeNotifier { Map errorsMap = results[1]; for (var app in pps) { if (apps.containsKey(app.id)) { - errorsMap.addAll({app.id: 'App already added'}); + errorsMap.addAll({app.id: tr('appAlreadyAdded')}); } else { await saveApps([app]); } @@ -673,9 +676,9 @@ class _APKPickerState extends State { apkUrl ??= widget.initVal; return AlertDialog( scrollable: true, - title: const Text('Pick an APK'), + title: Text(tr('pickAnAPK')), content: Column(children: [ - Text('${widget.app.name} has more than one package:'), + Text(tr('appHasMoreThanOnePackage', args: [widget.app.name])), const SizedBox(height: 16), ...widget.app.apkUrls.map( (u) => RadioListTile( @@ -697,7 +700,11 @@ class _APKPickerState extends State { ), if (widget.archs != null) Text( - 'Note:\nYour device supports the ${widget.archs!.length == 1 ? '\'${widget.archs![0]}\' CPU architecture.' : 'following CPU architectures: ${list2FriendlyString(widget.archs!.map((e) => '\'$e\'').toList())}.'}', + widget.archs!.length == 1 + ? tr('deviceSupportsXArch', args: [widget.archs![0]]) + : tr('deviceSupportsFollowingArchs') + + list2FriendlyString( + widget.archs!.map((e) => '\'$e\'').toList()), style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12), ), ]), @@ -706,13 +713,13 @@ class _APKPickerState extends State { onPressed: () { Navigator.of(context).pop(null); }, - child: const Text('Cancel')), + child: Text(tr('cancel'))), TextButton( onPressed: () { HapticFeedback.selectionClick(); Navigator.of(context).pop(apkUrl); }, - child: const Text('Continue')) + child: Text(tr('continue'))) ], ); } @@ -734,21 +741,23 @@ class _APKOriginWarningDialogState extends State { Widget build(BuildContext context) { return AlertDialog( scrollable: true, - title: const Text('Warning'), - content: Text( - 'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'), + title: Text(tr('warning')), + content: Text(tr('sourceIsXButPackageFromYPrompt', args: [ + Uri.parse(widget.sourceUrl).host, + Uri.parse(widget.apkUrl).host + ])), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(null); }, - child: const Text('Cancel')), + child: Text(tr('cancel'))), TextButton( onPressed: () { HapticFeedback.selectionClick(); Navigator.of(context).pop(true); }, - child: const Text('Continue')) + child: Text(tr('continue'))) ], ); } diff --git a/lib/providers/logs_provider.dart b/lib/providers/logs_provider.dart index 83a634e..8dab366 100644 --- a/lib/providers/logs_provider.dart +++ b/lib/providers/logs_provider.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:sqflite/sqflite.dart'; @@ -85,7 +86,9 @@ create table if not exists $logTable ( var res = await (await getDB()) .delete(logTable, where: where.key, whereArgs: where.value); if (res > 0) { - add('Cleared $res logs (before = $before, after = $after)'); + add(plural('clearedNLogsBeforeXAfterY', res, + namedArgs: {'before': before.toString(), 'after': after.toString()}, + name: 'n')); } return res; } diff --git a/lib/providers/notifications_provider.dart b/lib/providers/notifications_provider.dart index 07f85ca..cdb22a4 100644 --- a/lib/providers/notifications_provider.dart +++ b/lib/providers/notifications_provider.dart @@ -1,6 +1,7 @@ // Exposes functions that can be used to send notifications to the user // Contains a set of pre-defined ObtainiumNotification objects that should be used throughout the app +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -21,33 +22,30 @@ class UpdateNotification extends ObtainiumNotification { UpdateNotification(List updates) : super( 2, - 'Updates Available', + tr('updatesAvailable'), '', 'UPDATES_AVAILABLE', - 'Updates Available', - 'Notifies the user that updates are available for one or more Apps tracked by Obtainium', + tr('updatesAvailable'), + tr('updatesAvailableNotifDescription'), Importance.max) { message = updates.isEmpty - ? "No new updates." + ? tr('noNewUpdates') : updates.length == 1 - ? '${updates[0].name} has an update.' - : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} have updates.'; + ? tr('xHasAnUpdate', args: [updates[0].name]) + : plural('xAndNMoreUpdatesAvailable', updates.length - 1, + args: [updates[0].name]); } } class SilentUpdateNotification extends ObtainiumNotification { SilentUpdateNotification(List updates) - : super( - 3, - 'Apps Updated', - '', - 'APPS_UPDATED', - 'Apps Updated', - 'Notifies the user that updates to one or more Apps were applied in the background', - Importance.defaultImportance) { + : super(3, tr('appsUpdated'), '', 'APPS_UPDATED', tr('appsUpdated'), + tr('appsUpdatedNotifDescription'), Importance.defaultImportance) { message = updates.length == 1 - ? '${updates[0].name} was updated to ${updates[0].latestVersion}.' - : '${(updates.length == 2 ? '${updates[0].name} and ${updates[1].name}' : '${updates[0].name} and ${updates.length - 1} more apps')} were updated.'; + ? tr('xWasUpdatedToY', + args: [updates[0].name, updates[0].latestVersion]) + : plural('xAndNMoreUpdatesInstalled', updates.length - 1, + args: [updates[0].name]); } } @@ -55,27 +53,21 @@ class ErrorCheckingUpdatesNotification extends ObtainiumNotification { ErrorCheckingUpdatesNotification(String error) : super( 5, - 'Error Checking for Updates', + tr('errorCheckingUpdates'), error, 'BG_UPDATE_CHECK_ERROR', - 'Error Checking for Updates', - 'A notification that shows when background update checking fails', + tr('errorCheckingUpdates'), + tr('errorCheckingUpdatesNotifDescription'), Importance.high); } class AppsRemovedNotification extends ObtainiumNotification { AppsRemovedNotification(List> namedReasons) - : super( - 6, - 'Apps Removed', - '', - 'APPS_REMOVED', - 'Apps Removed', - 'Notifies the user that one or more Apps were removed due to errors while loading them', - Importance.max) { + : super(6, tr('appsRemoved'), '', 'APPS_REMOVED', tr('appsRemoved'), + tr('appsRemovedNotifDescription'), Importance.max) { message = ''; for (var r in namedReasons) { - message += '${r[0]} was removed due to this error: ${r[1]}. \n'; + message += '${tr('xWasRemovedDueToErrorY', args: [r[0], r[1]])} \n'; } message = message.trim(); } @@ -83,20 +75,20 @@ class AppsRemovedNotification extends ObtainiumNotification { final completeInstallationNotification = ObtainiumNotification( 1, - 'Complete App Installation', - 'Obtainium must be open to install Apps', + tr('completeAppInstallation'), + tr('obtainiumMustBeOpenToInstallApps'), 'COMPLETE_INSTALL', - 'Complete App Installation', - 'Asks the user to return to Obtanium to finish installing an App', + tr('completeAppInstallation'), + tr('completeAppInstallationNotifDescription'), Importance.max); final checkingUpdatesNotification = ObtainiumNotification( 4, - 'Checking for Updates', + tr('checkingForUpdates'), '', 'BG_UPDATE_CHECK', - 'Checking for Updates', - 'Transient notification that appears when checking for updates', + tr('checkingForUpdates'), + tr('checkingForUpdatesNotifDescription'), Importance.min); class NotificationsProvider { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index ba30e69..02bdb54 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,5 +1,6 @@ // Exposes functions used to save/load app settings +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:obtainium/app_sources/github.dart'; @@ -109,8 +110,7 @@ class SettingsProvider with ChangeNotifier { while (!(await Permission.requestInstallPackages.isGranted)) { // Explicit request as InstallPlugin request sometimes bugged Fluttertoast.showToast( - msg: 'Please allow Obtainium to install Apps', - toastLength: Toast.LENGTH_LONG); + msg: tr('pleaseAllowInstallPerm'), toastLength: Toast.LENGTH_LONG); if ((await Permission.requestInstallPackages.request()) == PermissionStatus.granted) { break; diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index ca2c5cd..ac192a8 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; +import 'package:easy_localization/easy_localization.dart'; import 'package:html/dom.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/apkmirror.dart'; @@ -120,13 +121,6 @@ preStandardizeUrl(String url) { return url; } -const String couldNotFindReleases = 'Could not find a suitable release'; -const String couldNotFindLatestVersion = - 'Could not determine latest release version'; -String notValidURL(String sourceName) { - return 'Not a valid $sourceName App URL'; -} - const String noAPKFound = 'No APK found'; List getLinksFromParsedHTML( @@ -164,7 +158,7 @@ class AppSource { // Some additional data may be needed for Apps regardless of Source final List additionalAppSpecificSourceAgnosticFormItems = [ GeneratedFormItem( - label: 'Track-Only', + label: tr('trackOnly'), type: FormItemType.bool, key: 'trackOnlyFormItemKey') ]; @@ -192,8 +186,8 @@ class AppSource { } ObtainiumError getObtainiumHttpError(Response res) { - return ObtainiumError( - res.reasonPhrase ?? 'Error ${res.statusCode.toString()}'); + return ObtainiumError(res.reasonPhrase ?? + tr('errorWithHttpStatusCode', args: [res.statusCode.toString()])); } abstract class MassAppUrlSource { @@ -254,6 +248,7 @@ class SourceProvider { } for (int i = 0; i < parts.length - 1; i++) { if (RegExp('.*[A-Z].*').hasMatch(parts[i])) { + // TODO: RegEx won't work for non-eng chars return false; } } diff --git a/pubspec.lock b/pubspec.lock index 007c9cc..9615a04 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -141,6 +141,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.4" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + easy_logger: + dependency: transitive + description: + name: easy_logger + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.2" fake_async: dependency: transitive description: @@ -216,6 +230,11 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -282,6 +301,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" js: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index da83253..10e06d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: package_archive_info: ^0.1.0 android_alarm_manager_plus: ^2.1.0 sqflite: ^2.2.0+3 + easy_localization: ^3.0.1 dev_dependencies: @@ -89,9 +90,12 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: + # - assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg + + assets: + - assets/translations/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware