mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-30 21:13:28 +01:00 
			
		
		
		
	Compare commits
	
		
			19 Commits
		
	
	
		
			v0.8.1-bet
			...
			v0.8.4-bet
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | fbff498ae1 | ||
|  | bb4e470760 | ||
|  | 15183c3a95 | ||
|  | b496a416ff | ||
|  | 6ac7ba204f | ||
|  | 0951c007d1 | ||
|  | d835beec76 | ||
|  | 2654bf12d3 | ||
|  | 3951108bc9 | ||
|  | d934ce2e13 | ||
|  | 66cc7f059f | ||
|  | 098428dac9 | ||
|  | 9e7c21b408 | ||
|  | 31c2c6b7c1 | ||
|  | f70049aded | ||
|  | 60c28bf912 | ||
|  | a6ed1e7c98 | ||
|  | 963f51dc53 | ||
|  | 17b1f6e5b0 | 
							
								
								
									
										216
									
								
								assets/translations/en.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								assets/translations/en.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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 whose install status cannot be automatically detected.", | ||||
|     "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." | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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<GeneratedForm> { | ||||
|             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<GeneratedForm> { | ||||
|         } 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))) | ||||
|   | ||||
| @@ -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<GeneratedFormModal> { | ||||
|             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<GeneratedFormModal> { | ||||
|                       Navigator.of(context).pop(values); | ||||
|                     } | ||||
|                   }, | ||||
|             child: const Text('Continue')) | ||||
|             child: Text(tr('continue'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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<String, List<String>> 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<String> list) { | ||||
|   return list.length == 2 | ||||
|       ? '${list[0]} and ${list[1]}' | ||||
|       ? '${list[0]} ${tr('and')} ${list[1]}' | ||||
|       : list | ||||
|           .asMap() | ||||
|           .entries | ||||
|   | ||||
| @@ -15,8 +15,9 @@ 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 currentVersion = '0.8.4'; | ||||
| const String currentReleaseTag = | ||||
|     'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @@ -24,15 +25,16 @@ const int bgUpdateCheckAlarmId = 666; | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async { | ||||
|   LogsProvider logs = LogsProvider(); | ||||
|   logs.add('Started BG update check task'); | ||||
|   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||
|   WidgetsFlutterBinding.ensureInitialized(); | ||||
|   await EasyLocalization.ensureInitialized(); | ||||
|   LogsProvider logs = LogsProvider(); | ||||
|   logs.add(tr('startedBgUpdateTask')); | ||||
|   int? ignoreAfterMicroseconds = params?['ignoreAfterMicroseconds']; | ||||
|   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 +46,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? 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 +82,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? 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 +93,14 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? 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 +115,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 +146,7 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|     } 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([ | ||||
| @@ -155,14 +162,15 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|               ['true'], | ||||
|               null, | ||||
|               false, | ||||
|               false, | ||||
|               false) | ||||
|         ]); | ||||
|       } | ||||
|       // 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 +203,9 @@ class _ObtainiumState extends State<Obtainium> { | ||||
|       } | ||||
|       return MaterialApp( | ||||
|           title: 'Obtainium', | ||||
|           localizationsDelegates: context.localizationDelegates, | ||||
|           supportedLocales: context.supportedLocales, | ||||
|           locale: context.locale, | ||||
|           theme: ThemeData( | ||||
|               useMaterial3: true, | ||||
|               colorScheme: settingsProvider.theme == ThemeSettings.dark | ||||
|   | ||||
| @@ -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<String> requiredArgs = ['Username']; | ||||
|   late List<String> requiredArgs = [tr('uname')]; | ||||
|  | ||||
|   Future<Map<String, String>> 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<Map<String, String>> getUrlsWithDescriptions(List<String> args) async { | ||||
|     if (args.length != requiredArgs.length) { | ||||
|       throw ObtainiumError('Wrong number of arguments provided'); | ||||
|       throw ObtainiumError(tr('wrongArgNum')); | ||||
|     } | ||||
|     Map<String, String> urlsWithDescriptions = {}; | ||||
|     var page = 1; | ||||
|   | ||||
| @@ -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<AddAppPage> { | ||||
|                     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,15 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|             // 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); | ||||
|             // ignore: use_build_context_synchronously | ||||
|             var downloadedApk = await appsProvider.downloadApp(app, context); | ||||
|             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 +142,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Add App'), | ||||
|           CustomAppBar(title: tr('addApp')), | ||||
|           SliverFillRemaining( | ||||
|             child: Padding( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
| @@ -151,7 +156,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: 'App Source Url', | ||||
|                                           label: tr('appSourceURL'), | ||||
|                                           additionalValidators: [ | ||||
|                                             (value) { | ||||
|                                               try { | ||||
| @@ -165,7 +170,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                                     ? e | ||||
|                                                     : e is ObtainiumError | ||||
|                                                         ? e.toString() | ||||
|                                                         : 'Error'; | ||||
|                                                         : tr('error'); | ||||
|                                               } | ||||
|                                               return null; | ||||
|                                             } | ||||
| @@ -195,7 +200,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                               !otherAdditionalDataIsValid) | ||||
|                                       ? null | ||||
|                                       : addApp, | ||||
|                                   child: const Text('Add')) | ||||
|                                   child: Text(tr('add'))) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (sourceProvider.sources | ||||
| @@ -218,7 +223,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: 'Search (Some Sources Only)', | ||||
|                                           label: tr('searchSomeSourcesLabel'), | ||||
|                                           required: false), | ||||
|                                     ] | ||||
|                                   ], | ||||
| @@ -281,7 +286,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                           showError(e, context); | ||||
|                                         }); | ||||
|                                       }, | ||||
|                                 child: const Text('Search')) | ||||
|                                 child: Text(tr('search'))) | ||||
|                           ], | ||||
|                         ), | ||||
|                       if (pickedSource != null && | ||||
| @@ -301,7 +306,10 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               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 +373,8 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                               const SizedBox( | ||||
|                                 height: 48, | ||||
|                               ), | ||||
|                               const Text( | ||||
|                                 'Supported Sources:', | ||||
|                               Text( | ||||
|                                 tr('supportedSourcesBelow'), | ||||
|                               ), | ||||
|                               const SizedBox( | ||||
|                                 height: 8, | ||||
| @@ -379,7 +387,7 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                                 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, | ||||
|   | ||||
| @@ -114,7 +114,7 @@ class _AppPageState extends State<AppPage> { | ||||
|                           height: 32, | ||||
|                         ), | ||||
|                         Text( | ||||
|                           'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}', | ||||
|                           'Last Update Check: ${app?.app.lastUpdateCheck == null ? 'Never' : '\n${app?.app.lastUpdateCheck?.toLocal()}'}${app?.app.enhancedVersionDetection == true ? '\n\nThis App has enhanced version detection.' : ''}', | ||||
|                           textAlign: TextAlign.center, | ||||
|                           style: const TextStyle( | ||||
|                               fontStyle: FontStyle.italic, fontSize: 12), | ||||
| @@ -141,7 +141,9 @@ class _AppPageState extends State<AppPage> { | ||||
|                       children: [ | ||||
|                         if (app?.app.installedVersion != null && | ||||
|                             app?.app.trackOnly == false && | ||||
|                             app?.app.installedVersion != app?.app.latestVersion) | ||||
|                             app?.app.installedVersion != | ||||
|                                 app?.app.latestVersion && | ||||
|                             app?.app.enhancedVersionDetection != true) | ||||
|                           IconButton( | ||||
|                               onPressed: app?.downloadProgress != null | ||||
|                                   ? null | ||||
|   | ||||
| @@ -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<AppsPage> { | ||||
|             }); | ||||
|           }, | ||||
|           child: CustomScrollView(slivers: <Widget>[ | ||||
|             const CustomAppBar(title: 'Apps'), | ||||
|             CustomAppBar(title: tr('appsString')), | ||||
|             if (appsProvider.loadingApps || sortedApps.isEmpty) | ||||
|               SliverFillRemaining( | ||||
|                   child: Center( | ||||
| @@ -199,8 +200,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                           ? 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, | ||||
|                             ))), | ||||
| @@ -218,6 +219,9 @@ class AppsPageState extends State<AppsPage> { | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
|               String? changesUrl = SourceProvider() | ||||
|                   .getSource(sortedApps[index].app.url) | ||||
|                   .changeLogPageFromStandardUrl(sortedApps[index].app.url); | ||||
|               return ListTile( | ||||
|                 tileColor: sortedApps[index].app.pinned | ||||
|                     ? Colors.grey.withOpacity(0.1) | ||||
| @@ -244,58 +248,53 @@ class AppsPageState extends State<AppsPage> { | ||||
|                           ? 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()}%') | ||||
|                     : (sortedApps[index].app.installedVersion != null && | ||||
|                             sortedApps[index].app.installedVersion != | ||||
|                                 sortedApps[index].app.latestVersion | ||||
|                         ? Column( | ||||
|                     ? Text(tr('percentProgress', args: [ | ||||
|                         sortedApps[index] | ||||
|                                 .downloadProgress | ||||
|                                 ?.toInt() | ||||
|                                 .toString() ?? | ||||
|                             '100' | ||||
|                       ])) | ||||
|                     : (Column( | ||||
|                         mainAxisAlignment: MainAxisAlignment.center, | ||||
|                         crossAxisAlignment: CrossAxisAlignment.end, | ||||
|                         children: [ | ||||
|                               Text(appsProvider.areDownloadsRunning() | ||||
|                                   ? 'Please Wait...' | ||||
|                                   : 'Update Available${sortedApps[index].app.trackOnly ? ' (Est.)' : ''}'), | ||||
|                               SourceProvider() | ||||
|                                           .getSource(sortedApps[index].app.url) | ||||
|                                           .changeLogPageFromStandardUrl( | ||||
|                                               sortedApps[index].app.url) == | ||||
|                                       null | ||||
|                                   ? const SizedBox() | ||||
|                                   : GestureDetector( | ||||
|                                       onTap: () { | ||||
|                                         launchUrlString( | ||||
|                                             SourceProvider() | ||||
|                                                 .getSource( | ||||
|                                                     sortedApps[index].app.url) | ||||
|                                                 .changeLogPageFromStandardUrl( | ||||
|                                                     sortedApps[index].app.url)!, | ||||
|                                             mode: | ||||
|                                                 LaunchMode.externalApplication); | ||||
|                                       }, | ||||
|                                       child: const Text( | ||||
|                                         'See Changes', | ||||
|                                         style: TextStyle( | ||||
|                                             fontStyle: FontStyle.italic, | ||||
|                                             decoration: | ||||
|                                                 TextDecoration.underline), | ||||
|                                       )), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : SingleChildScrollView( | ||||
|                           SingleChildScrollView( | ||||
|                               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, | ||||
|                                 )))), | ||||
|                                   ))), | ||||
|                           sortedApps[index].app.installedVersion != null && | ||||
|                                   sortedApps[index].app.installedVersion != | ||||
|                                       sortedApps[index].app.latestVersion | ||||
|                               ? GestureDetector( | ||||
|                                   onTap: changesUrl == null | ||||
|                                       ? null | ||||
|                                       : () { | ||||
|                                           launchUrlString(changesUrl, | ||||
|                                               mode: LaunchMode | ||||
|                                                   .externalApplication); | ||||
|                                         }, | ||||
|                                   child: Text( | ||||
|                                     '${tr('updateAvailable')}${sortedApps[index].app.trackOnly ? ' ${tr('estimateInBracketsShort')}' : ''}', | ||||
|                                     style: TextStyle( | ||||
|                                         fontStyle: FontStyle.italic, | ||||
|                                         decoration: changesUrl == null | ||||
|                                             ? TextDecoration.none | ||||
|                                             : TextDecoration.underline), | ||||
|                                   )) | ||||
|                               : const SizedBox(), | ||||
|                         ], | ||||
|                       )), | ||||
|                 onTap: () { | ||||
|                   if (selectedApps.isNotEmpty) { | ||||
|                     toggleAppSelected(sortedApps[index].app); | ||||
| @@ -327,8 +326,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                   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 +342,15 @@ class AppsPageState extends State<AppsPage> { | ||||
|                               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 +359,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             } | ||||
|                           }); | ||||
|                         }, | ||||
|                         tooltip: 'Remove Selected Apps', | ||||
|                         tooltip: tr('removeSelectedApps'), | ||||
|                         icon: const Icon(Icons.delete_outline_outlined), | ||||
|                       ), | ||||
|                 IconButton( | ||||
| @@ -373,16 +375,20 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             List<String> 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 +396,10 @@ class AppsPageState extends State<AppsPage> { | ||||
|                             } | ||||
|                             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 +413,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                       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 +467,9 @@ class AppsPageState extends State<AppsPage> { | ||||
|                               } | ||||
|                             }); | ||||
|                           }, | ||||
|                     tooltip: | ||||
|                         'Install/Update${selectedApps.isEmpty ? ' ' : ' Selected '}Apps', | ||||
|                     tooltip: selectedApps.isEmpty | ||||
|                         ? tr('installUpdateApps') | ||||
|                         : tr('installUpdateSelectedApps'), | ||||
|                     icon: const Icon( | ||||
|                       Icons.file_download_outlined, | ||||
|                     )), | ||||
| @@ -492,11 +501,15 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                   (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 +517,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'No')), | ||||
|                                                                         child: Text( | ||||
|                                                                             tr('no'))), | ||||
|                                                                     TextButton( | ||||
|                                                                         onPressed: | ||||
|                                                                             () { | ||||
| @@ -513,8 +526,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                               .selectionClick(); | ||||
|                                                                           appsProvider | ||||
|                                                                               .saveApps(selectedApps.map((a) { | ||||
|                                                                             if (a.installedVersion != | ||||
|                                                                                 null) { | ||||
|                                                                             if (a.installedVersion != null && | ||||
|                                                                                 !a.enhancedVersionDetection) { | ||||
|                                                                               a.installedVersion = a.latestVersion; | ||||
|                                                                             } | ||||
|                                                                             return a; | ||||
| @@ -523,8 +536,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                                           Navigator.of(context) | ||||
|                                                                               .pop(); | ||||
|                                                                         }, | ||||
|                                                                         child: const Text( | ||||
|                                                                             'Yes')) | ||||
|                                                                         child: Text( | ||||
|                                                                             tr('yes'))) | ||||
|                                                                   ], | ||||
|                                                                 ); | ||||
|                                                               }).whenComplete(() { | ||||
| @@ -534,7 +547,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                           }); | ||||
|                                                         }, | ||||
|                                               tooltip: | ||||
|                                                   'Mark Selected Apps as Updated', | ||||
|                                                   tr('markSelectedAppsUpdated'), | ||||
|                                               icon: const Icon(Icons.done)), | ||||
|                                           IconButton( | ||||
|                                             onPressed: () { | ||||
| @@ -549,8 +562,12 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                               }).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 +585,11 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                               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 +598,19 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                   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 +624,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                                 Navigator.of(context).pop(); | ||||
|                                               }); | ||||
|                                             }, | ||||
|                                             tooltip: 'Reset Install Status', | ||||
|                                             tooltip: tr('resetInstallStatus'), | ||||
|                                             icon: const Icon( | ||||
|                                                 Icons.restore_page_outlined), | ||||
|                                           ), | ||||
| @@ -610,7 +633,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                                 ); | ||||
|                               }); | ||||
|                         }, | ||||
|                         tooltip: 'More', | ||||
|                         tooltip: tr('more'), | ||||
|                         icon: const Icon(Icons.more_horiz), | ||||
|                       ), | ||||
|               ], | ||||
| @@ -628,8 +651,8 @@ class AppsPageState extends State<AppsPage> { | ||||
|                 }); | ||||
|               }, | ||||
|               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 +664,7 @@ class AppsPageState extends State<AppsPage> { | ||||
|                 ? 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 +675,22 @@ class AppsPageState extends State<AppsPage> { | ||||
|                           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) | ||||
|                                   ] | ||||
|                                 ], | ||||
|   | ||||
| @@ -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<HomePage> { | ||||
|   List<int> selectedIndexHistory = []; | ||||
|  | ||||
|   List<NavigationPageItem> pages = [ | ||||
|     NavigationPageItem(tr('appsString'), Icons.apps, | ||||
|         AppsPage(key: GlobalKey<AppsPageState>())), | ||||
|     NavigationPageItem(tr('addApp'), Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem( | ||||
|         'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())), | ||||
|     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 | ||||
|   | ||||
| @@ -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<ImportExportPage> { | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Import/Export'), | ||||
|           CustomAppBar(title: tr('importExport')), | ||||
|           SliverFillRemaining( | ||||
|               child: Padding( | ||||
|                   padding: | ||||
| @@ -63,10 +64,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                               .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<ImportExportPage> { | ||||
|                                                 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<ImportExportPage> { | ||||
|                                             }); | ||||
|                                           }); | ||||
|                                         }, | ||||
|                                   child: const Text('Obtainium Import'))) | ||||
|                                   child: Text(tr('obtainiumImport')))) | ||||
|                         ], | ||||
|                       ), | ||||
|                       if (importInProgress) | ||||
| @@ -138,11 +142,11 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                       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<ImportExportPage> { | ||||
|                                                                 .getSource( | ||||
|                                                                     lines[i]); | ||||
|                                                           } catch (e) { | ||||
|                                                             return 'Line ${i + 1}: $e'; | ||||
|                                                             return '${tr('line')} ${i + 1}: $e'; | ||||
|                                                           } | ||||
|                                                         } | ||||
|                                                       } | ||||
| @@ -182,7 +186,9 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                           .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<ImportExportPage> { | ||||
|                                     } | ||||
|                                   }); | ||||
|                                 }, | ||||
|                           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<ImportExportPage> { | ||||
|                                                       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<ImportExportPage> { | ||||
|                                                         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<ImportExportPage> { | ||||
|                                                       } | ||||
|                                                     } else { | ||||
|                                                       throw ObtainiumError( | ||||
|                                                           'No results found'); | ||||
|                                                           tr('noResults')); | ||||
|                                                     } | ||||
|                                                   } | ||||
|                                                 }() | ||||
| @@ -303,8 +319,9 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                               }, | ||||
|                                         child: Text( | ||||
|                                             'Search ${source.runtimeType}')) | ||||
|                                         child: Text(tr('searchX', args: [ | ||||
|                                           source.runtimeType.toString() | ||||
|                                         ]))) | ||||
|                                   ])) | ||||
|                           .toList(), | ||||
|                       ...sourceProvider.massUrlSources | ||||
| @@ -323,8 +340,10 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|                                                       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<ImportExportPage> { | ||||
|                                                       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<ImportExportPage> { | ||||
|                                                   }); | ||||
|                                                 }); | ||||
|                                               }, | ||||
|                                         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<ImportErrorDialog> { | ||||
|   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<ImportErrorDialog> { | ||||
|             onPressed: () { | ||||
|               Navigator.of(context).pop(null); | ||||
|             }, | ||||
|             child: const Text('Okay')) | ||||
|             child: Text(tr('okay'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| @@ -505,8 +533,8 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> { | ||||
|   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<UrlSelectionModal> { | ||||
|             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<UrlSelectionModal> { | ||||
|                             .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) | ||||
|                   ]))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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<SettingsPage> { | ||||
|     } | ||||
|  | ||||
|     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<SettingsPage> { | ||||
|         }); | ||||
|  | ||||
|     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<SettingsPage> { | ||||
|         }); | ||||
|  | ||||
|     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<SettingsPage> { | ||||
|         }); | ||||
|  | ||||
|     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<SettingsPage> { | ||||
|         }); | ||||
|  | ||||
|     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<SettingsPage> { | ||||
|                       ? 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<SettingsPage> { | ||||
|     return Scaffold( | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: CustomScrollView(slivers: <Widget>[ | ||||
|           const CustomAppBar(title: 'Settings'), | ||||
|           CustomAppBar(title: tr('settings')), | ||||
|           SliverToBoxAdapter( | ||||
|               child: Padding( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
| @@ -177,7 +175,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               'Appearance', | ||||
|                               tr('appearance'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
| @@ -200,7 +198,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             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<SettingsPage> { | ||||
|                             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<SettingsPage> { | ||||
|                             ), | ||||
|                             height16, | ||||
|                             Text( | ||||
|                               'Updates', | ||||
|                               tr('updates'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
| @@ -234,7 +232,7 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                               height: 48, | ||||
|                             ), | ||||
|                             Text( | ||||
|                               'Source-Specific', | ||||
|                               tr('sourceSpecific'), | ||||
|                               style: TextStyle( | ||||
|                                   color: Theme.of(context).colorScheme.primary), | ||||
|                             ), | ||||
| @@ -256,15 +254,15 @@ class _SettingsPageState extends State<SettingsPage> { | ||||
|                             mode: LaunchMode.externalApplication); | ||||
|                       }, | ||||
|                       icon: const Icon(Icons.code), | ||||
|                       label: const Text( | ||||
|                         'App Source', | ||||
|                       label: Text( | ||||
|                         tr('appSource'), | ||||
|                       ), | ||||
|                     ), | ||||
|                     TextButton.icon( | ||||
|                         onPressed: () { | ||||
|                           context.read<LogsProvider>().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<SettingsPage> { | ||||
|                           }); | ||||
|                         }, | ||||
|                         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<LogsDialog> { | ||||
|           .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<LogsDialog> { | ||||
|  | ||||
|     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<LogsDialog> { | ||||
|               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<LogsDialog> { | ||||
|             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'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -6,9 +6,9 @@ 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'; | ||||
| import 'package:install_plugin_v2/install_plugin_v2.dart'; | ||||
| import 'package:installed_apps/app_info.dart'; | ||||
| import 'package:installed_apps/installed_apps.dart'; | ||||
| @@ -105,19 +105,23 @@ class AppsProvider with ChangeNotifier { | ||||
|       } | ||||
|       if (response.statusCode != 200) { | ||||
|         tempDownloadedFile.deleteSync(); | ||||
|         throw response.reasonPhrase ?? 'Unknown Error'; | ||||
|         throw response.reasonPhrase ?? tr('unexpectedError'); | ||||
|       } | ||||
|       tempDownloadedFile.renameSync(downloadedFile.path); | ||||
|     } | ||||
|     return downloadedFile; | ||||
|   } | ||||
|  | ||||
|   Future<DownloadedApk> downloadApp(App app) async { | ||||
|   Future<DownloadedApk> downloadApp(App app, BuildContext? context) async { | ||||
|     var fileName = | ||||
|         '${app.id}-${app.latestVersion}-${app.preferredApkIndex}.apk'; | ||||
|     String downloadUrl = await SourceProvider() | ||||
|         .getSource(app.url) | ||||
|         .apkUrlPrefetchModifier(app.apkUrls[app.preferredApkIndex]); | ||||
|     NotificationsProvider? notificationsProvider = | ||||
|         context?.read<NotificationsProvider>(); | ||||
|     var notif = DownloadNotification(app.name, 100); | ||||
|     notificationsProvider?.cancel(notif.id); | ||||
|     int? prevProg; | ||||
|     File downloadedFile = | ||||
|         await downloadFile(downloadUrl, fileName, (double? progress) { | ||||
| @@ -125,12 +129,14 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (apps[app.id] != null) { | ||||
|         apps[app.id]!.downloadProgress = progress; | ||||
|         notifyListeners(); | ||||
|       } else if ((prog == 25 || prog == 50 || prog == 75) && prevProg != prog) { | ||||
|         Fluttertoast.showToast( | ||||
|             msg: 'Progress: $prog%', toastLength: Toast.LENGTH_SHORT); | ||||
|       } | ||||
|       notif = DownloadNotification(app.name, prog ?? 100); | ||||
|       if (prog != null && prevProg != prog) { | ||||
|         notificationsProvider?.notify(notif); | ||||
|       } | ||||
|       prevProg = prog; | ||||
|     }); | ||||
|     notificationsProvider?.cancel(notif.id); | ||||
|     // Delete older versions of the APK if any | ||||
|     for (var file in downloadedFile.parent.listSync()) { | ||||
|       var fn = file.path.split('/').last; | ||||
| @@ -272,7 +278,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) { | ||||
| @@ -303,7 +309,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     List<DownloadedApk?> downloadedFiles = | ||||
|         await Future.wait(appsToInstall.map((id) async { | ||||
|       try { | ||||
|         return await downloadApp(apps[id]!.app); | ||||
|         return await downloadApp(apps[id]!.app, context); | ||||
|       } catch (e) { | ||||
|         errors.add(id, e.toString()); | ||||
|       } | ||||
| @@ -394,9 +400,9 @@ class AppsProvider with ChangeNotifier { | ||||
|   } | ||||
|  | ||||
|   // If the App says it is installed but installedInfo is null, set it to not installed | ||||
|   // If the App says is is not installed but installedInfo exists, try to set it to installed as latest version... | ||||
|   // ...if the latestVersion seems to match the version in installedInfo (not guaranteed) | ||||
|   // If that fails, just set it to the actual version string (all we can do at that point) | ||||
|   // If the App says is is not installed but installedInfo exists, set it to the real installed version | ||||
|   // If the internal version does not match the real one, sync them if the App supports enhanced version detection | ||||
|   // Enhanced version detection will be true if the version extracted from source matches the standard version format | ||||
|   // Don't save changes, just return the object if changes were made (else null) | ||||
|   // If in a background isolate, return null straight away as the required plugin won't work anyways | ||||
|   App? getCorrectedInstallStatusAppIfPossible(App app, AppInfo? installedInfo) { | ||||
| @@ -409,28 +415,21 @@ class AppsProvider with ChangeNotifier { | ||||
|         !app.trackOnly) { | ||||
|       app.installedVersion = null; | ||||
|       modded = true; | ||||
|     } | ||||
|     if (installedInfo != null && app.installedVersion == null) { | ||||
|       if (app.latestVersion.characters | ||||
|               .where((p0) => [ | ||||
|                     '0', | ||||
|                     '1', | ||||
|                     '2', | ||||
|                     '3', | ||||
|                     '4', | ||||
|                     '5', | ||||
|                     '6', | ||||
|                     '7', | ||||
|                     '8', | ||||
|                     '9', | ||||
|                     '.' | ||||
|                   ].contains(p0)) | ||||
|               .join('') == | ||||
|           installedInfo.versionName) { | ||||
|     } else if (installedInfo != null && app.installedVersion == null) { | ||||
|       if (app.enhancedVersionDetection) { | ||||
|         app.installedVersion = installedInfo.versionName; | ||||
|       } else { | ||||
|         if (app.latestVersion.contains(installedInfo.versionName!)) { | ||||
|           app.installedVersion = app.latestVersion; | ||||
|         } else { | ||||
|           app.installedVersion = installedInfo.versionName; | ||||
|         } | ||||
|       } | ||||
|       modded = true; | ||||
|     } else if (installedInfo?.versionName != app.installedVersion && | ||||
|         app.enhancedVersionDetection && | ||||
|         !app.trackOnly) { | ||||
|       app.installedVersion = installedInfo?.versionName; | ||||
|       modded = true; | ||||
|     } | ||||
|     return modded ? app : null; | ||||
| @@ -601,13 +600,13 @@ class AppsProvider with ChangeNotifier { | ||||
|  | ||||
|   Future<String> 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 +642,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     Map<String, dynamic> 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 +672,9 @@ class _APKPickerState extends State<APKPicker> { | ||||
|     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<String>( | ||||
| @@ -697,7 +696,11 @@ class _APKPickerState extends State<APKPicker> { | ||||
|           ), | ||||
|         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 +709,13 @@ class _APKPickerState extends State<APKPicker> { | ||||
|             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 +737,23 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> { | ||||
|   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'))) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
| @@ -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'; | ||||
|  | ||||
| @@ -12,42 +13,41 @@ class ObtainiumNotification { | ||||
|   late String channelName; | ||||
|   late String channelDescription; | ||||
|   Importance importance; | ||||
|   bool onlyAlertOnce; | ||||
|  | ||||
|   ObtainiumNotification(this.id, this.title, this.message, this.channelCode, | ||||
|       this.channelName, this.channelDescription, this.importance); | ||||
|       this.channelName, this.channelDescription, this.importance, | ||||
|       {this.onlyAlertOnce = false}); | ||||
| } | ||||
|  | ||||
| class UpdateNotification extends ObtainiumNotification { | ||||
|   UpdateNotification(List<App> 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<App> 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,48 +55,57 @@ 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<List<String>> 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(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class DownloadNotification extends ObtainiumNotification { | ||||
|   DownloadNotification(String appName, int progPercent) | ||||
|       : super( | ||||
|             appName.hashCode, | ||||
|             'Downloading $appName', | ||||
|             '$progPercent%', | ||||
|             'APP_DOWNLOADING', | ||||
|             'Downloading App', | ||||
|             'Notifies the user of the progress in downloading an App', | ||||
|             Importance.defaultImportance, | ||||
|             onlyAlertOnce: true) { | ||||
|     message = tr('percentProgress', args: [progPercent.toString()]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| 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 { | ||||
| @@ -136,7 +145,9 @@ class NotificationsProvider { | ||||
|       String channelName, | ||||
|       String channelDescription, | ||||
|       Importance importance, | ||||
|       {bool cancelExisting = false}) async { | ||||
|       {bool cancelExisting = false, | ||||
|       int? progPercent, | ||||
|       bool onlyAlertOnce = false}) async { | ||||
|     if (cancelExisting) { | ||||
|       await cancel(id); | ||||
|     } | ||||
| @@ -152,12 +163,16 @@ class NotificationsProvider { | ||||
|                 channelDescription: channelDescription, | ||||
|                 importance: importance, | ||||
|                 priority: importanceToPriority[importance]!, | ||||
|                 groupKey: 'dev.imranr.obtainium.$channelCode'))); | ||||
|                 groupKey: 'dev.imranr.obtainium.$channelCode', | ||||
|                 progress: progPercent ?? 0, | ||||
|                 maxProgress: 100, | ||||
|                 showProgress: progPercent != null, | ||||
|                 onlyAlertOnce: onlyAlertOnce))); | ||||
|   } | ||||
|  | ||||
|   Future<void> notify(ObtainiumNotification notif, | ||||
|           {bool cancelExisting = false}) => | ||||
|       notifyRaw(notif.id, notif.title, notif.message, notif.channelCode, | ||||
|           notif.channelName, notif.channelDescription, notif.importance, | ||||
|           cancelExisting: cancelExisting); | ||||
|           cancelExisting: cancelExisting, onlyAlertOnce: notif.onlyAlertOnce); | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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'; | ||||
| @@ -26,9 +27,15 @@ class AppNames { | ||||
|  | ||||
| class APKDetails { | ||||
|   late String version; | ||||
|   late String versionFromSource; | ||||
|   late bool isStandardVersion; | ||||
|   late List<String> apkUrls; | ||||
|  | ||||
|   APKDetails(this.version, this.apkUrls); | ||||
|   APKDetails(this.versionFromSource, this.apkUrls) { | ||||
|     var temp = extractStandardVersionName(versionFromSource); | ||||
|     isStandardVersion = temp != null; | ||||
|     version = temp ?? versionFromSource; | ||||
|   } | ||||
| } | ||||
|  | ||||
| class App { | ||||
| @@ -44,6 +51,7 @@ class App { | ||||
|   late DateTime? lastUpdateCheck; | ||||
|   bool pinned = false; | ||||
|   bool trackOnly = false; | ||||
|   bool enhancedVersionDetection = false; | ||||
|   App( | ||||
|       this.id, | ||||
|       this.url, | ||||
| @@ -56,7 +64,8 @@ class App { | ||||
|       this.additionalData, | ||||
|       this.lastUpdateCheck, | ||||
|       this.pinned, | ||||
|       this.trackOnly); | ||||
|       this.trackOnly, | ||||
|       this.enhancedVersionDetection); | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
| @@ -85,7 +94,8 @@ class App { | ||||
|           ? null | ||||
|           : DateTime.fromMicrosecondsSinceEpoch(json['lastUpdateCheck']), | ||||
|       json['pinned'] ?? false, | ||||
|       json['trackOnly'] ?? false); | ||||
|       json['trackOnly'] ?? false, | ||||
|       json['enhancedVersionDetection'] ?? false); | ||||
|  | ||||
|   Map<String, dynamic> toJson() => { | ||||
|         'id': id, | ||||
| @@ -99,7 +109,8 @@ class App { | ||||
|         'additionalData': jsonEncode(additionalData), | ||||
|         'lastUpdateCheck': lastUpdateCheck?.microsecondsSinceEpoch, | ||||
|         'pinned': pinned, | ||||
|         'trackOnly': trackOnly | ||||
|         'trackOnly': trackOnly, | ||||
|         'enhancedVersionDetection': enhancedVersionDetection | ||||
|       }; | ||||
| } | ||||
|  | ||||
| @@ -120,13 +131,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<String> getLinksFromParsedHTML( | ||||
| @@ -164,7 +168,7 @@ class AppSource { | ||||
|   // Some additional data may be needed for Apps regardless of Source | ||||
|   final List<GeneratedFormItem> additionalAppSpecificSourceAgnosticFormItems = [ | ||||
|     GeneratedFormItem( | ||||
|         label: 'Track-Only', | ||||
|         label: tr('trackOnly'), | ||||
|         type: FormItemType.bool, | ||||
|         key: 'trackOnlyFormItemKey') | ||||
|   ]; | ||||
| @@ -192,8 +196,15 @@ class AppSource { | ||||
| } | ||||
|  | ||||
| ObtainiumError getObtainiumHttpError(Response res) { | ||||
|   return ObtainiumError( | ||||
|       res.reasonPhrase ?? 'Error ${res.statusCode.toString()}'); | ||||
|   return ObtainiumError(res.reasonPhrase ?? | ||||
|       tr('errorWithHttpStatusCode', args: [res.statusCode.toString()])); | ||||
| } | ||||
|  | ||||
| String? extractStandardVersionName(String version, {bool strict = false}) { | ||||
|   var match = | ||||
|       RegExp('${strict ? '^' : ''}[0-9]+(\\.[0-9]+)+${strict ? '\$' : ''}') | ||||
|           .firstMatch(version); | ||||
|   return match != null ? version.substring(match.start, match.end) : null; | ||||
| } | ||||
|  | ||||
| abstract class MassAppUrlSource { | ||||
| @@ -254,6 +265,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; | ||||
|       } | ||||
|     } | ||||
| @@ -273,6 +285,12 @@ class SourceProvider { | ||||
|     if (apk.apkUrls.isEmpty && !trackOnly) { | ||||
|       throw NoAPKError(); | ||||
|     } | ||||
|     bool enhancedVersionDetection = apk.isStandardVersion && | ||||
|         installedVersion != null && | ||||
|         extractStandardVersionName(installedVersion, strict: true) != null; | ||||
|     if (!enhancedVersionDetection) { | ||||
|       apk.version = apk.versionFromSource; | ||||
|     } | ||||
|     String apkVersion = apk.version.replaceAll('/', '-'); | ||||
|     return App( | ||||
|         id ?? | ||||
| @@ -290,7 +308,8 @@ class SourceProvider { | ||||
|         additionalData, | ||||
|         DateTime.now(), | ||||
|         pinned, | ||||
|         trackOnly); | ||||
|         trackOnly, | ||||
|         enhancedVersionDetection); | ||||
|   } | ||||
|  | ||||
|   // Returns errors in [results, errors] instead of throwing them | ||||
|   | ||||
							
								
								
									
										34
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								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: | ||||
| @@ -168,7 +182,7 @@ packages: | ||||
|       name: file_picker | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "5.2.2" | ||||
|     version: "5.2.3" | ||||
|   flutter: | ||||
|     dependency: "direct main" | ||||
|     description: flutter | ||||
| @@ -180,7 +194,7 @@ packages: | ||||
|       name: flutter_fgbg | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "0.2.1" | ||||
|     version: "0.2.2" | ||||
|   flutter_launcher_icons: | ||||
|     dependency: "direct dev" | ||||
|     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: | ||||
| @@ -330,7 +356,7 @@ packages: | ||||
|       name: mime | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.2" | ||||
|     version: "1.0.3" | ||||
|   nested: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -573,7 +599,7 @@ packages: | ||||
|       name: sqflite | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.2.0+3" | ||||
|     version: "2.2.1" | ||||
|   sqflite_common: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|   | ||||
| @@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev | ||||
| # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html | ||||
| # In Windows, build-name is used as the major, minor, and patch parts | ||||
| # of the product and file versions while build-number is used as the build suffix. | ||||
| version: 0.8.1+64 # When changing this, update the tag in main() accordingly | ||||
| version: 0.8.4+67 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.18.2 <3.0.0' | ||||
| @@ -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,10 +90,13 @@ 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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user