mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-28 20:13:28 +01:00 
			
		
		
		
	Merge pull request #31 from ImranR98/apps-list-improvements
Added - Multi select on the Apps page with share, delete, and install actions - #23 - (Related to above) Ability to filter and update all out of date Apps - #27 - Notifying users to return to the App to complete installs is less buggy thanks to the new installer plugin - #24
This commit is contained in:
		| @@ -21,9 +21,9 @@ class GitHub implements AppSource { | ||||
|   Future<APKDetails> getLatestAPKDetails( | ||||
|       String standardUrl, List<String> additionalData) async { | ||||
|     var includePrereleases = | ||||
|         additionalData.isNotEmpty && additionalData[0] == "true"; | ||||
|         additionalData.isNotEmpty && additionalData[0] == 'true'; | ||||
|     var fallbackToOlderReleases = | ||||
|         additionalData.length >= 2 && additionalData[1] == "true"; | ||||
|         additionalData.length >= 2 && additionalData[1] == 'true'; | ||||
|     var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty | ||||
|         ? additionalData[2] | ||||
|         : null; | ||||
| @@ -92,14 +92,14 @@ class GitHub implements AppSource { | ||||
|  | ||||
|   @override | ||||
|   List<List<GeneratedFormItem>> additionalDataFormItems = [ | ||||
|     [GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)], | ||||
|     [GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: "Fallback to older releases", type: FormItemType.bool) | ||||
|           label: 'Fallback to older releases', type: FormItemType.bool) | ||||
|     ], | ||||
|     [ | ||||
|       GeneratedFormItem( | ||||
|           label: "Filter Release Titles by Regular Expression", | ||||
|           label: 'Filter Release Titles by Regular Expression', | ||||
|           type: FormItemType.string, | ||||
|           required: false, | ||||
|           additionalValidators: [ | ||||
| @@ -110,7 +110,7 @@ class GitHub implements AppSource { | ||||
|               try { | ||||
|                 RegExp(value); | ||||
|               } catch (e) { | ||||
|                 return "Invalid regular expression"; | ||||
|                 return 'Invalid regular expression'; | ||||
|               } | ||||
|               return null; | ||||
|             } | ||||
| @@ -119,5 +119,5 @@ class GitHub implements AppSource { | ||||
|   ]; | ||||
|  | ||||
|   @override | ||||
|   List<String> additionalDataDefaults = ["true", "true", ""]; | ||||
|   List<String> additionalDataDefaults = ['true', 'true', '']; | ||||
| } | ||||
|   | ||||
| @@ -12,7 +12,7 @@ class GeneratedFormItem { | ||||
|   late List<String? Function(String? value)> additionalValidators; | ||||
|  | ||||
|   GeneratedFormItem( | ||||
|       {this.label = "Input", | ||||
|       {this.label = 'Input', | ||||
|       this.type = FormItemType.string, | ||||
|       this.required = true, | ||||
|       this.max = 1, | ||||
| @@ -69,7 +69,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|         .map((row) => row.map((e) { | ||||
|               return j < widget.defaultValues.length | ||||
|                   ? widget.defaultValues[j++] | ||||
|                   : ""; | ||||
|                   : ''; | ||||
|             }).toList()) | ||||
|         .toList(); | ||||
|  | ||||
| @@ -89,7 +89,7 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|               }); | ||||
|             }, | ||||
|             decoration: InputDecoration( | ||||
|                 helperText: e.value.label + (e.value.required ? " *" : "")), | ||||
|                 helperText: e.value.label + (e.value.required ? ' *' : '')), | ||||
|             minLines: e.value.max <= 1 ? null : e.value.max, | ||||
|             maxLines: e.value.max <= 1 ? 1 : e.value.max, | ||||
|             validator: (value) { | ||||
| @@ -122,10 +122,10 @@ class _GeneratedFormState extends State<GeneratedForm> { | ||||
|             children: [ | ||||
|               Text(widget.items[r][e].label), | ||||
|               Switch( | ||||
|                   value: values[r][e] == "true", | ||||
|                   value: values[r][e] == 'true', | ||||
|                   onChanged: (value) { | ||||
|                     setState(() { | ||||
|                       values[r][e] = value ? "true" : ""; | ||||
|                       values[r][e] = value ? 'true' : ''; | ||||
|                       someValueChanged(); | ||||
|                     }); | ||||
|                   }) | ||||
|   | ||||
| @@ -7,11 +7,15 @@ class GeneratedFormModal extends StatefulWidget { | ||||
|       {super.key, | ||||
|       required this.title, | ||||
|       required this.items, | ||||
|       required this.defaultValues}); | ||||
|       required this.defaultValues, | ||||
|       this.initValid = false, | ||||
|       this.message = ''}); | ||||
|  | ||||
|   final String title; | ||||
|   final String message; | ||||
|   final List<List<GeneratedFormItem>> items; | ||||
|   final List<String> defaultValues; | ||||
|   final bool initValid; | ||||
|  | ||||
|   @override | ||||
|   State<GeneratedFormModal> createState() => _GeneratedFormModalState(); | ||||
| @@ -21,12 +25,25 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|   List<String> values = []; | ||||
|   bool valid = false; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     valid = widget.initValid; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       scrollable: true, | ||||
|       title: Text(widget.title), | ||||
|       content: GeneratedForm( | ||||
|       content: | ||||
|           Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ | ||||
|         if (widget.message.isNotEmpty) Text(widget.message), | ||||
|         if (widget.message.isNotEmpty) | ||||
|           const SizedBox( | ||||
|             height: 16, | ||||
|           ), | ||||
|         GeneratedForm( | ||||
|             items: widget.items, | ||||
|             onValueChanges: (values, valid) { | ||||
|               setState(() { | ||||
| @@ -34,7 +51,8 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> { | ||||
|                 this.valid = valid; | ||||
|               }); | ||||
|             }, | ||||
|           defaultValues: widget.defaultValues), | ||||
|             defaultValues: widget.defaultValues) | ||||
|       ]), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|             onPressed: () { | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import 'package:dynamic_color/dynamic_color.dart'; | ||||
| import 'package:device_info_plus/device_info_plus.dart'; | ||||
|  | ||||
| const String currentReleaseTag = | ||||
|     'v0.3.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|     'v0.4.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES | ||||
|  | ||||
| @pragma('vm:entry-point') | ||||
| void bgTaskCallback() { | ||||
| @@ -99,7 +99,7 @@ class MyApp extends StatelessWidget { | ||||
|       if (settingsProvider.updateInterval > 0) { | ||||
|         Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', | ||||
|             frequency: Duration(minutes: settingsProvider.updateInterval), | ||||
|             // initialDelay: Duration(minutes: settingsProvider.updateInterval), | ||||
|             initialDelay: Duration(minutes: settingsProvider.updateInterval), | ||||
|             constraints: Constraints(networkType: NetworkType.connected), | ||||
|             existingWorkPolicy: ExistingWorkPolicy.replace); | ||||
|       } else { | ||||
| @@ -109,7 +109,8 @@ class MyApp extends StatelessWidget { | ||||
|       if (isFirstRun) { | ||||
|         // If this is the first run, ask for notification permissions and add Obtainium to the Apps list | ||||
|         Permission.notification.request(); | ||||
|         appsProvider.saveApp(App( | ||||
|         appsProvider.saveApps([ | ||||
|           App( | ||||
|               'imranr98_obtainium_${GitHub().host}', | ||||
|               'https://github.com/ImranR98/Obtainium', | ||||
|               'ImranR98', | ||||
| @@ -118,7 +119,8 @@ class MyApp extends StatelessWidget { | ||||
|               currentReleaseTag, | ||||
|               [], | ||||
|               0, | ||||
|             ["true"])); | ||||
|               ['true']) | ||||
|         ]); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -19,7 +19,7 @@ class AddAppPage extends StatefulWidget { | ||||
| class _AddAppPageState extends State<AddAppPage> { | ||||
|   bool gettingAppInfo = false; | ||||
|  | ||||
|   String userInput = ""; | ||||
|   String userInput = ''; | ||||
|   AppSource? pickedSource; | ||||
|   List<String> additionalData = []; | ||||
|   bool validAdditionalData = true; | ||||
| @@ -44,19 +44,19 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                   items: [ | ||||
|                                     [ | ||||
|                                       GeneratedFormItem( | ||||
|                                           label: "App Source Url", | ||||
|                                           label: 'App Source Url', | ||||
|                                           additionalValidators: [ | ||||
|                                             (value) { | ||||
|                                               try { | ||||
|                                                 sourceProvider | ||||
|                                                     .getSource(value ?? "") | ||||
|                                                     .getSource(value ?? '') | ||||
|                                                     .standardizeURL( | ||||
|                                                         makeUrlHttps( | ||||
|                                                             value ?? "")); | ||||
|                                                             value ?? '')); | ||||
|                                               } catch (e) { | ||||
|                                                 return e is String | ||||
|                                                     ? e | ||||
|                                                     : "Error"; | ||||
|                                                     : 'Error'; | ||||
|                                               } | ||||
|                                               return null; | ||||
|                                             } | ||||
| @@ -113,7 +113,8 @@ class _AddAppPageState extends State<AddAppPage> { | ||||
|                                         settingsProvider | ||||
|                                             .getInstallPermission() | ||||
|                                             .then((_) { | ||||
|                                           appsProvider.saveApp(app).then((_) { | ||||
|                                           appsProvider | ||||
|                                               .saveApps([app]).then((_) { | ||||
|                                             Navigator.push( | ||||
|                                                 context, | ||||
|                                                 MaterialPageRoute( | ||||
|   | ||||
| @@ -126,8 +126,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                                                                 .installedVersion = | ||||
|                                                             updatedApp | ||||
|                                                                 .latestVersion; | ||||
|                                                         appsProvider.saveApp( | ||||
|                                                             updatedApp); | ||||
|                                                         appsProvider.saveApps( | ||||
|                                                             [updatedApp]); | ||||
|                                                       } | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
| @@ -167,8 +167,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                                                         updatedApp | ||||
|                                                                 .installedVersion = | ||||
|                                                             null; | ||||
|                                                         appsProvider.saveApp( | ||||
|                                                             updatedApp); | ||||
|                                                         appsProvider.saveApps( | ||||
|                                                             [updatedApp]); | ||||
|                                                       } | ||||
|                                                       Navigator.of(context) | ||||
|                                                           .pop(); | ||||
| @@ -202,10 +202,11 @@ class _AppPageState extends State<AppPage> { | ||||
|                                         if (app != null && values != null) { | ||||
|                                           var changedApp = app.app; | ||||
|                                           changedApp.additionalData = values; | ||||
|                                           appsProvider.saveApp(changedApp); | ||||
|                                           appsProvider.saveApps([changedApp]); | ||||
|                                         } | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               tooltip: 'Additional Options', | ||||
|                               icon: const Icon(Icons.settings)), | ||||
|                         const SizedBox(width: 16.0), | ||||
|                         Expanded( | ||||
| @@ -247,9 +248,8 @@ class _AppPageState extends State<AppPage> { | ||||
|                                                 onPressed: () { | ||||
|                                                   HapticFeedback | ||||
|                                                       .selectionClick(); | ||||
|                                                   appsProvider | ||||
|                                                       .removeApp(app!.app.id) | ||||
|                                                       .then((_) { | ||||
|                                                   appsProvider.removeApps( | ||||
|                                                       [app!.app.id]).then((_) { | ||||
|                                                     int count = 0; | ||||
|                                                     Navigator.of(context) | ||||
|                                                         .popUntil((_) => | ||||
|   | ||||
| @@ -7,28 +7,67 @@ import 'package:obtainium/pages/app.dart'; | ||||
| import 'package:obtainium/providers/apps_provider.dart'; | ||||
| import 'package:obtainium/providers/settings_provider.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
|  | ||||
| class AppsPage extends StatefulWidget { | ||||
|   const AppsPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<AppsPage> createState() => _AppsPageState(); | ||||
|   State<AppsPage> createState() => AppsPageState(); | ||||
| } | ||||
|  | ||||
| class _AppsPageState extends State<AppsPage> { | ||||
| class AppsPageState extends State<AppsPage> { | ||||
|   AppsFilter? filter; | ||||
|   Set<String> selectedIds = {}; | ||||
|  | ||||
|   clearSelected() { | ||||
|     if (selectedIds.isNotEmpty) { | ||||
|       setState(() { | ||||
|         selectedIds.clear(); | ||||
|       }); | ||||
|       return true; | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   selectThese(List<String> appIds) { | ||||
|     if (selectedIds.isEmpty) { | ||||
|       setState(() { | ||||
|         for (var a in appIds) { | ||||
|           selectedIds.add(a); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     var appsProvider = context.watch<AppsProvider>(); | ||||
|     var settingsProvider = context.watch<SettingsProvider>(); | ||||
|     var existingUpdateAppIds = appsProvider.getExistingUpdates(); | ||||
|     var sortedApps = appsProvider.apps.values.toList(); | ||||
|  | ||||
|     selectedIds = selectedIds | ||||
|         .where((element) => sortedApps.map((e) => e.app.id).contains(element)) | ||||
|         .toSet(); | ||||
|  | ||||
|     toggleAppSelected(String appId) { | ||||
|       setState(() { | ||||
|         if (selectedIds.contains(appId)) { | ||||
|           selectedIds.remove(appId); | ||||
|         } else { | ||||
|           selectedIds.add(appId); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     if (filter != null) { | ||||
|       sortedApps = sortedApps.where((app) { | ||||
|         if (app.app.installedVersion == app.app.latestVersion && | ||||
|             filter!.onlyNonLatest) { | ||||
|             !(filter!.includeUptodate)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (app.app.installedVersion == null && | ||||
|             !(filter!.includeNonInstalled)) { | ||||
|           return false; | ||||
|         } | ||||
|         if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { | ||||
| @@ -75,76 +114,6 @@ class _AppsPageState extends State<AppsPage> { | ||||
|  | ||||
|     return Scaffold( | ||||
|       backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         floatingActionButton: | ||||
|             Row(mainAxisAlignment: MainAxisAlignment.end, children: [ | ||||
|           existingUpdateAppIds.isEmpty || filter != null | ||||
|               ? const SizedBox() | ||||
|               : ElevatedButton.icon( | ||||
|                   onPressed: appsProvider.areDownloadsRunning() | ||||
|                       ? null | ||||
|                       : () { | ||||
|                           HapticFeedback.heavyImpact(); | ||||
|                           settingsProvider.getInstallPermission().then((_) { | ||||
|                             appsProvider.downloadAndInstallLatestApp( | ||||
|                                 existingUpdateAppIds, context); | ||||
|                           }); | ||||
|                         }, | ||||
|                   icon: const Icon(Icons.install_mobile_outlined), | ||||
|                   label: const Text('Install All')), | ||||
|           const SizedBox( | ||||
|             width: 16, | ||||
|           ), | ||||
|           appsProvider.apps.isEmpty | ||||
|               ? const SizedBox() | ||||
|               : ElevatedButton.icon( | ||||
|                   onPressed: () { | ||||
|                     showDialog<List<String>?>( | ||||
|                         context: context, | ||||
|                         builder: (BuildContext ctx) { | ||||
|                           return GeneratedFormModal( | ||||
|                               title: 'Filter Apps', | ||||
|                               items: [ | ||||
|                                 [ | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "App Name", required: false), | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "Author", required: false) | ||||
|                                 ], | ||||
|                                 [ | ||||
|                                   GeneratedFormItem( | ||||
|                                       label: "Ignore Up-to-Date Apps", | ||||
|                                       type: FormItemType.bool) | ||||
|                                 ] | ||||
|                               ], | ||||
|                               defaultValues: filter == null | ||||
|                                   ? [] | ||||
|                                   : [ | ||||
|                                       filter!.nameFilter, | ||||
|                                       filter!.authorFilter, | ||||
|                                       filter!.onlyNonLatest ? 'true' : '' | ||||
|                                     ]); | ||||
|                         }).then((values) { | ||||
|                       if (values != null && | ||||
|                           values | ||||
|                               .where((element) => element.isNotEmpty) | ||||
|                               .isNotEmpty) { | ||||
|                         setState(() { | ||||
|                           filter = AppsFilter( | ||||
|                               nameFilter: values[0], | ||||
|                               authorFilter: values[1], | ||||
|                               onlyNonLatest: values[2] == "true"); | ||||
|                         }); | ||||
|                       } else { | ||||
|                         setState(() { | ||||
|                           filter = null; | ||||
|                         }); | ||||
|                       } | ||||
|                     }); | ||||
|                   }, | ||||
|                   label: Text(filter == null ? 'Search' : 'Modify Search'), | ||||
|                   icon: Icon( | ||||
|                       filter == null ? Icons.search : Icons.manage_search)), | ||||
|         ]), | ||||
|       body: RefreshIndicator( | ||||
|           onRefresh: () { | ||||
|             HapticFeedback.lightImpact(); | ||||
| @@ -165,13 +134,18 @@ class _AppsPageState extends State<AppsPage> { | ||||
|                               appsProvider.apps.isEmpty | ||||
|                                   ? 'No Apps' | ||||
|                                   : 'No Search Results', | ||||
|                                 style: | ||||
|                                     Theme.of(context).textTheme.headlineMedium, | ||||
|                               style: Theme.of(context).textTheme.headlineMedium, | ||||
|                             ))), | ||||
|             SliverList( | ||||
|                 delegate: SliverChildBuilderDelegate( | ||||
|                     (BuildContext context, int index) { | ||||
|               return ListTile( | ||||
|                 selectedTileColor: | ||||
|                     Theme.of(context).colorScheme.primary.withOpacity(0.1), | ||||
|                 selected: selectedIds.contains(sortedApps[index].app.id), | ||||
|                 onLongPress: () { | ||||
|                   toggleAppSelected(sortedApps[index].app.id); | ||||
|                 }, | ||||
|                 title: Text(sortedApps[index].app.name), | ||||
|                 subtitle: Text('By ${sortedApps[index].app.author}'), | ||||
|                 trailing: sortedApps[index].downloadProgress != null | ||||
| @@ -184,26 +158,262 @@ class _AppsPageState extends State<AppsPage> { | ||||
|                         : Text(sortedApps[index].app.installedVersion ?? | ||||
|                             'Not Installed')), | ||||
|                 onTap: () { | ||||
|                   if (selectedIds.isNotEmpty) { | ||||
|                     toggleAppSelected(sortedApps[index].app.id); | ||||
|                   } else { | ||||
|                     Navigator.push( | ||||
|                       context, | ||||
|                       MaterialPageRoute( | ||||
|                           builder: (context) => | ||||
|                               AppPage(appId: sortedApps[index].app.id)), | ||||
|                     ); | ||||
|                   } | ||||
|                 }, | ||||
|               ); | ||||
|             }, childCount: sortedApps.length)) | ||||
|             ]))); | ||||
|           ])), | ||||
|       persistentFooterButtons: [ | ||||
|         Row( | ||||
|           children: [ | ||||
|             TextButton.icon( | ||||
|                 onPressed: () { | ||||
|                   selectedIds.isEmpty | ||||
|                       ? selectThese(sortedApps.map((e) => e.app.id).toList()) | ||||
|                       : clearSelected(); | ||||
|                 }, | ||||
|                 icon: Icon(selectedIds.isEmpty | ||||
|                     ? Icons.select_all_outlined | ||||
|                     : Icons.deselect_outlined), | ||||
|                 label: Text(selectedIds.isEmpty | ||||
|                     ? 'Select All' | ||||
|                     : 'Deselect ${selectedIds.length.toString()}')), | ||||
|             const VerticalDivider(), | ||||
|             Expanded( | ||||
|                 child: selectedIds.isEmpty | ||||
|                     ? Container() | ||||
|                     : Row( | ||||
|                         mainAxisAlignment: MainAxisAlignment.spaceEvenly, | ||||
|                         children: [ | ||||
|                           IconButton( | ||||
|                             visualDensity: VisualDensity.compact, | ||||
|                             onPressed: () { | ||||
|                               showDialog<List<String>?>( | ||||
|                                   context: context, | ||||
|                                   builder: (BuildContext ctx) { | ||||
|                                     return GeneratedFormModal( | ||||
|                                       title: 'Remove Selected Apps?', | ||||
|                                       items: const [], | ||||
|                                       defaultValues: const [], | ||||
|                                       initValid: true, | ||||
|                                       message: | ||||
|                                           '${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.', | ||||
|                                     ); | ||||
|                                   }).then((values) { | ||||
|                                 if (values != null) { | ||||
|                                   appsProvider.removeApps(selectedIds.toList()); | ||||
|                                 } | ||||
|                               }); | ||||
|                             }, | ||||
|                             tooltip: 'Remove Selected Apps', | ||||
|                             icon: const Icon(Icons.delete_outline_outlined), | ||||
|                           ), | ||||
|                           IconButton( | ||||
|                               visualDensity: VisualDensity.compact, | ||||
|                               onPressed: appsProvider.areDownloadsRunning() || | ||||
|                                       selectedIds | ||||
|                                           .where((id) => | ||||
|                                               appsProvider.apps[id]!.app | ||||
|                                                   .installedVersion != | ||||
|                                               appsProvider | ||||
|                                                   .apps[id]!.app.latestVersion) | ||||
|                                           .isEmpty | ||||
|                                   ? null | ||||
|                                   : () { | ||||
|                                       HapticFeedback.heavyImpact(); | ||||
|                                       var existingUpdateIdsSelected = | ||||
|                                           appsProvider | ||||
|                                               .getExistingUpdates( | ||||
|                                                   installedOnly: true) | ||||
|                                               .where((element) => | ||||
|                                                   selectedIds.contains(element)) | ||||
|                                               .toList(); | ||||
|                                       var newInstallIdsSelected = appsProvider | ||||
|                                           .getExistingUpdates( | ||||
|                                               nonInstalledOnly: true) | ||||
|                                           .where((element) => | ||||
|                                               selectedIds.contains(element)) | ||||
|                                           .toList(); | ||||
|                                       List<List<GeneratedFormItem>> formInputs = | ||||
|                                           []; | ||||
|                                       if (existingUpdateIdsSelected | ||||
|                                               .isNotEmpty && | ||||
|                                           newInstallIdsSelected.isNotEmpty) { | ||||
|                                         formInputs.add([ | ||||
|                                           GeneratedFormItem( | ||||
|                                               label: | ||||
|                                                   'Update ${existingUpdateIdsSelected.length} Apps?', | ||||
|                                               type: FormItemType.bool) | ||||
|                                         ]); | ||||
|                                         formInputs.add([ | ||||
|                                           GeneratedFormItem( | ||||
|                                               label: | ||||
|                                                   'Install ${newInstallIdsSelected.length} new Apps?', | ||||
|                                               type: FormItemType.bool) | ||||
|                                         ]); | ||||
|                                       } | ||||
|                                       showDialog<List<String>?>( | ||||
|                                           context: context, | ||||
|                                           builder: (BuildContext ctx) { | ||||
|                                             return GeneratedFormModal( | ||||
|                                               title: 'Install Selected Apps?', | ||||
|                                               message: | ||||
|                                                   '${existingUpdateIdsSelected.length} update${existingUpdateIdsSelected.length == 1 ? '' : 's'} and ${newInstallIdsSelected.length} new install${newInstallIdsSelected.length == 1 ? '' : 's'}.', | ||||
|                                               items: formInputs, | ||||
|                                               defaultValues: const [ | ||||
|                                                 'true', | ||||
|                                                 'true' | ||||
|                                               ], | ||||
|                                               initValid: true, | ||||
|                                             ); | ||||
|                                           }).then((values) { | ||||
|                                         if (values != null) { | ||||
|                                           bool shouldInstallUpdates = | ||||
|                                               values.length < 2 || | ||||
|                                                   values[0] == 'true'; | ||||
|                                           bool shouldInstallNew = | ||||
|                                               values.length < 2 || | ||||
|                                                   values[1] == 'true'; | ||||
|                                           settingsProvider | ||||
|                                               .getInstallPermission() | ||||
|                                               .then((_) { | ||||
|                                             List<String> toInstall = []; | ||||
|                                             if (shouldInstallUpdates) { | ||||
|                                               toInstall.addAll( | ||||
|                                                   existingUpdateIdsSelected); | ||||
|                                             } | ||||
|                                             if (shouldInstallNew) { | ||||
|                                               toInstall.addAll( | ||||
|                                                   newInstallIdsSelected); | ||||
|                                             } | ||||
|                                             appsProvider | ||||
|                                                 .downloadAndInstallLatestApp( | ||||
|                                                     toInstall, context); | ||||
|                                           }); | ||||
|                                         } | ||||
|                                       }); | ||||
|                                     }, | ||||
|                               tooltip: 'Install/Update Selected Apps', | ||||
|                               icon: const Icon( | ||||
|                                 Icons.file_download_outlined, | ||||
|                               )), | ||||
|                           IconButton( | ||||
|                             visualDensity: VisualDensity.compact, | ||||
|                             onPressed: () { | ||||
|                               String urls = ''; | ||||
|                               for (var id in selectedIds) { | ||||
|                                 urls += '${appsProvider.apps[id]!.app.url}\n'; | ||||
|                               } | ||||
|                               urls = urls.substring(0, urls.length - 1); | ||||
|                               Share.share(urls, | ||||
|                                   subject: 'Selected App URLs from Obtainium'); | ||||
|                             }, | ||||
|                             tooltip: 'Share Selected App URLs', | ||||
|                             icon: const Icon(Icons.share), | ||||
|                           ), | ||||
|                         ], | ||||
|                       )), | ||||
|             const VerticalDivider(), | ||||
|             appsProvider.apps.isEmpty | ||||
|                 ? const SizedBox() | ||||
|                 : TextButton.icon( | ||||
|                     label: Text( | ||||
|                       filter == null ? 'Filter' : 'Filter *', | ||||
|                       style: TextStyle( | ||||
|                           fontWeight: filter == null | ||||
|                               ? FontWeight.normal | ||||
|                               : FontWeight.bold), | ||||
|                     ), | ||||
|                     onPressed: () { | ||||
|                       showDialog<List<String>?>( | ||||
|                           context: context, | ||||
|                           builder: (BuildContext ctx) { | ||||
|                             return GeneratedFormModal( | ||||
|                                 title: 'Filter Apps', | ||||
|                                 items: [ | ||||
|                                   [ | ||||
|                                     GeneratedFormItem( | ||||
|                                         label: 'App Name', required: false), | ||||
|                                     GeneratedFormItem( | ||||
|                                         label: 'Author', required: false) | ||||
|                                   ], | ||||
|                                   [ | ||||
|                                     GeneratedFormItem( | ||||
|                                         label: 'Up to Date Apps', | ||||
|                                         type: FormItemType.bool) | ||||
|                                   ], | ||||
|                                   [ | ||||
|                                     GeneratedFormItem( | ||||
|                                         label: 'Non-Installed Apps', | ||||
|                                         type: FormItemType.bool) | ||||
|                                   ] | ||||
|                                 ], | ||||
|                                 defaultValues: filter == null | ||||
|                                     ? AppsFilter().toValuesArray() | ||||
|                                     : filter!.toValuesArray()); | ||||
|                           }).then((values) { | ||||
|                         if (values != null) { | ||||
|                           setState(() { | ||||
|                             filter = AppsFilter.fromValuesArray(values); | ||||
|                             if (AppsFilter().isIdenticalTo(filter!)) { | ||||
|                               filter = null; | ||||
|                             } | ||||
|                           }); | ||||
|                         } else { | ||||
|                           setState(() { | ||||
|                             filter = null; | ||||
|                           }); | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|                     icon: const Icon(Icons.filter_list_rounded)) | ||||
|           ], | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class AppsFilter { | ||||
|   late String nameFilter; | ||||
|   late String authorFilter; | ||||
|   late bool onlyNonLatest; | ||||
|   late bool includeUptodate; | ||||
|   late bool includeNonInstalled; | ||||
|  | ||||
|   AppsFilter( | ||||
|       {this.nameFilter = "", | ||||
|       this.authorFilter = "", | ||||
|       this.onlyNonLatest = false}); | ||||
|       {this.nameFilter = '', | ||||
|       this.authorFilter = '', | ||||
|       this.includeUptodate = true, | ||||
|       this.includeNonInstalled = true}); | ||||
|  | ||||
|   List<String> toValuesArray() { | ||||
|     return [ | ||||
|       nameFilter, | ||||
|       authorFilter, | ||||
|       includeUptodate ? 'true' : '', | ||||
|       includeNonInstalled ? 'true' : '' | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   AppsFilter.fromValuesArray(List<String> values) { | ||||
|     nameFilter = values[0]; | ||||
|     authorFilter = values[1]; | ||||
|     includeUptodate = values[2] == 'true'; | ||||
|     includeNonInstalled = values[3] == 'true'; | ||||
|   } | ||||
|  | ||||
|   bool isIdenticalTo(AppsFilter other) => | ||||
|       authorFilter.trim() == other.authorFilter.trim() && | ||||
|       nameFilter.trim() == other.nameFilter.trim() && | ||||
|       includeUptodate == other.includeUptodate && | ||||
|       includeNonInstalled == other.includeNonInstalled; | ||||
| } | ||||
|   | ||||
| @@ -25,7 +25,8 @@ class _HomePageState extends State<HomePage> { | ||||
|   List<int> selectedIndexHistory = []; | ||||
|  | ||||
|   List<NavigationPageItem> pages = [ | ||||
|     NavigationPageItem('Apps', Icons.apps, const AppsPage()), | ||||
|     NavigationPageItem( | ||||
|         'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())), | ||||
|     NavigationPageItem('Add App', Icons.add, const AddAppPage()), | ||||
|     NavigationPageItem( | ||||
|         'Import/Export', Icons.import_export, const ImportExportPage()), | ||||
| @@ -88,7 +89,10 @@ class _HomePageState extends State<HomePage> { | ||||
|             }); | ||||
|             return false; | ||||
|           } | ||||
|           return true; | ||||
|           return !(pages[0].widget.key as GlobalKey<AppsPageState>) | ||||
|               .currentState | ||||
|               ?.clearSelected(); | ||||
|           // return !appsPageKey.currentState?.clearSelected(); | ||||
|         }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -47,7 +47,7 @@ class _ImportExportPageState extends State<ImportExportPage> { | ||||
|         if (appsProvider.apps.containsKey(app.id)) { | ||||
|           errorsMap.addAll({app.id: 'App already added'}); | ||||
|         } else { | ||||
|           await appsProvider.saveApp(app); | ||||
|           await appsProvider.saveApps([app]); | ||||
|         } | ||||
|       } | ||||
|       List<List<String>> errors = | ||||
|   | ||||
| @@ -1,53 +0,0 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:obtainium/components/generated_form.dart'; | ||||
|  | ||||
| class TestPage extends StatefulWidget { | ||||
|   const TestPage({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<TestPage> createState() => _TestPageState(); | ||||
| } | ||||
|  | ||||
| class _TestPageState extends State<TestPage> { | ||||
|   List<String?>? sourceSpecificData; | ||||
|   bool valid = false; | ||||
|  | ||||
|   List<List<GeneratedFormItem>> sourceSpecificInputs = [ | ||||
|     [GeneratedFormItem(label: 'Test Item 1')], | ||||
|     [ | ||||
|       GeneratedFormItem(label: 'Test Item 2', required: false), | ||||
|       GeneratedFormItem(label: 'Test Item 3') | ||||
|     ], | ||||
|     [GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)] | ||||
|   ]; | ||||
|  | ||||
|   List<String> defaultInputValues = ["ABC"]; | ||||
|  | ||||
|   void onSourceSpecificDataChanges( | ||||
|       List<String?> valuesFromForm, bool formValid) { | ||||
|     setState(() { | ||||
|       sourceSpecificData = valuesFromForm; | ||||
|       valid = formValid; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Scaffold( | ||||
|         appBar: AppBar(title: const Text('Test Page')), | ||||
|         backgroundColor: Theme.of(context).colorScheme.surface, | ||||
|         body: Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16), | ||||
|             child: Column(children: [ | ||||
|               GeneratedForm( | ||||
|                 items: sourceSpecificInputs, | ||||
|                 onValueChanges: onSourceSpecificDataChanges, | ||||
|                 defaultValues: defaultInputValues, | ||||
|               ), | ||||
|               ...(sourceSpecificData != null | ||||
|                   ? (sourceSpecificData as List<String?>) | ||||
|                       .map((e) => Text(e ?? "")) | ||||
|                   : [Container()]) | ||||
|             ]))); | ||||
|   } | ||||
| } | ||||
| @@ -98,24 +98,24 @@ class AppsProvider with ChangeNotifier { | ||||
|       .isNotEmpty; | ||||
|  | ||||
|   Future<bool> canInstallSilently(App app) async { | ||||
|     // TODO: This is unreliable - try to get from OS | ||||
|     // TODO: This is unreliable - try to get from OS in the future | ||||
|     var osInfo = await DeviceInfoPlugin().androidInfo; | ||||
|     return app.installedVersion != null && | ||||
|         osInfo.version.sdkInt! >= 30 && | ||||
|         osInfo.version.release!.compareTo('12') >= 0; | ||||
|   } | ||||
|  | ||||
|   Future<void> askUserToReturnToForeground(BuildContext context) async { | ||||
|   Future<void> askUserToReturnToForeground(BuildContext context, | ||||
|       {bool waitForFG = false}) async { | ||||
|     NotificationsProvider notificationsProvider = | ||||
|         context.read<NotificationsProvider>(); | ||||
|     if (!isForeground) { | ||||
|       await notificationsProvider.notify(completeInstallationNotification, | ||||
|           cancelExisting: true); | ||||
|       if (waitForFG) { | ||||
|         await FGBGEvents.stream.first == FGBGType.foreground; | ||||
|         await notificationsProvider.cancel(completeInstallationNotification.id); | ||||
|       // We need to wait for the App to come to the foreground to install it | ||||
|       // Can't try to call install plugin in a background isolate (may not have worked anyways) because of: | ||||
|       // https://github.com/flutter/flutter/issues/13937 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -127,7 +127,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     await AppInstaller.installApk(file.file.path, actionRequired: false); | ||||
|     apps[file.appId]!.app.installedVersion = | ||||
|         apps[file.appId]!.app.latestVersion; | ||||
|     await saveApp(apps[file.appId]!.app); | ||||
|     await saveApps([apps[file.appId]!.app]); | ||||
|   } | ||||
|  | ||||
|   // Given a list of AppIds, uses stored info about the apps to download APKs and install them | ||||
| @@ -146,8 +146,6 @@ class AppsProvider with ChangeNotifier { | ||||
|       // If the App has more than one APK, the user should pick one (if context provided) | ||||
|       String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; | ||||
|       if (apps[id]!.app.apkUrls.length > 1 && context != null) { | ||||
|         // ignore: use_build_context_synchronously | ||||
|         await askUserToReturnToForeground(context); | ||||
|         apkUrl = await showDialog( | ||||
|             context: context, | ||||
|             builder: (BuildContext ctx) { | ||||
| @@ -158,8 +156,6 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (apkUrl != null && | ||||
|           Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin && | ||||
|           context != null) { | ||||
|         // ignore: use_build_context_synchronously | ||||
|         await askUserToReturnToForeground(context); | ||||
|         if (await showDialog( | ||||
|                 context: context, | ||||
|                 builder: (BuildContext ctx) { | ||||
| @@ -174,7 +170,7 @@ class AppsProvider with ChangeNotifier { | ||||
|         int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); | ||||
|         if (urlInd != apps[id]!.app.preferredApkIndex) { | ||||
|           apps[id]!.app.preferredApkIndex = urlInd; | ||||
|           await saveApp(apps[id]!.app); | ||||
|           await saveApps([apps[id]!.app]); | ||||
|         } | ||||
|         if (context != null || | ||||
|             (await canInstallSilently(apps[id]!.app) && | ||||
| @@ -203,9 +199,11 @@ class AppsProvider with ChangeNotifier { | ||||
|     } | ||||
|  | ||||
|     if (context != null) { | ||||
|       for (var i in regularInstalls) { | ||||
|       if (regularInstalls.isNotEmpty) { | ||||
|         // ignore: use_build_context_synchronously | ||||
|         await askUserToReturnToForeground(context); | ||||
|       } | ||||
|       for (var i in regularInstalls) { | ||||
|         await installApk(i); | ||||
|       } | ||||
|     } | ||||
| @@ -248,15 +246,19 @@ class AppsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> saveApp(App app) async { | ||||
|   Future<void> saveApps(List<App> apps) async { | ||||
|     for (var app in apps) { | ||||
|       File('${(await getAppsDir()).path}/${app.id}.json') | ||||
|           .writeAsStringSync(jsonEncode(app.toJson())); | ||||
|     apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), | ||||
|       this.apps.update( | ||||
|           app.id, (value) => AppInMemory(app, value.downloadProgress), | ||||
|           ifAbsent: () => AppInMemory(app, null)); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   Future<void> removeApp(String appId) async { | ||||
|   Future<void> removeApps(List<String> appIds) async { | ||||
|     for (var appId in appIds) { | ||||
|       File file = File('${(await getAppsDir()).path}/$appId.json'); | ||||
|       if (file.existsSync()) { | ||||
|         file.deleteSync(); | ||||
| @@ -264,8 +266,11 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (apps.containsKey(appId)) { | ||||
|         apps.remove(appId); | ||||
|       } | ||||
|     } | ||||
|     if (appIds.isNotEmpty) { | ||||
|       notifyListeners(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool checkAppObjectForUpdate(App app) { | ||||
|     if (!apps.containsKey(app.id)) { | ||||
| @@ -286,7 +291,7 @@ class AppsProvider with ChangeNotifier { | ||||
|       if (currentApp.preferredApkIndex < newApp.apkUrls.length) { | ||||
|         newApp.preferredApkIndex = currentApp.preferredApkIndex; | ||||
|       } | ||||
|       await saveApp(newApp); | ||||
|       await saveApps([newApp]); | ||||
|       return newApp; | ||||
|     } | ||||
|     return null; | ||||
| @@ -309,16 +314,22 @@ class AppsProvider with ChangeNotifier { | ||||
|     return updates; | ||||
|   } | ||||
|  | ||||
|   List<String> getExistingUpdates({bool installedOnly = false}) { | ||||
|   List<String> getExistingUpdates( | ||||
|       {bool installedOnly = false, bool nonInstalledOnly = false}) { | ||||
|     List<String> updateAppIds = []; | ||||
|     List<String> appIds = apps.keys.toList(); | ||||
|     for (int i = 0; i < appIds.length; i++) { | ||||
|       App? app = apps[appIds[i]]!.app; | ||||
|       if (app.installedVersion != app.latestVersion && | ||||
|           (app.installedVersion != null || !installedOnly)) { | ||||
|           (!installedOnly || !nonInstalledOnly)) { | ||||
|         if ((app.installedVersion == null && | ||||
|                 (nonInstalledOnly || !installedOnly) || | ||||
|             (app.installedVersion != null && | ||||
|                 (installedOnly || !nonInstalledOnly)))) { | ||||
|           updateAppIds.add(app.id); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return updateAppIds; | ||||
|   } | ||||
|  | ||||
| @@ -344,7 +355,7 @@ class AppsProvider with ChangeNotifier { | ||||
|     for (App a in importedApps) { | ||||
|       a.installedVersion = | ||||
|           apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; | ||||
|       await saveApp(a); | ||||
|       await saveApps([a]); | ||||
|     } | ||||
|     notifyListeners(); | ||||
|     return importedApps.length; | ||||
|   | ||||
| @@ -85,7 +85,7 @@ class App { | ||||
|  | ||||
| escapeRegEx(String s) { | ||||
|   return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { | ||||
|     return "\\${x[0]}"; | ||||
|     return '\\${x[0]}'; | ||||
|   }); | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										49
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -324,6 +324,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.8.0" | ||||
|   mime: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: mime | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "1.0.2" | ||||
|   nested: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -457,6 +464,48 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "6.0.3" | ||||
|   share_plus: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: share_plus | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.4.0" | ||||
|   share_plus_linux: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_linux | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.0" | ||||
|   share_plus_macos: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_macos | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   share_plus_platform_interface: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_platform_interface | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.3" | ||||
|   share_plus_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_web | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   share_plus_windows: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: share_plus_windows | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "3.0.1" | ||||
|   shared_preferences: | ||||
|     dependency: "direct main" | ||||
|     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.3.2+18 # When changing this, update the tag in main() accordingly | ||||
| version: 0.4.0+19 # When changing this, update the tag in main() accordingly | ||||
|  | ||||
| environment: | ||||
|   sdk: '>=2.19.0-79.0.dev <3.0.0' | ||||
| @@ -53,6 +53,7 @@ dependencies: | ||||
|   file_picker: ^5.1.0 | ||||
|   animations: ^2.0.4 | ||||
|   flutter_install_app: ^1.3.0 | ||||
|   share_plus: ^4.4.0 | ||||
|  | ||||
|  | ||||
| dev_dependencies: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user