Added GitHub starred import (+ general import/export changes)

This commit is contained in:
Imran Remtulla
2022-09-16 23:52:58 -04:00
parent ab4efd85ce
commit 4ccf7cbc92
5 changed files with 399 additions and 116 deletions

View File

@@ -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<GeneratedFormItem> items;
@override
State<GeneratedFormModal> createState() => _GeneratedFormModalState();
}
class _GeneratedFormModalState extends State<GeneratedFormModal> {
final _formKey = GlobalKey<FormState>();
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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
import 'package:obtainium/pages/apps.dart'; import 'package:obtainium/pages/apps.dart';
import 'package:obtainium/pages/import_export.dart';
import 'package:obtainium/pages/settings.dart'; import 'package:obtainium/pages/settings.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@@ -12,11 +13,12 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
int selectedIndex = 1; List<int> selectedIndexHistory = [];
List<Widget> pages = [ List<Widget> pages = [
const SettingsPage(),
const AppsPage(), const AppsPage(),
const AddAppPage() const AddAppPage(),
const ImportExportPage(),
const SettingsPage()
]; ];
@override @override
@@ -24,27 +26,42 @@ class _HomePageState extends State<HomePage> {
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')), appBar: AppBar(title: const Text('Obtainium')),
body: pages.elementAt(selectedIndex), body: pages.elementAt(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: const [
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), 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) { onDestinationSelected: (int index) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
setState(() { 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 { onWillPop: () async {
if (selectedIndex != 1) { if (selectedIndexHistory.isNotEmpty) {
setState(() { setState(() {
selectedIndex = 1; selectedIndexHistory.removeLast();
}); });
return false; return false;
} }

View File

@@ -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<ImportExportPage> createState() => _ImportExportPageState();
}
class _ImportExportPageState extends State<ImportExportPage> {
bool gettingAppInfo = false;
Future<List<List<String>>> addApps(
MassAppSource source,
List<String> args,
SourceProvider sourceProvider,
SettingsProvider settingsProvider,
AppsProvider appsProvider) async {
var urls = await source.getUrls(args);
await settingsProvider.getInstallPermission();
List<dynamic> results = await sourceProvider.getApps(urls);
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.saveApp(app);
}
}
List<List<String>> errors =
errorsMap.keys.map((e) => [e, errorsMap[e].toString()]).toList();
return errors;
}
@override
Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>();
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<FormState>();
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()
],
));
}
}

View File

@@ -127,112 +127,6 @@ class _SettingsPageState extends State<SettingsPage> {
}) })
], ],
), ),
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<FormState>();
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(), const Spacer(),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -6,6 +6,7 @@ import 'dart:convert';
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:obtainium/components/generated_form_modal.dart';
class AppNames { class AppNames {
late String author; late String author;
@@ -404,6 +405,8 @@ class SourceProvider {
IzzyOnDroid() IzzyOnDroid()
]; ];
List<MassAppSource> massSources = [GitHubStars()];
// Add more source classes here so they are available via the service // Add more source classes here so they are available via the service
AppSource getSource(String url) { AppSource getSource(String url) {
AppSource? source; AppSource? source;
@@ -442,5 +445,54 @@ class SourceProvider {
apk.apkUrls.length - 1); apk.apkUrls.length - 1);
} }
/// Returns a length 2 list, where the first element is a list of Apps and
/// the second is a Map<String, dynamic> of URLs and errors
Future<List<dynamic>> getApps(List<String> urls) async {
List<App> apps = [];
Map<String, dynamic> errors = {};
for (var url in urls) {
try {
apps.add(await getApp(url));
} catch (e) {
errors.addAll(<String, dynamic>{url: e});
}
}
return [apps, errors];
}
List<String> getSourceHosts() => sources.map((e) => e.host).toList(); List<String> getSourceHosts() => sources.map((e) => e.host).toList();
} }
abstract class MassAppSource {
late String name;
late List<String> requiredArgs;
Future<List<String>> getUrls(List<String> args);
}
class GitHubStars implements MassAppSource {
@override
late String name = 'GitHub Starred Repos';
@override
late List<String> requiredArgs = ['Username'];
@override
Future<List<String>> getUrls(List<String> 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<dynamic>)
.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';
}
}
}