Added search bar on Add App page

This commit is contained in:
Imran Remtulla
2022-11-25 20:31:52 -05:00
parent 377e0e07bd
commit 47324fcb49
3 changed files with 254 additions and 168 deletions

View File

@@ -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<AddAppPage> {
bool gettingAppInfo = false;
String userInput = '';
String searchQuery = '';
AppSource? pickedSource;
List<String> sourceSpecificAdditionalData = [];
bool sourceSpecificDataIsValid = true;
@@ -31,6 +33,107 @@ class _AddAppPageState extends State<AddAppPage> {
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
AppsProvider appsProvider = context.read<AppsProvider>();
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<SettingsProvider>();
() 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: <Widget>[
@@ -70,34 +173,8 @@ class _AddAppPageState extends State<AddAppPage> {
]
],
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<AddAppPage> {
.isNotEmpty &&
!otherAdditionalDataIsValid)
? null
: () async {
setState(() {
gettingAppInfo = true;
});
var appsProvider =
context.read<AppsProvider>();
var settingsProvider =
context.read<SettingsProvider>();
() 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<String, String> 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<String>? selectedUrls = res
.isEmpty
? []
: await showDialog<List<String>?>(
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<AddAppPage> {
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<AddAppPage> {
)))
.toList()
])),
const SizedBox(
height: 8,
),
])),
)
]));

View File

@@ -38,23 +38,6 @@ class _ImportExportPageState extends State<ImportExportPage> {
),
);
Future<List<List<String>>> addApps(List<String> urls) async {
List<dynamic> results = await sourceProvider.getAppsByURLNaive(urls,
ignoreUrls: appsProvider.apps.values.map((e) => e.app.url).toList());
List<App> apps = results[0];
Map<String, dynamic> 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<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: CustomScrollView(slivers: <Widget>[
@@ -194,7 +177,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
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<ImportExportPage> {
return UrlSelectionModal(
urlsWithDescriptions:
urlsWithDescriptions,
defaultSelected:
selectedByDefault:
false,
);
});
@@ -281,8 +266,9 @@ class _ImportExportPageState extends State<ImportExportPage> {
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<ImportExportPage> {
});
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<String, String> urlsWithDescriptions;
bool defaultSelected;
bool selectedByDefault;
bool onlyOneSelectionAllowed;
@override
State<UrlSelectionModal> createState() => _UrlSelectionModalState();
@@ -498,8 +487,17 @@ class _UrlSelectionModalState extends State<UrlSelectionModal> {
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<UrlSelectionModal> {
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<UrlSelectionModal> {
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<UrlSelectionModal> {
},
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'))
],
);
}

View File

@@ -635,6 +635,23 @@ class AppsProvider with ChangeNotifier {
foregroundSubscription?.cancel();
super.dispose();
}
Future<List<List<String>>> addAppsByURL(List<String> urls) async {
List<dynamic> results = await SourceProvider().getAppsByURLNaive(urls,
ignoreUrls: apps.values.map((e) => e.app.url).toList());
List<App> pps = results[0];
Map<String, dynamic> 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<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
}
class APKPicker extends StatefulWidget {