mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 05:16:43 +02:00
Compare commits
11 Commits
v0.1.9-bet
...
v0.2.1-bet
Author | SHA1 | Date | |
---|---|---|---|
9e21f2d6e6 | |||
6f11f850e0 | |||
5e96b91029 | |||
5fc79af960 | |||
05f5590e7d | |||
50f8caeb47 | |||
f966a9e626 | |||
02a5749ba7 | |||
4ccf7cbc92 | |||
ab4efd85ce | |||
42bba0f64c |
81
lib/components/generated_form_modal.dart
Normal file
81
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,81 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
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)],
|
||||
)),
|
||||
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
|
@ -12,7 +12,7 @@ import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
const String currentReleaseTag =
|
||||
'v0.1.9-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
'v0.2.1-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void bgTaskCallback() {
|
||||
@ -81,11 +81,15 @@ class MyApp extends StatelessWidget {
|
||||
settingsProvider.initializeSettings();
|
||||
} else {
|
||||
// Register the background update task according to the user's setting
|
||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||
frequency: Duration(minutes: settingsProvider.updateInterval),
|
||||
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace);
|
||||
if (settingsProvider.updateInterval > 0) {
|
||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||
frequency: Duration(minutes: settingsProvider.updateInterval),
|
||||
initialDelay: Duration(minutes: settingsProvider.updateInterval),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingWorkPolicy.replace);
|
||||
} else {
|
||||
Workmanager().cancelByUniqueName('bg-update-check');
|
||||
}
|
||||
bool isFirstRun = settingsProvider.checkAndFlipFirstRun();
|
||||
if (isFirstRun) {
|
||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||
|
@ -91,6 +91,42 @@ class _AppPageState extends State<AppPage> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion == null)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'App Already Installed?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp.installedVersion =
|
||||
updatedApp.latestVersion;
|
||||
appsProvider
|
||||
.saveApp(updatedApp);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Installed'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Installed',
|
||||
icon: const Icon(Icons.done)),
|
||||
if (app?.app.installedVersion == null)
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
|
@ -16,7 +16,23 @@ class _AppsPageState extends State<AppsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
var existingUpdateAppIds = appsProvider.getExistingUpdates();
|
||||
var sortedApps = appsProvider.apps.values.toList();
|
||||
sortedApps.sort((a, b) {
|
||||
int result = 0;
|
||||
if (settingsProvider.sortColumn == SortColumnSettings.authorName) {
|
||||
result =
|
||||
(a.app.author + a.app.name).compareTo(b.app.author + b.app.name);
|
||||
} else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) {
|
||||
result =
|
||||
(a.app.name + a.app.author).compareTo(b.app.name + b.app.author);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
if (settingsProvider.sortOrder == SortOrderSettings.ascending) {
|
||||
sortedApps = sortedApps.reversed.toList();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
||||
@ -26,10 +42,7 @@ class _AppsPageState extends State<AppsPage> {
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
context
|
||||
.read<SettingsProvider>()
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
settingsProvider.getInstallPermission().then((_) {
|
||||
appsProvider.downloadAndInstallLatestApp(
|
||||
existingUpdateAppIds, context);
|
||||
});
|
||||
@ -50,7 +63,7 @@ class _AppsPageState extends State<AppsPage> {
|
||||
return appsProvider.checkUpdates();
|
||||
},
|
||||
child: ListView(
|
||||
children: appsProvider.apps.values
|
||||
children: sortedApps
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text('${e.app.author}/${e.app.name}'),
|
||||
|
@ -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<HomePage> {
|
||||
int selectedIndex = 1;
|
||||
List<int> selectedIndexHistory = [];
|
||||
List<Widget> pages = [
|
||||
const SettingsPage(),
|
||||
const AppsPage(),
|
||||
const AddAppPage()
|
||||
const AddAppPage(),
|
||||
const ImportExportPage(),
|
||||
const SettingsPage()
|
||||
];
|
||||
|
||||
@override
|
||||
@ -24,27 +26,41 @@ class _HomePageState extends State<HomePage> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectedIndex: selectedIndex,
|
||||
selectedIndex:
|
||||
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
|
||||
),
|
||||
),
|
||||
onWillPop: () async {
|
||||
if (selectedIndex != 1) {
|
||||
if (selectedIndexHistory.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIndex = 1;
|
||||
selectedIndexHistory.removeLast();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
288
lib/pages/import_export.dart
Normal file
288
lib/pages/import_export.dart
Normal file
@ -0,0 +1,288 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
|
||||
class ImportExportPage extends StatefulWidget {
|
||||
const ImportExportPage({super.key});
|
||||
|
||||
@override
|
||||
State<ImportExportPage> createState() => _ImportExportPageState();
|
||||
}
|
||||
|
||||
class _ImportExportPageState extends State<ImportExportPage> {
|
||||
bool importInProgress = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var settingsProvider = context.read<SettingsProvider>();
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
|
||||
Future<List<List<String>>> addApps(List<String> urls) async {
|
||||
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;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: appsProvider.apps.isEmpty || importInProgress
|
||||
? 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: importInProgress
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
FilePicker.platform.pickFiles().then((result) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
if (result != null) {
|
||||
String data = File(result.files.single.path!)
|
||||
.readAsStringSync();
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw 'Invalid input';
|
||||
}
|
||||
appsProvider.importApps(data).then((value) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Import')),
|
||||
if (importInProgress)
|
||||
Column(
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 14,
|
||||
),
|
||||
LinearProgressIndicator(),
|
||||
SizedBox(
|
||||
height: 14,
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Divider(
|
||||
height: 32,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Import from URL List',
|
||||
items: [
|
||||
GeneratedFormItem('App URL List', true, 7)
|
||||
],
|
||||
);
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
var urls = (values[0] as String).split('\n');
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Imported ${urls.length} Apps')),
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: urls.length,
|
||||
errors: errors);
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Text('Import from URL List')),
|
||||
...sourceProvider.massSources
|
||||
.map((source) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: 'Import ${source.name}',
|
||||
items: source.requiredArgs
|
||||
.map((e) =>
|
||||
GeneratedFormItem(
|
||||
e, true, 1))
|
||||
.toList());
|
||||
}).then((values) {
|
||||
if (values != null) {
|
||||
source.getUrls(values).then((urls) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
addApps(urls).then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Imported ${urls.length} Apps')),
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength:
|
||||
urls.length,
|
||||
errors: errors);
|
||||
});
|
||||
}
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString())),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text('Import ${source.name}'))
|
||||
]))
|
||||
.toList()
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class ImportErrorDialog extends StatefulWidget {
|
||||
const ImportErrorDialog(
|
||||
{super.key, required this.urlsLength, required this.errors});
|
||||
|
||||
final int urlsLength;
|
||||
final List<List<String>> errors;
|
||||
|
||||
@override
|
||||
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
|
||||
}
|
||||
|
||||
class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Import Errors'),
|
||||
content:
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
Text(
|
||||
'${widget.urlsLength - widget.errors.length} of ${widget.urlsLength} Apps imported.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'The following URLs had errors:',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
...widget.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'))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,8 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -17,7 +14,6 @@ class SettingsPage extends StatefulWidget {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings();
|
||||
@ -103,6 +99,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
value: 1440,
|
||||
child: Text('1 Day'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text('Never - Manual Only'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
@ -112,6 +112,54 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'App Sort By'),
|
||||
value: settingsProvider.sortColumn,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.authorName,
|
||||
child: Text('Author/Name'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.nameAuthor,
|
||||
child: Text('Name/Author'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortColumnSettings.added,
|
||||
child: Text('As Added'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortColumn = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'App Sort Order'),
|
||||
value: settingsProvider.sortOrder,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.ascending,
|
||||
child: Text('Ascending'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: SortOrderSettings.descending,
|
||||
child: Text('Descending'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.sortOrder = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@ -123,112 +171,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(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
@ -119,7 +119,7 @@ class AppsProvider with ChangeNotifier {
|
||||
}
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation
|
||||
if (apkUrl != null &&
|
||||
!apkUrl.toLowerCase().startsWith(apps[id]!.app.url.toLowerCase())) {
|
||||
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin) {
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
|
@ -9,6 +9,10 @@ enum ThemeSettings { system, light, dark }
|
||||
|
||||
enum ColourSettings { basic, materialYou }
|
||||
|
||||
enum SortColumnSettings { added, nameAuthor, authorName }
|
||||
|
||||
enum SortOrderSettings { ascending, descending }
|
||||
|
||||
class SettingsProvider with ChangeNotifier {
|
||||
SharedPreferences? prefs;
|
||||
|
||||
@ -45,7 +49,27 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
set updateInterval(int min) {
|
||||
prefs?.setInt('updateInterval', min < 15 ? 15 : min);
|
||||
prefs?.setInt('updateInterval', (min < 15 && min != 0) ? 15 : min);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
SortColumnSettings get sortColumn {
|
||||
return SortColumnSettings
|
||||
.values[prefs?.getInt('sortColumn') ?? SortColumnSettings.added.index];
|
||||
}
|
||||
|
||||
set sortColumn(SortColumnSettings s) {
|
||||
prefs?.setInt('sortColumn', s.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
SortOrderSettings get sortOrder {
|
||||
return SortOrderSettings.values[
|
||||
prefs?.getInt('sortOrder') ?? SortOrderSettings.descending.index];
|
||||
}
|
||||
|
||||
set sortOrder(SortOrderSettings s) {
|
||||
prefs?.setInt('sortOrder', s.index);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
@ -296,8 +296,7 @@ class FDroid implements AppSource {
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
var name = Uri.parse(standardUrl).pathSegments.last;
|
||||
return AppNames(name, name);
|
||||
return AppNames('F-Droid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
}
|
||||
|
||||
@ -341,8 +340,71 @@ class Mullvad implements AppSource {
|
||||
}
|
||||
}
|
||||
|
||||
class IzzyOnDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'android.izzysoft.de';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/repo/apk/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var parsedHtml = parse(res.body);
|
||||
var multipleVersionApkUrls = parsedHtml
|
||||
.querySelectorAll('a')
|
||||
.where((element) =>
|
||||
element.attributes['href']?.toLowerCase().endsWith('.apk') ??
|
||||
false)
|
||||
.map((e) => 'https://$host${e.attributes['href'] ?? ''}')
|
||||
.toList();
|
||||
if (multipleVersionApkUrls.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
var version = parsedHtml
|
||||
.querySelector('#keydata')
|
||||
?.querySelectorAll('b')
|
||||
.where(
|
||||
(element) => element.innerHtml.toLowerCase().contains('version'))
|
||||
.toList()[0]
|
||||
.parentNode
|
||||
?.parentNode
|
||||
?.children[1]
|
||||
.innerHtml;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [multipleVersionApkUrls[0]]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('IzzyOnDroid', Uri.parse(standardUrl).pathSegments.last);
|
||||
}
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
|
||||
List<AppSource> sources = [
|
||||
GitHub(),
|
||||
GitLab(),
|
||||
FDroid(),
|
||||
Mullvad(),
|
||||
Signal(),
|
||||
IzzyOnDroid()
|
||||
];
|
||||
|
||||
List<MassAppSource> massSources = [GitHubStars()];
|
||||
|
||||
// Add more source classes here so they are available via the service
|
||||
AppSource getSource(String url) {
|
||||
@ -382,5 +444,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<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();
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
32
pubspec.lock
32
pubspec.lock
@ -133,7 +133,7 @@ packages:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
version: "4.1.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -162,6 +162,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.4"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -209,6 +216,13 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@ -365,7 +379,7 @@ packages:
|
||||
name: path_provider_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -421,7 +435,7 @@ packages:
|
||||
name: plugin_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -449,7 +463,7 @@ packages:
|
||||
name: shared_preferences_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.12"
|
||||
version: "2.0.13"
|
||||
shared_preferences_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -538,7 +552,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.13"
|
||||
version: "0.4.14"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -566,7 +580,7 @@ packages:
|
||||
name: url_launcher_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.17"
|
||||
version: "6.0.19"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -629,7 +643,7 @@ packages:
|
||||
name: webview_flutter_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.10.0"
|
||||
version: "2.10.1"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -643,14 +657,14 @@ packages:
|
||||
name: webview_flutter_wkwebview
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.9.3"
|
||||
version: "2.9.4"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
version: "3.0.0"
|
||||
workmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 0.1.9+10 # When changing this, update the tag in main() accordingly
|
||||
version: 0.2.1+12 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
@ -51,6 +51,7 @@ dependencies:
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^4.1.2
|
||||
file_picker: ^5.1.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
Reference in New Issue
Block a user