From 4ccf7cbc924c3ca32e283f60e650c14363f1c685 Mon Sep 17 00:00:00 2001 From: Imran Remtulla Date: Fri, 16 Sep 2022 23:52:58 -0400 Subject: [PATCH] Added GitHub starred import (+ general import/export changes) --- lib/components/generated_form_modal.dart | 78 ++++++++ lib/pages/home.dart | 37 +++- lib/pages/import_export.dart | 242 +++++++++++++++++++++++ lib/pages/settings.dart | 106 ---------- lib/providers/source_provider.dart | 52 +++++ 5 files changed, 399 insertions(+), 116 deletions(-) create mode 100644 lib/components/generated_form_modal.dart create mode 100644 lib/pages/import_export.dart diff --git a/lib/components/generated_form_modal.dart b/lib/components/generated_form_modal.dart new file mode 100644 index 0000000..9b15e67 --- /dev/null +++ b/lib/components/generated_form_modal.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class GeneratedFormItem { + late String message; + late bool required; + + GeneratedFormItem(this.message, this.required); +} + +class GeneratedFormModal extends StatefulWidget { + const GeneratedFormModal( + {super.key, required this.title, required this.items}); + + final String title; + final List items; + + @override + State createState() => _GeneratedFormModalState(); +} + +class _GeneratedFormModalState extends State { + final _formKey = GlobalKey(); + + final urlInputController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final formInputs = widget.items.map((e) { + final controller = TextEditingController(); + return [ + controller, + TextFormField( + decoration: InputDecoration(helperText: e.message), + controller: controller, + 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)], + )), + actions: [ + TextButton( + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(null); + }, + child: const Text('Cancel')), + TextButton( + onPressed: () { + if (_formKey.currentState?.validate() == true) { + HapticFeedback.heavyImpact(); + Navigator.of(context).pop(formInputs + .map((e) => (e[0] as TextEditingController).value.text) + .toList()); + } + }, + 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/pages/home.dart b/lib/pages/home.dart index 5d22a8e..76d9aa8 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; 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'; class HomePage extends StatefulWidget { @@ -12,11 +13,12 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - int selectedIndex = 1; + List selectedIndexHistory = []; List pages = [ - const SettingsPage(), const AppsPage(), - const AddAppPage() + const AddAppPage(), + const ImportExportPage(), + const SettingsPage() ]; @override @@ -24,27 +26,42 @@ class _HomePageState extends State { return WillPopScope( child: Scaffold( appBar: AppBar(title: const Text('Obtainium')), - body: pages.elementAt(selectedIndex), + body: pages.elementAt( + selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), bottomNavigationBar: NavigationBar( destinations: const [ - NavigationDestination( - icon: Icon(Icons.settings), label: 'Settings'), NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), + NavigationDestination( + icon: Icon(Icons.import_export), label: 'Import/Export'), + NavigationDestination( + icon: Icon(Icons.settings), label: 'Settings'), ], onDestinationSelected: (int index) { HapticFeedback.lightImpact(); setState(() { - selectedIndex = index; + if (index == 0) { + selectedIndexHistory.clear(); + } else if (selectedIndexHistory.isEmpty || + (selectedIndexHistory.isNotEmpty && + selectedIndexHistory.last != index)) { + int existingInd = selectedIndexHistory.indexOf(index); + if (existingInd >= 0) { + selectedIndexHistory.removeAt(existingInd); + } + selectedIndexHistory.add(index); + } + print(selectedIndexHistory); }); }, - selectedIndex: selectedIndex, + selectedIndex: + selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last, ), ), onWillPop: () async { - if (selectedIndex != 1) { + if (selectedIndexHistory.isNotEmpty) { setState(() { - selectedIndex = 1; + selectedIndexHistory.removeLast(); }); return false; } diff --git a/lib/pages/import_export.dart b/lib/pages/import_export.dart new file mode 100644 index 0000000..f878dc9 --- /dev/null +++ b/lib/pages/import_export.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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:provider/provider.dart'; + +class ImportExportPage extends StatefulWidget { + const ImportExportPage({super.key}); + + @override + State createState() => _ImportExportPageState(); +} + +class _ImportExportPageState extends State { + bool gettingAppInfo = false; + + Future>> addApps( + MassAppSource source, + List args, + SourceProvider sourceProvider, + SettingsProvider settingsProvider, + AppsProvider appsProvider) async { + var urls = await source.getUrls(args); + await settingsProvider.getInstallPermission(); + List results = await sourceProvider.getApps(urls); + 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.saveApp(app); + } + } + List> errors = + errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList(); + return errors; + } + + @override + Widget build(BuildContext context) { + SourceProvider sourceProvider = SourceProvider(); + var settingsProvider = context.read(); + var appsProvider = context.read(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + onPressed: appsProvider.apps.isEmpty + ? null + : () { + HapticFeedback.lightImpact(); + appsProvider.exportApps().then((String path) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Exported to $path')), + ); + }); + }, + child: const Text('Obtainium Export')), + const SizedBox( + height: 8, + ), + ElevatedButton( + onPressed: () { + HapticFeedback.lightImpact(); + showDialog( + context: context, + builder: (BuildContext ctx) { + final formKey = GlobalKey(); + final jsonInputController = TextEditingController(); + + return AlertDialog( + scrollable: true, + title: const Text('Import App List'), + content: Column(children: [ + const Text( + 'Copy the contents of the Obtainium export file and paste them into the field below:'), + Form( + key: formKey, + child: TextFormField( + minLines: 7, + maxLines: 7, + decoration: const InputDecoration( + helperText: 'Obtainium export data'), + controller: jsonInputController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your Obtainium export data'; + } + bool isJSON = true; + try { + jsonDecode(value); + } catch (e) { + isJSON = false; + } + if (!isJSON) { + return 'Invalid input'; + } + return null; + }, + ), + ) + ]), + actions: [ + TextButton( + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + }, + child: const Text('Cancel')), + TextButton( + onPressed: () { + HapticFeedback.heavyImpact(); + if (formKey.currentState!.validate()) { + appsProvider + .importApps( + jsonInputController.value.text) + .then((value) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + '$value App${value == 1 ? '' : 's'} Imported')), + ); + }).catchError((e) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar(content: Text(e.toString())), + ); + }).whenComplete(() { + Navigator.of(context).pop(); + }); + } + }, + child: const Text('Import')), + ], + ); + }); + }, + child: const Text('Obtainium Import')), + const Divider( + height: 32, + ), + ...sourceProvider.massSources + .map((source) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext ctx) { + return GeneratedFormModal( + title: 'Import ${source.name}', + items: source.requiredArgs + .map((e) => GeneratedFormItem(e, true)) + .toList()); + }).then((values) { + if (values != null) { + source.getUrls(values).then((urls) { + addApps(source, values, sourceProvider, + settingsProvider, appsProvider) + .then((errors) { + if (errors.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Imported ${urls.length} Apps')), + ); + } else { + showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + scrollable: true, + title: const Text('Import Errors'), + content: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Text( + '${urls.length - errors.length} of ${urls.length} Apps imported.', + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'The following Apps had errors:', + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ...errors.map((e) { + return Column( + crossAxisAlignment: + CrossAxisAlignment + .stretch, + children: [ + const SizedBox( + height: 16, + ), + Text(e[0]), + Text( + e[1], + style: const TextStyle( + fontStyle: FontStyle + .italic), + ) + ]); + }).toList() + ]), + actions: [ + TextButton( + onPressed: () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(null); + }, + child: const Text('Okay')) + ], + ); + }); + } + }); + }).catchError((e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + }); + } + }); + }, + child: Text('Import ${source.name}'))) + .toList() + ], + )); + } +} diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart index 64406f4..8623fb3 100644 --- a/lib/pages/settings.dart +++ b/lib/pages/settings.dart @@ -127,112 +127,6 @@ class _SettingsPageState extends State { }) ], ), - const SizedBox( - height: 16, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - ElevatedButton( - onPressed: appsProvider.apps.isEmpty - ? null - : () { - HapticFeedback.lightImpact(); - appsProvider.exportApps().then((String path) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Exported to $path')), - ); - }); - }, - child: const Text('Export App List')), - ElevatedButton( - onPressed: () { - HapticFeedback.lightImpact(); - showDialog( - context: context, - builder: (BuildContext ctx) { - final formKey = GlobalKey(); - final jsonInputController = - TextEditingController(); - - return AlertDialog( - scrollable: true, - title: const Text('Import App List'), - content: Column(children: [ - const Text( - 'Copy the contents of the Obtainium export file and paste them into the field below:'), - Form( - key: formKey, - child: TextFormField( - minLines: 7, - maxLines: 7, - decoration: const InputDecoration( - helperText: - 'Obtainium export data'), - controller: jsonInputController, - validator: (value) { - if (value == null || - value.isEmpty) { - return 'Please enter your Obtainium export data'; - } - bool isJSON = true; - try { - jsonDecode(value); - } catch (e) { - isJSON = false; - } - if (!isJSON) { - return 'Invalid input'; - } - return null; - }, - ), - ) - ]), - actions: [ - TextButton( - onPressed: () { - HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - }, - child: const Text('Cancel')), - TextButton( - onPressed: () { - HapticFeedback.heavyImpact(); - if (formKey.currentState! - .validate()) { - appsProvider - .importApps( - jsonInputController - .value.text) - .then((value) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - '$value App${value == 1 ? '' : 's'} Imported')), - ); - }).catchError((e) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: - Text(e.toString())), - ); - }).whenComplete(() { - Navigator.of(context).pop(); - }); - } - }, - child: const Text('Import')), - ], - ); - }); - }, - child: const Text('Import App List')) - ], - ), const Spacer(), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/providers/source_provider.dart b/lib/providers/source_provider.dart index 52bed44..2482322 100644 --- a/lib/providers/source_provider.dart +++ b/lib/providers/source_provider.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'package:html/dom.dart'; import 'package:http/http.dart'; import 'package:html/parser.dart'; +import 'package:obtainium/components/generated_form_modal.dart'; class AppNames { late String author; @@ -404,6 +405,8 @@ class SourceProvider { IzzyOnDroid() ]; + List massSources = [GitHubStars()]; + // Add more source classes here so they are available via the service AppSource getSource(String url) { AppSource? source; @@ -442,5 +445,54 @@ class SourceProvider { apk.apkUrls.length - 1); } + /// Returns a length 2 list, where the first element is a list of Apps and + /// the second is a Map of URLs and errors + Future> getApps(List urls) async { + List apps = []; + Map errors = {}; + for (var url in urls) { + try { + apps.add(await getApp(url)); + } catch (e) { + errors.addAll({url: e}); + } + } + return [apps, errors]; + } + List getSourceHosts() => sources.map((e) => e.host).toList(); } + +abstract class MassAppSource { + late String name; + late List requiredArgs; + Future> getUrls(List args); +} + +class GitHubStars implements MassAppSource { + @override + late String name = 'GitHub Starred Repos'; + + @override + late List requiredArgs = ['Username']; + + @override + Future> getUrls(List args) async { + if (args.length != requiredArgs.length) { + throw 'Wrong number of arguments provided'; + } + Response res = + await get(Uri.parse('https://api.github.com/users/${args[0]}/starred')); + if (res.statusCode == 200) { + return (jsonDecode(res.body) as List) + .map((e) => e['html_url'] as String) + .toList(); + } 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'; + } + + throw 'Unable to find user\'s starred repos'; + } + } +}