diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index cd14beb..1b7bce2 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -5,6 +5,7 @@ import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/pages/app.dart'; +import 'package:obtainium/pages/import_export.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/source_provider.dart'; @@ -22,6 +23,7 @@ class _AddAppPageState extends State { bool gettingAppInfo = false; String userInput = ''; + String searchQuery = ''; AppSource? pickedSource; List sourceSpecificAdditionalData = []; bool sourceSpecificDataIsValid = true; @@ -31,6 +33,107 @@ class _AddAppPageState extends State { @override Widget build(BuildContext context) { SourceProvider sourceProvider = SourceProvider(); + AppsProvider appsProvider = context.read(); + + changeUserInput(String input, bool valid, bool isBuilding) { + userInput = input; + fn() { + var source = valid ? sourceProvider.getSource(userInput) : null; + if (pickedSource != source) { + pickedSource = source; + sourceSpecificAdditionalData = + source != null ? source.additionalSourceAppSpecificDefaults : []; + sourceSpecificDataIsValid = source != null + ? sourceProvider.ifSourceAppsRequireAdditionalData(source) + : true; + } + } + + if (isBuilding) { + fn(); + } else { + setState(() { + fn(); + }); + } + } + + addApp({bool resetUserInputAfter = false}) async { + setState(() { + gettingAppInfo = true; + }); + var settingsProvider = context.read(); + () async { + var userPickedTrackOnly = findGeneratedFormValueByKey( + pickedSource!.additionalAppSpecificSourceAgnosticFormItems, + otherAdditionalData, + 'trackOnlyFormItemKey') == + 'true'; + var cont = true; + if ((userPickedTrackOnly || pickedSource!.enforceTrackOnly) && + await showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: + '${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only', + 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.', + ); + }) == + null) { + cont = false; + } + if (cont) { + HapticFeedback.selectionClick(); + var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly; + App app = await sourceProvider.getApp( + pickedSource!, userInput, sourceSpecificAdditionalData, + trackOnly: trackOnly); + if (!trackOnly) { + await settingsProvider.getInstallPermission(); + } + // Only download the APK here if you need to for the package ID + if (sourceProvider.isTempId(app.id) && !app.trackOnly) { + // ignore: use_build_context_synchronously + var apkUrl = await appsProvider.confirmApkUrl(app, context); + if (apkUrl == null) { + throw ObtainiumError('Cancelled'); + } + app.preferredApkIndex = app.apkUrls.indexOf(apkUrl); + var downloadedApk = await appsProvider.downloadApp(app); + app.id = downloadedApk.appId; + } + if (appsProvider.apps.containsKey(app.id)) { + throw ObtainiumError('App already added'); + } + if (app.trackOnly) { + app.installedVersion = app.latestVersion; + } + await appsProvider.saveApps([app]); + + return app; + } + }() + .then((app) { + if (app != null) { + Navigator.push(context, + MaterialPageRoute(builder: (context) => AppPage(appId: app.id))); + } + }).catchError((e) { + showError(e, context); + }).whenComplete(() { + setState(() { + gettingAppInfo = false; + if (resetUserInputAfter) { + changeUserInput('', false, true); + } + }); + }); + } + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -70,34 +173,8 @@ class _AddAppPageState extends State { ] ], onValueChanges: (values, valid, isBuilding) { - fn() { - userInput = values[0]; - var source = valid - ? sourceProvider.getSource(userInput) - : null; - if (pickedSource != source) { - pickedSource = source; - sourceSpecificAdditionalData = source != - null - ? source - .additionalSourceAppSpecificDefaults - : []; - sourceSpecificDataIsValid = source != - null - ? sourceProvider - .ifSourceAppsRequireAdditionalData( - source) - : true; - } - } - - if (isBuilding) { - fn(); - } else { - setState(() { - fn(); - }); - } + changeUserInput( + values[0], valid, isBuilding); }, defaultValues: const [])), const SizedBox( @@ -117,111 +194,91 @@ class _AddAppPageState extends State { .isNotEmpty && !otherAdditionalDataIsValid) ? null - : () async { - setState(() { - gettingAppInfo = true; - }); - var appsProvider = - context.read(); - var settingsProvider = - context.read(); - () async { - var userPickedTrackOnly = - findGeneratedFormValueByKey( - pickedSource! - .additionalAppSpecificSourceAgnosticFormItems, - otherAdditionalData, - 'trackOnlyFormItemKey') == - 'true'; - var cont = true; - if ((userPickedTrackOnly || - pickedSource! - .enforceTrackOnly) && - await showDialog( - context: context, - builder: - (BuildContext ctx) { - return GeneratedFormModal( - title: - '${pickedSource!.enforceTrackOnly ? 'Source' : 'App'} is Track-Only', - 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.', - ); - }) == - null) { - cont = false; - } - if (cont) { - HapticFeedback.selectionClick(); - var trackOnly = pickedSource! - .enforceTrackOnly || - userPickedTrackOnly; - App app = - await sourceProvider.getApp( - pickedSource!, - userInput, - sourceSpecificAdditionalData, - trackOnly: trackOnly); - if (!trackOnly) { - await settingsProvider - .getInstallPermission(); - } - // Only download the APK here if you need to for the package ID - if (sourceProvider - .isTempId(app.id) && - !app.trackOnly) { - // ignore: use_build_context_synchronously - var apkUrl = await appsProvider - .confirmApkUrl( - app, context); - if (apkUrl == null) { - throw ObtainiumError( - 'Cancelled'); - } - app.preferredApkIndex = - app.apkUrls.indexOf(apkUrl); - var downloadedApk = - await appsProvider - .downloadApp(app); - app.id = downloadedApk.appId; - } - if (appsProvider.apps - .containsKey(app.id)) { - throw ObtainiumError( - 'App already added'); - } - if (app.trackOnly) { - app.installedVersion = - app.latestVersion; - } - await appsProvider - .saveApps([app]); - - return app; - } - }() - .then((app) { - if (app != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - AppPage( - appId: app.id))); - } - }).catchError((e) { - showError(e, context); - }).whenComplete(() { - setState(() { - gettingAppInfo = false; - }); - }); - }, + : addApp, child: const Text('Add')) ], ), + const SizedBox( + height: 16, + ), + if (sourceProvider.sources + .where((e) => e.canSearch) + .isNotEmpty && + pickedSource == null) + Row( + children: [ + Expanded( + child: GeneratedForm( + items: [ + [ + GeneratedFormItem( + label: 'Search (Some Sources Only)', + required: false), + ] + ], + onValueChanges: (values, valid, isBuilding) { + if (values.isNotEmpty && valid) { + setState(() { + searchQuery = values[0].trim(); + }); + } + }, + defaultValues: const ['']), + ), + const SizedBox( + width: 16, + ), + ElevatedButton( + onPressed: searchQuery.isEmpty || gettingAppInfo + ? null + : () { + Future.wait(sourceProvider.sources + .where((e) => e.canSearch) + .map((e) => + e.search(searchQuery))) + .then((results) async { + var res = // TODO: Interleave results + results.reduce((value, element) { + value.addAll(element); + return value; + }); + // Map res = {}; + // var si = 0; + // var done = false; + // for (var r in results) { + // if (r.length > si) { + // res.addEntries(r.entries.toList()[si]); + // } + // } + // for (var rs in results) { + // for (var r in rs.entries) {} + // } + List? selectedUrls = res + .isEmpty + ? [] + : await showDialog?>( + context: context, + builder: (BuildContext ctx) { + return UrlSelectionModal( + urlsWithDescriptions: res, + selectedByDefault: false, + onlyOneSelectionAllowed: + true, + ); + }); + if (selectedUrls != null && + selectedUrls.isNotEmpty) { + changeUserInput( + selectedUrls[0], true, true); + addApp(resetUserInputAfter: true); + } + }).catchError((e) { + showError(e, context); + }); + }, + child: const Text('Search')) + ], + ), if (pickedSource != null && (pickedSource!.additionalSourceAppSpecificDefaults .isNotEmpty || @@ -314,7 +371,7 @@ class _AddAppPageState extends State { LaunchMode.externalApplication); }, child: Text( - '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}', + '${e.runtimeType.toString()}${e.enforceTrackOnly ? ' (Track-Only)' : ''}${e.canSearch ? ' (Searchable)' : ''}', style: const TextStyle( decoration: TextDecoration.underline, @@ -322,6 +379,9 @@ class _AddAppPageState extends State { ))) .toList() ])), + const SizedBox( + height: 8, + ), ])), ) ])); diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index 5d74113..5aa554f 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -38,23 +38,6 @@ class _ImportExportPageState extends State { ), ); - Future>> addApps(List urls) async { - List results = await sourceProvider.getAppsByURLNaive(urls, - ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList()); - List apps = results[0]; - Map errorsMap = results[1]; - for (var app in apps) { - if (appsProvider.apps.containsKey(app.id)) { - errorsMap.addAll({app.id: 'App already added'}); - } else { - await appsProvider.saveApps([app]); - } - } - List> errors = - errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); - return errors; - } - return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: CustomScrollView(slivers: [ @@ -194,7 +177,9 @@ class _ImportExportPageState extends State { setState(() { importInProgress = true; }); - addApps(urls).then((errors) { + appsProvider + .addAppsByURL(urls) + .then((errors) { if (errors.isEmpty) { showError( 'Imported ${urls.length} Apps', @@ -272,7 +257,7 @@ class _ImportExportPageState extends State { return UrlSelectionModal( urlsWithDescriptions: urlsWithDescriptions, - defaultSelected: + selectedByDefault: false, ); }); @@ -281,8 +266,9 @@ class _ImportExportPageState extends State { selectedUrls .isNotEmpty) { var errors = - await addApps( - selectedUrls); + await appsProvider + .addAppsByURL( + selectedUrls); if (errors.isEmpty) { // ignore: use_build_context_synchronously showError( @@ -371,8 +357,9 @@ class _ImportExportPageState extends State { }); if (selectedUrls != null) { var errors = - await addApps( - selectedUrls); + await appsProvider + .addAppsByURL( + selectedUrls); if (errors.isEmpty) { // ignore: use_build_context_synchronously showError( @@ -483,10 +470,12 @@ class UrlSelectionModal extends StatefulWidget { UrlSelectionModal( {super.key, required this.urlsWithDescriptions, - this.defaultSelected = true}); + this.selectedByDefault = true, + this.onlyOneSelectionAllowed = false}); Map urlsWithDescriptions; - bool defaultSelected; + bool selectedByDefault; + bool onlyOneSelectionAllowed; @override State createState() => _UrlSelectionModalState(); @@ -498,8 +487,17 @@ class _UrlSelectionModalState extends State { void initState() { super.initState(); for (var url in widget.urlsWithDescriptions.entries) { - urlWithDescriptionSelections.putIfAbsent( - url, () => widget.defaultSelected); + urlWithDescriptionSelections.putIfAbsent(url, + () => widget.selectedByDefault && !widget.onlyOneSelectionAllowed); + } + if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) { + selectOnlyOne(widget.urlsWithDescriptions.entries.first.key); + } + } + + selectOnlyOne(String url) { + for (var uwd in urlWithDescriptionSelections.keys) { + urlWithDescriptionSelections[uwd] = uwd.key == url; } } @@ -507,7 +505,8 @@ class _UrlSelectionModalState extends State { Widget build(BuildContext context) { return AlertDialog( scrollable: true, - title: const Text('Select URLs to Import'), + title: + Text(widget.onlyOneSelectionAllowed ? 'Select URL' : 'Select URLs'), content: Column(children: [ ...urlWithDescriptionSelections.keys.map((urlWithD) { return Row(children: [ @@ -515,7 +514,12 @@ class _UrlSelectionModalState extends State { value: urlWithDescriptionSelections[urlWithD], onChanged: (value) { setState(() { - urlWithDescriptionSelections[urlWithD] = value ?? false; + value ??= false; + if (value! && widget.onlyOneSelectionAllowed) { + selectOnlyOne(urlWithD.key); + } else { + urlWithDescriptionSelections[urlWithD] = value!; + } }); }), const SizedBox( @@ -562,14 +566,19 @@ class _UrlSelectionModalState extends State { }, child: const Text('Cancel')), TextButton( - onPressed: () { - Navigator.of(context).pop(urlWithDescriptionSelections.entries - .where((entry) => entry.value) - .map((e) => e.key.key) - .toList()); - }, - child: Text( - 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) + onPressed: + urlWithDescriptionSelections.values.where((b) => b).isEmpty + ? null + : () { + Navigator.of(context).pop(urlWithDescriptionSelections + .entries + .where((entry) => entry.value) + .map((e) => e.key.key) + .toList()); + }, + child: Text(widget.onlyOneSelectionAllowed + ? 'Pick' + : 'Import ${urlWithDescriptionSelections.values.where((b) => b).length} URLs')) ], ); } diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 578d828..cc72844 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -635,6 +635,23 @@ class AppsProvider with ChangeNotifier { foregroundSubscription?.cancel(); super.dispose(); } + + Future>> addAppsByURL(List urls) async { + List results = await SourceProvider().getAppsByURLNaive(urls, + ignoreUrls: apps.values.map((e) => e.app.url).toList()); + List pps = results[0]; + Map errorsMap = results[1]; + for (var app in pps) { + if (apps.containsKey(app.id)) { + errorsMap.addAll({app.id: 'App already added'}); + } else { + await saveApps([app]); + } + } + List> errors = + errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); + return errors; + } } class APKPicker extends StatefulWidget {