From d03486fc5d5f8c2b24c73dcfd0d277aea72b423a Mon Sep 17 00:00:00 2001 From: Imran Remtulla <30463115+ImranR98@users.noreply.github.com> Date: Sat, 24 Sep 2022 00:36:32 -0400 Subject: [PATCH] Adds Source-specific options + other changes (#26) * Started work on dynamic forms * dynamic form progress (switch doesn't work) * dynamic forms work * Gen. form improvements, source specific data (untested) * Gen form bugfix * Removed redundant generated modal code * Added custom validators to gen. forms * Progress on source options (incomplete), gen form bugfixes * Tweaks, more * More * Progress * Changed a default * Additional options done! --- lib/app_sources/fdroid.dart | 12 +- lib/app_sources/github.dart | 102 +++++++--- lib/app_sources/gitlab.dart | 12 +- lib/app_sources/izzyondroid.dart | 12 +- lib/app_sources/mullvad.dart | 12 +- lib/app_sources/signal.dart | 10 +- lib/components/generated_form.dart | 175 ++++++++++++++++ lib/components/generated_form_modal.dart | 75 +++---- lib/main.dart | 5 +- lib/pages/add_app.dart | 247 +++++++++++++---------- lib/pages/app.dart | 36 ++++ lib/pages/home.dart | 1 + lib/pages/import_export.dart | 49 ++++- lib/pages/test_page.dart | 53 +++++ lib/providers/apps_provider.dart | 6 +- lib/providers/source_provider.dart | 101 ++++++--- 16 files changed, 678 insertions(+), 230 deletions(-) create mode 100644 lib/components/generated_form.dart create mode 100644 lib/pages/test_page.dart diff --git a/lib/app_sources/fdroid.dart b/lib/app_sources/fdroid.dart index 629454b..920d67c 100644 --- a/lib/app_sources/fdroid.dart +++ b/lib/app_sources/fdroid.dart @@ -1,5 +1,6 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class FDroid implements AppSource { @@ -11,13 +12,14 @@ class FDroid implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL; + throw notValidURL(runtimeType.toString()); } return url.substring(0, match.end); } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { Response res = await get(Uri.parse(standardUrl)); if (res.statusCode == 200) { var latestReleaseDiv = @@ -46,4 +48,10 @@ class FDroid implements AppSource { AppNames getAppNames(String standardUrl) { return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last); } + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; } diff --git a/lib/app_sources/github.dart b/lib/app_sources/github.dart index f77f3f2..200a29d 100644 --- a/lib/app_sources/github.dart +++ b/lib/app_sources/github.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class GitHub implements AppSource { @@ -11,47 +12,68 @@ class GitHub implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL; + throw notValidURL(runtimeType.toString()); } return url.substring(0, match.end); } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { + var includePrereleases = + additionalData.isNotEmpty && additionalData[0] == "true"; + var fallbackToOlderReleases = + additionalData.length >= 2 && additionalData[1] == "true"; + var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty + ? additionalData[2] + : null; Response res = await get(Uri.parse( 'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases')); if (res.statusCode == 200) { var releases = jsonDecode(res.body) as List; - // Right now, the latest non-prerelease version is picked - // If none exists, the latest prerelease version is picked - // In the future, the user could be given a choice - var nonPrereleaseReleases = - releases.where((element) => element['prerelease'] != true).toList(); - var latestRelease = nonPrereleaseReleases.isNotEmpty - ? nonPrereleaseReleases[0] - : releases.isNotEmpty - ? releases[0] - : null; - if (latestRelease == null) { + + List getReleaseAPKUrls(dynamic release) => + (release['assets'] as List?) + ?.map((e) { + return e['browser_download_url'] != null + ? e['browser_download_url'] as String + : ''; + }) + .where((element) => element.toLowerCase().endsWith('.apk')) + .toList() ?? + []; + + dynamic targetRelease; + + for (int i = 0; i < releases.length; i++) { + if (!fallbackToOlderReleases && i > 0) break; + if (!includePrereleases && releases[i]['prerelease'] == true) { + continue; + } + if (regexFilter != null && + !RegExp(regexFilter) + .hasMatch((releases[i]['name'] as String).trim())) { + continue; + } + var apkUrls = getReleaseAPKUrls(releases[i]); + if (apkUrls.isEmpty) { + continue; + } + targetRelease = releases[i]; + targetRelease['apkUrls'] = apkUrls; + break; + } + if (targetRelease == null) { throw couldNotFindReleases; } - List? assets = latestRelease['assets']; - List? apkUrlList = assets - ?.map((e) { - return e['browser_download_url'] != null - ? e['browser_download_url'] as String - : ''; - }) - .where((element) => element.toLowerCase().endsWith('.apk')) - .toList(); - if (apkUrlList == null || apkUrlList.isEmpty) { + if ((targetRelease['apkUrls'] as List).isEmpty) { throw noAPKFound; } - String? version = latestRelease['tag_name']; + String? version = targetRelease['tag_name']; if (version == null) { throw couldNotFindLatestVersion; } - return APKDetails(version, apkUrlList); + return APKDetails(version, targetRelease['apkUrls']); } else { if (res.headers['x-ratelimit-remaining'] == '0') { throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes'; @@ -67,4 +89,34 @@ class GitHub implements AppSource { List names = temp.substring(temp.indexOf('/') + 1).split('/'); return AppNames(names[0], names[1]); } + + @override + List> additionalDataFormItems = [ + [GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)], + [ + GeneratedFormItem( + label: "Fallback to older releases", type: FormItemType.bool) + ], + [ + GeneratedFormItem( + label: "Filter Release Titles by Regular Expression", + type: FormItemType.string, + required: false, + additionalValidators: [ + (value) { + if (value == null || value.isEmpty) { + return null; + } + try { + RegExp(value); + } catch (e) { + return "Invalid regular expression"; + } + } + ]) + ] + ]; + + @override + List additionalDataDefaults = ["true", "true", ""]; } diff --git a/lib/app_sources/gitlab.dart b/lib/app_sources/gitlab.dart index 618a130..0e4e364 100644 --- a/lib/app_sources/gitlab.dart +++ b/lib/app_sources/gitlab.dart @@ -1,6 +1,7 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; import 'package:obtainium/app_sources/github.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class GitLab implements AppSource { @@ -12,13 +13,14 @@ class GitLab implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL; + throw notValidURL(runtimeType.toString()); } return url.substring(0, match.end); } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom')); if (res.statusCode == 200) { var standardUri = Uri.parse(standardUrl); @@ -60,4 +62,10 @@ class GitLab implements AppSource { // Same as GitHub return GitHub().getAppNames(standardUrl); } + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; } diff --git a/lib/app_sources/izzyondroid.dart b/lib/app_sources/izzyondroid.dart index 6a2f891..061a037 100644 --- a/lib/app_sources/izzyondroid.dart +++ b/lib/app_sources/izzyondroid.dart @@ -1,5 +1,6 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class IzzyOnDroid implements AppSource { @@ -11,13 +12,14 @@ class IzzyOnDroid implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL; + throw notValidURL(runtimeType.toString()); } return url.substring(0, match.end); } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { Response res = await get(Uri.parse(standardUrl)); if (res.statusCode == 200) { var parsedHtml = parse(res.body); @@ -54,4 +56,10 @@ class IzzyOnDroid implements AppSource { AppNames getAppNames(String standardUrl) { return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last); } + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; } diff --git a/lib/app_sources/mullvad.dart b/lib/app_sources/mullvad.dart index 0d93b5d..658f04f 100644 --- a/lib/app_sources/mullvad.dart +++ b/lib/app_sources/mullvad.dart @@ -1,5 +1,6 @@ import 'package:html/parser.dart'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class Mullvad implements AppSource { @@ -11,13 +12,14 @@ class Mullvad implements AppSource { RegExp standardUrlRegEx = RegExp('^https?://$host'); RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase()); if (match == null) { - throw notValidURL; + throw notValidURL(runtimeType.toString()); } return url.substring(0, match.end); } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { Response res = await get(Uri.parse('$standardUrl/en/download/android')); if (res.statusCode == 200) { var version = parse(res.body) @@ -40,4 +42,10 @@ class Mullvad implements AppSource { AppNames getAppNames(String standardUrl) { return AppNames('Mullvad-VPN', 'Mullvad-VPN'); } + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; } diff --git a/lib/app_sources/signal.dart b/lib/app_sources/signal.dart index d6ae8ee..886c147 100644 --- a/lib/app_sources/signal.dart +++ b/lib/app_sources/signal.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/providers/source_provider.dart'; class Signal implements AppSource { @@ -12,7 +13,8 @@ class Signal implements AppSource { } @override - Future getLatestAPKDetails(String standardUrl) async { + Future getLatestAPKDetails( + String standardUrl, List additionalData) async { Response res = await get(Uri.parse('https://updates.$host/android/latest.json')); if (res.statusCode == 200) { @@ -33,4 +35,10 @@ class Signal implements AppSource { @override AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal'); + + @override + List> additionalDataFormItems = []; + + @override + List additionalDataDefaults = []; } diff --git a/lib/components/generated_form.dart b/lib/components/generated_form.dart new file mode 100644 index 0000000..24577f7 --- /dev/null +++ b/lib/components/generated_form.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +enum FormItemType { string, bool } + +typedef OnValueChanges = void Function(List values, bool valid); + +class GeneratedFormItem { + late String label; + late FormItemType type; + late bool required; + late int max; + late List additionalValidators; + + GeneratedFormItem( + {this.label = "Input", + this.type = FormItemType.string, + this.required = true, + this.max = 1, + this.additionalValidators = const []}); +} + +class GeneratedForm extends StatefulWidget { + const GeneratedForm( + {super.key, + required this.items, + required this.onValueChanges, + required this.defaultValues}); + + final List> items; + final OnValueChanges onValueChanges; + final List defaultValues; + + @override + State createState() => _GeneratedFormState(); +} + +class _GeneratedFormState extends State { + final _formKey = GlobalKey(); + late List> values; + late List> formInputs; + List> rows = []; + + // If any value changes, call this to update the parent with value and validity + void someValueChanged() { + List returnValues = []; + var valid = true; + for (int r = 0; r < values.length; r++) { + for (int i = 0; i < values[r].length; i++) { + returnValues.add(values[r][i]); + if (formInputs[r][i] is TextFormField) { + valid = valid && + ((formInputs[r][i].key as GlobalKey) + .currentState + ?.isValid ?? + false); + } + } + } + widget.onValueChanges(returnValues, valid); + } + + @override + void initState() { + super.initState(); + + // Initialize form values as all empty + int j = 0; + values = widget.items + .map((row) => row.map((e) { + return j < widget.defaultValues.length + ? widget.defaultValues[j++] + : ""; + }).toList()) + .toList(); + + // Dynamically create form inputs + formInputs = widget.items.asMap().entries.map((row) { + return row.value.asMap().entries.map((e) { + if (e.value.type == FormItemType.string) { + final formFieldKey = GlobalKey(); + return TextFormField( + key: formFieldKey, + initialValue: values[row.key][e.key], + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (value) { + setState(() { + values[row.key][e.key] = value; + someValueChanged(); + }); + }, + decoration: InputDecoration( + 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) { + if (e.value.required && (value == null || value.trim().isEmpty)) { + return '${e.value.label} (required)'; + } + for (var validator in e.value.additionalValidators) { + String? result = validator(value); + if (result != null) { + return result; + } + } + return null; + }, + ); + } else { + return Container(); // Some input types added in build + } + }).toList(); + }).toList(); + } + + @override + Widget build(BuildContext context) { + for (var r = 0; r < formInputs.length; r++) { + for (var e = 0; e < formInputs[r].length; e++) { + if (widget.items[r][e].type == FormItemType.bool) { + formInputs[r][e] = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(widget.items[r][e].label), + Switch( + value: values[r][e] == "true", + onChanged: (value) { + setState(() { + values[r][e] = value ? "true" : ""; + someValueChanged(); + }); + }) + ], + ); + } + } + } + + rows.clear(); + formInputs.asMap().entries.forEach((rowInputs) { + if (rowInputs.key > 0) { + rows.add([ + SizedBox( + height: widget.items[rowInputs.key][0].type == FormItemType.bool && + widget.items[rowInputs.key - 1][0].type == + FormItemType.string + ? 25 + : 8, + ) + ]); + } + List rowItems = []; + rowInputs.value.asMap().entries.forEach((rowInput) { + if (rowInput.key > 0) { + rowItems.add(const SizedBox( + width: 20, + )); + } + rowItems.add(Expanded(child: rowInput.value)); + }); + rows.add(rowItems); + }); + + return Form( + key: _formKey, + child: Column( + children: [ + ...rows.map((row) => Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [...row.map((e) => e)], + )) + ], + )); + } +} diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart index e1644be..7a6ed44 100644 --- a/lib/components/generated_form_modal.dart +++ b/lib/components/generated_form_modal.dart @@ -1,61 +1,40 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - -class GeneratedFormItem { - late String message; - late bool required; - late int lines; - - GeneratedFormItem(this.message, this.required, this.lines); -} +import 'package:obtainium/components/generated_form.dart'; class GeneratedFormModal extends StatefulWidget { const GeneratedFormModal( - {super.key, required this.title, required this.items}); + {super.key, + required this.title, + required this.items, + required this.defaultValues}); final String title; - final List items; + final List> items; + final List defaultValues; @override State createState() => _GeneratedFormModalState(); } class _GeneratedFormModalState extends State { - final _formKey = GlobalKey(); - - final urlInputController = TextEditingController(); + List values = []; + bool valid = false; @override Widget build(BuildContext context) { - final formInputs = widget.items.map((e) { - final controller = TextEditingController(); - return [ - controller, - TextFormField( - decoration: InputDecoration(helperText: e.message), - controller: controller, - minLines: e.lines <= 1 ? null : e.lines, - maxLines: e.lines <= 1 ? 1 : e.lines, - validator: e.required - ? (value) { - if (value == null || value.isEmpty) { - return '${e.message} (required)'; - } - return null; - } - : null, - ) - ]; - }).toList(); return AlertDialog( scrollable: true, title: Text(widget.title), - content: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [...formInputs.map((e) => e[1] as Widget)], - )), + content: GeneratedForm( + items: widget.items, + onValueChanges: (values, valid) { + setState(() { + this.values = values; + this.valid = valid; + }); + }, + defaultValues: widget.defaultValues), actions: [ TextButton( onPressed: () { @@ -63,18 +42,16 @@ class _GeneratedFormModalState extends State { }, child: const Text('Cancel')), TextButton( - onPressed: () { - if (_formKey.currentState?.validate() == true) { - HapticFeedback.selectionClick(); - Navigator.of(context).pop(formInputs - .map((e) => (e[0] as TextEditingController).value.text) - .toList()); - } - }, + onPressed: !valid + ? null + : () { + if (valid) { + HapticFeedback.selectionClick(); + Navigator.of(context).pop(values); + } + }, child: const Text('Continue')) ], ); } } - -// TODO: Add support for larger textarea so this can be used for text/json imports \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 9a50042..cca12de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -59,7 +59,7 @@ void main() async { ChangeNotifierProvider( create: (context) => AppsProvider( shouldLoadApps: true, - shouldCheckUpdatesAfterLoad: true, + shouldCheckUpdatesAfterLoad: false, shouldDeleteAPKs: true)), ChangeNotifierProvider(create: (context) => SettingsProvider()), Provider(create: (context) => NotificationsProvider()) @@ -103,7 +103,8 @@ class MyApp extends StatelessWidget { currentReleaseTag, currentReleaseTag, [], - 0)); + 0, + ["true"])); } } diff --git a/lib/pages/add_app.dart b/lib/pages/add_app.dart index 912ec03..687fb77 100644 --- a/lib/pages/add_app.dart +++ b/lib/pages/add_app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; @@ -16,10 +17,13 @@ class AddAppPage extends StatefulWidget { } class _AddAppPageState extends State { - final _formKey = GlobalKey(); - final urlInputController = TextEditingController(); bool gettingAppInfo = false; + String userInput = ""; + AppSource? pickedSource; + List additionalData = []; + bool validAdditionalData = true; + @override Widget build(BuildContext context) { SourceProvider sourceProvider = SourceProvider(); @@ -28,103 +32,147 @@ class _AddAppPageState extends State { body: CustomScrollView(slivers: [ const CustomAppBar(title: 'Add App'), SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container(), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - decoration: const InputDecoration( - hintText: - 'https://github.com/Author/Project', - helperText: 'Enter the App source URL'), - controller: urlInputController, - validator: (value) { - if (value == null || - value.isEmpty || - Uri.tryParse(value) == null) { - return 'Please enter a supported source URL'; - } - return null; - }, - ), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 16.0), - child: ElevatedButton( - onPressed: gettingAppInfo - ? null - : () { - HapticFeedback.selectionClick(); - if (_formKey.currentState! - .validate()) { - setState(() { - gettingAppInfo = true; - }); - sourceProvider - .getApp(urlInputController - .value.text) - .then((app) { - var appsProvider = - context.read(); - var settingsProvider = context - .read(); - if (appsProvider.apps - .containsKey(app.id)) { - throw 'App already added'; + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: GeneratedForm( + items: [ + [ + GeneratedFormItem( + label: "App Source Url", + additionalValidators: [ + (value) { + try { + sourceProvider + .getSource(value ?? "") + .standardizeURL( + makeUrlHttps( + value ?? "")); + } catch (e) { + return e is String + ? e + : "Error"; } - settingsProvider - .getInstallPermission() - .then((_) { - appsProvider - .saveApp(app) - .then((_) { - urlInputController.clear(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - AppPage( - appId: - app.id))); - }); - }); - }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: - Text(e.toString())), - ); - }).whenComplete(() { - setState(() { - gettingAppInfo = false; - }); - }); - } - }, - child: const Text('Add'), - ), - ), - ], + return null; + } + ]) + ] + ], + onValueChanges: (values, valid) { + setState(() { + userInput = values[0]; + var source = valid + ? sourceProvider.getSource(userInput) + : null; + if (pickedSource != source) { + pickedSource = source; + additionalData = []; + validAdditionalData = source != null + ? sourceProvider + .doesSourceHaveRequiredAdditionalData( + source) + : true; + } + }); + }, + defaultValues: const [])), + const SizedBox( + width: 16, ), - ), + ElevatedButton( + onPressed: gettingAppInfo || + pickedSource == null || + (pickedSource!.additionalDataFormItems + .isNotEmpty && + !validAdditionalData) + ? null + : () { + HapticFeedback.selectionClick(); + setState(() { + gettingAppInfo = true; + }); + sourceProvider + .getApp(pickedSource!, userInput, + additionalData) + .then((app) { + var appsProvider = + context.read(); + var settingsProvider = + context.read(); + if (appsProvider.apps + .containsKey(app.id)) { + throw 'App already added'; + } + settingsProvider + .getInstallPermission() + .then((_) { + appsProvider.saveApp(app).then((_) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + AppPage( + appId: app.id))); + }); + }); + }).catchError((e) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar(content: Text(e.toString())), + ); + }).whenComplete(() { + setState(() { + gettingAppInfo = false; + }); + }); + }, + child: const Text('Add')) + ], + ), + const Divider( + height: 64, + ), + if (pickedSource != null && + (pickedSource!.additionalDataFormItems.isNotEmpty)) Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Additional Options for ${pickedSource?.runtimeType}', + style: TextStyle( + color: + Theme.of(context).colorScheme.primary)), + const SizedBox( + height: 16, + ), + GeneratedForm( + items: pickedSource!.additionalDataFormItems, + onValueChanges: (values, valid) { + setState(() { + additionalData = values; + validAdditionalData = valid; + }); + }, + defaultValues: + pickedSource!.additionalDataDefaults) + ], + ) + else + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // const SizedBox( + // height: 48, + // ), const Text( 'Supported Sources:', - // style: TextStyle(fontWeight: FontWeight.bold), - // style: Theme.of(context).textTheme.bodySmall, ), const SizedBox( height: 8, @@ -145,14 +193,9 @@ class _AddAppPageState extends State { fontStyle: FontStyle.italic), ))) .toList() - ]), - if (gettingAppInfo) - const LinearProgressIndicator() - else - Container(), - ], - )), - )) + ])), + ])), + ) ])); } } diff --git a/lib/pages/app.dart b/lib/pages/app.dart index e65b1e0..e805cab 100644 --- a/lib/pages/app.dart +++ b/lib/pages/app.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:obtainium/components/generated_form.dart'; +import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; +import 'package:obtainium/providers/source_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:provider/provider.dart'; @@ -20,7 +23,9 @@ class _AppPageState extends State { Widget build(BuildContext context) { var appsProvider = context.watch(); var settingsProvider = context.watch(); + var sourceProvider = SourceProvider(); AppInMemory? app = appsProvider.apps[widget.appId]; + var source = app != null ? sourceProvider.getSource(app.app.url) : null; if (app?.app.installedVersion != null) { appsProvider.getUpdate(app!.app.id); } @@ -159,6 +164,37 @@ class _AppPageState extends State { }, tooltip: 'Mark as Not Installed', icon: const Icon(Icons.no_cell_outlined)), + if (source != null && + source.additionalDataFormItems.isNotEmpty) + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: 'Additional Options', + items: source.additionalDataFormItems, + defaultValues: + source.additionalDataDefaults); + }).then((values) { + if (app != null && values != null) { + var changedApp = app.app; + changedApp.additionalData = values; + sourceProvider + .getApp(source, changedApp.url, + changedApp.additionalData) + .then((finalChangedApp) { + appsProvider.saveApp(finalChangedApp); + }).catchError((e) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar(content: Text(e.toString())), + ); + }); + } + }); + }, + icon: const Icon(Icons.settings)), const SizedBox(width: 16.0), Expanded( child: ElevatedButton( diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 96d5683..7c35787 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -5,6 +5,7 @@ import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/import_export.dart'; import 'package:obtainium/pages/settings.dart'; +import 'package:obtainium/pages/test_page.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart index e08e311..a8c1a05 100644 --- a/lib/pages/import_export.dart +++ b/lib/pages/import_export.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/settings_provider.dart'; @@ -167,9 +168,34 @@ class _ImportExportPageState extends State { return GeneratedFormModal( title: 'Import from URL List', items: [ - GeneratedFormItem( - 'App URL List', true, 7) + [ + GeneratedFormItem( + label: 'App URL List', + max: 7, + additionalValidators: [ + (String? value) { + if (value != null && + value.isNotEmpty) { + var lines = value + .trim() + .split('\n'); + for (int i = 0; + i < lines.length; + i++) { + try { + sourceProvider + .getSource( + lines[i]); + } catch (e) { + return 'Line ${i + 1}: $e'; + } + } + } + } + ]) + ] ], + defaultValues: const [], ); }).then((values) { if (values != null) { @@ -226,16 +252,17 @@ class _ImportExportPageState extends State { builder: (BuildContext ctx) { return GeneratedFormModal( - title: - 'Import ${source.name}', - items: source - .requiredArgs - .map((e) => + title: + 'Import ${source.name}', + items: source + .requiredArgs + .map((e) => [ GeneratedFormItem( - e, - true, - 1)) - .toList()); + label: e) + ]) + .toList(), + defaultValues: const [], + ); }).then((values) { if (values != null) { source diff --git a/lib/pages/test_page.dart b/lib/pages/test_page.dart new file mode 100644 index 0000000..c15679e --- /dev/null +++ b/lib/pages/test_page.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:obtainium/components/generated_form.dart'; + +class TestPage extends StatefulWidget { + const TestPage({super.key}); + + @override + State createState() => _TestPageState(); +} + +class _TestPageState extends State { + List? sourceSpecificData; + bool valid = false; + + List> 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 defaultInputValues = ["ABC"]; + + void onSourceSpecificDataChanges( + List 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) + .map((e) => Text(e ?? "")) + : [Container()]) + ]))); + } +} diff --git a/lib/providers/apps_provider.dart b/lib/providers/apps_provider.dart index 638e3ee..64ff3a3 100644 --- a/lib/providers/apps_provider.dart +++ b/lib/providers/apps_provider.dart @@ -228,7 +228,11 @@ class AppsProvider with ChangeNotifier { Future getUpdate(String appId) async { App? currentApp = apps[appId]!.app; - App newApp = await SourceProvider().getApp(currentApp.url); + SourceProvider sourceProvider = SourceProvider(); + App newApp = await sourceProvider.getApp( + sourceProvider.getSource(currentApp.url), + currentApp.url, + currentApp.additionalData); if (newApp.latestVersion != currentApp.latestVersion) { newApp.installedVersion = currentApp.installedVersion; if (currentApp.preferredApkIndex < newApp.apkUrls.length) { diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 6c0c3c5..5a252f2 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -10,6 +10,7 @@ import 'package:obtainium/app_sources/gitlab.dart'; import 'package:obtainium/app_sources/izzyondroid.dart'; import 'package:obtainium/app_sources/mullvad.dart'; import 'package:obtainium/app_sources/signal.dart'; +import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/mass_app_sources/githubstars.dart'; class AppNames { @@ -35,8 +36,17 @@ class App { late String latestVersion; List apkUrls = []; late int preferredApkIndex; - App(this.id, this.url, this.author, this.name, this.installedVersion, - this.latestVersion, this.apkUrls, this.preferredApkIndex); + late List additionalData; + App( + this.id, + this.url, + this.author, + this.name, + this.installedVersion, + this.latestVersion, + this.apkUrls, + this.preferredApkIndex, + this.additionalData); @override String toString() { @@ -44,19 +54,21 @@ class App { } factory App.fromJson(Map json) => App( - json['id'] as String, - json['url'] as String, - json['author'] as String, - json['name'] as String, - json['installedVersion'] == null - ? null - : json['installedVersion'] as String, - json['latestVersion'] as String, - List.from(jsonDecode(json['apkUrls'])), - json['preferredApkIndex'] == null - ? 0 - : json['preferredApkIndex'] as int, - ); + json['id'] as String, + json['url'] as String, + json['author'] as String, + json['name'] as String, + json['installedVersion'] == null + ? null + : json['installedVersion'] as String, + json['latestVersion'] as String, + json['apkUrls'] == null + ? [] + : List.from(jsonDecode(json['apkUrls'])), + json['preferredApkIndex'] == null ? 0 : json['preferredApkIndex'] as int, + json['additionalData'] == null + ? SourceProvider().getSource(json['url']).additionalDataDefaults + : List.from(jsonDecode(json['additionalData']))); Map toJson() => { 'id': id, @@ -66,7 +78,8 @@ class App { 'installedVersion': installedVersion, 'latestVersion': latestVersion, 'apkUrls': jsonEncode(apkUrls), - 'preferredApkIndex': preferredApkIndex + 'preferredApkIndex': preferredApkIndex, + 'additionalData': jsonEncode(additionalData) }; } @@ -76,10 +89,24 @@ escapeRegEx(String s) { }); } -const String couldNotFindReleases = 'Unable to fetch release info'; +makeUrlHttps(String url) { + if (url.toLowerCase().indexOf('http://') != 0 && + url.toLowerCase().indexOf('https://') != 0) { + url = 'https://$url'; + } + if (url.toLowerCase().indexOf('https://www.') == 0) { + url = 'https://${url.substring(12)}'; + } + return url; +} + +const String couldNotFindReleases = 'Could not find a suitable release'; const String couldNotFindLatestVersion = 'Could not determine latest release version'; -const String notValidURL = 'Not a valid URL'; +String notValidURL(String sourceName) { + return 'Not a valid $sourceName App URL'; +} + const String noAPKFound = 'No APK found'; List getLinksFromParsedHTML( @@ -96,8 +123,12 @@ List getLinksFromParsedHTML( abstract class AppSource { late String host; String standardizeURL(String url); - Future getLatestAPKDetails(String standardUrl); + Future getLatestAPKDetails( + String standardUrl, List additionalData); AppNames getAppNames(String standardUrl); + late List> additionalDataFormItems; + late List + additionalDataDefaults; // TODO: Make these integrate into generated form } abstract class MassAppSource { @@ -121,6 +152,7 @@ class SourceProvider { List massSources = [GitHubStars()]; AppSource getSource(String url) { + url = makeUrlHttps(url); AppSource? source; for (var s in sources) { if (url.toLowerCase().contains('://${s.host}')) { @@ -134,18 +166,23 @@ class SourceProvider { return source; } - Future getApp(String url) async { - if (url.toLowerCase().indexOf('http://') != 0 && - url.toLowerCase().indexOf('https://') != 0) { - url = 'https://$url'; + bool doesSourceHaveRequiredAdditionalData(AppSource source) { + for (var row in source.additionalDataFormItems) { + for (var element in row) { + if (element.required) { + return true; + } + } } - if (url.toLowerCase().indexOf('https://www.') == 0) { - url = 'https://${url.substring(12)}'; - } - AppSource source = getSource(url); - String standardUrl = source.standardizeURL(url); + return false; + } + + Future getApp( + AppSource source, String url, List additionalData) async { + String standardUrl = source.standardizeURL(makeUrlHttps(url)); AppNames names = source.getAppNames(standardUrl); - APKDetails apk = await source.getLatestAPKDetails(standardUrl); + APKDetails apk = + await source.getLatestAPKDetails(standardUrl, additionalData); return App( '${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}', standardUrl, @@ -154,7 +191,8 @@ class SourceProvider { null, apk.version, apk.apkUrls, - apk.apkUrls.length - 1); + apk.apkUrls.length - 1, + additionalData); } /// Returns a length 2 list, where the first element is a list of Apps and @@ -164,7 +202,8 @@ class SourceProvider { Map errors = {}; for (var url in urls) { try { - apps.add(await getApp(url)); + var source = getSource(url); + apps.add(await getApp(source, url, source.additionalDataDefaults)); } catch (e) { errors.addAll({url: e}); }