mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-13 13:26:43 +02:00
Compare commits
27 Commits
v0.1.8-bet
...
v0.2.4-bet
Author | SHA1 | Date | |
---|---|---|---|
6c1ad94b4f | |||
7d7986f8bf | |||
3ddf9ea736 | |||
2272f8b4e6 | |||
9514062a3a | |||
da57018b90 | |||
87e31c37aa | |||
cb4dfff1b9 | |||
911b06bfb6 | |||
53513bfdd1 | |||
681092d895 | |||
0f6b6253de | |||
c724b276ab | |||
35369273bd | |||
0b1863a227 | |||
9e21f2d6e6 | |||
6f11f850e0 | |||
5e96b91029 | |||
5fc79af960 | |||
05f5590e7d | |||
50f8caeb47 | |||
f966a9e626 | |||
02a5749ba7 | |||
4ccf7cbc92 | |||
ab4efd85ce | |||
42bba0f64c | |||
294327bde4 |
@ -10,13 +10,14 @@ Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [IzzyOnDroid](https://android.izzysoft.de/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods are either unavailable (e.g. Mullvad), insufficient (e.g. GitHub RSS) or subject to rate limits (e.g. GitHub API).
|
||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||
|
||||
## Screenshots
|
||||
|
||||
|
29
lib/components/custom_app_bar.dart
Normal file
29
lib/components/custom_app_bar.dart
Normal file
@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomAppBar extends StatefulWidget {
|
||||
const CustomAppBar({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<CustomAppBar> createState() => _CustomAppBarState();
|
||||
}
|
||||
|
||||
class _CustomAppBarState extends State<CustomAppBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
expandedHeight: 100,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
titlePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
title: Text(
|
||||
widget.title,
|
||||
style:
|
||||
TextStyle(color: Theme.of(context).textTheme.bodyMedium!.color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
80
lib/components/generated_form_modal.dart
Normal file
80
lib/components/generated_form_modal.dart
Normal file
@ -0,0 +1,80 @@
|
||||
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: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
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());
|
||||
}
|
||||
},
|
||||
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.8-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
'v0.2.4-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
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -22,113 +23,136 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
return 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.mediumImpact();
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider
|
||||
.getApp(urlInputController.value.text)
|
||||
.then((app) {
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
||||
// style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
]),
|
||||
if (gettingAppInfo)
|
||||
const LinearProgressIndicator()
|
||||
else
|
||||
Container(),
|
||||
],
|
||||
)),
|
||||
);
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
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<AppsProvider>();
|
||||
var settingsProvider = context
|
||||
.read<SettingsProvider>();
|
||||
if (appsProvider.apps
|
||||
.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
||||
// style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
mode:
|
||||
LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
style: const TextStyle(
|
||||
decoration:
|
||||
TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
]),
|
||||
if (gettingAppInfo)
|
||||
const LinearProgressIndicator()
|
||||
else
|
||||
Container(),
|
||||
],
|
||||
)),
|
||||
))
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -25,61 +26,64 @@ class _AppPageState extends State<AppPage> {
|
||||
appsProvider.getUpdate(app!.app.id);
|
||||
}
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
||||
),
|
||||
body: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
CustomAppBar(title: '${app?.app.name}'),
|
||||
SliverFillRemaining(
|
||||
child: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||
@ -91,6 +95,76 @@ class _AppPageState extends State<AppPage> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (app?.app.installedVersion != app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
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))
|
||||
else
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('App Not Installed?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('No')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp.installedVersion =
|
||||
null;
|
||||
appsProvider
|
||||
.saveApp(updatedApp);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
'Yes, Mark as Not Installed'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
tooltip: 'Mark as Not Installed',
|
||||
icon: const Icon(Icons.no_cell_outlined)),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: (app?.app.installedVersion == null ||
|
||||
@ -118,7 +192,6 @@ class _AppPageState extends State<AppPage> {
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -129,7 +202,8 @@ class _AppPageState extends State<AppPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
HapticFeedback
|
||||
.selectionClick();
|
||||
appsProvider
|
||||
.removeApp(app!.app.id)
|
||||
.then((_) {
|
||||
@ -142,7 +216,6 @@ class _AppPageState extends State<AppPage> {
|
||||
child: const Text('Remove')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -16,9 +17,26 @@ 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(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
@ -26,57 +44,56 @@ class _AppsPageState extends State<AppsPage> {
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
context
|
||||
.read<SettingsProvider>()
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
settingsProvider.getInstallPermission().then((_) {
|
||||
appsProvider.downloadAndInstallLatestApp(
|
||||
existingUpdateAppIds, context);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.update),
|
||||
label: const Text('Update All')),
|
||||
body: Center(
|
||||
child: appsProvider.loadingApps
|
||||
? const CircularProgressIndicator()
|
||||
: appsProvider.apps.isEmpty
|
||||
? Text(
|
||||
'No Apps',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
return appsProvider.checkUpdates();
|
||||
},
|
||||
child: ListView(
|
||||
children: appsProvider.apps.values
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text('${e.app.author}/${e.app.name}'),
|
||||
subtitle: Text(
|
||||
e.app.installedVersion ?? 'Not Installed'),
|
||||
trailing: e.downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
||||
: (e.app.installedVersion != null &&
|
||||
e.app.installedVersion !=
|
||||
e.app.latestVersion
|
||||
? const Text('Update Available')
|
||||
: null),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: e.app.id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
));
|
||||
icon: const Icon(Icons.install_mobile_outlined),
|
||||
label: const Text('Install All')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
return appsProvider.checkUpdates();
|
||||
},
|
||||
child: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Apps'),
|
||||
if (appsProvider.loadingApps || appsProvider.apps.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
child: appsProvider.loadingApps
|
||||
? const CircularProgressIndicator()
|
||||
: Text(
|
||||
'No Apps',
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium,
|
||||
))),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
'${sortedApps[index].app.author}/${sortedApps[index].app.name}'),
|
||||
subtitle: Text(sortedApps[index].app.installedVersion ??
|
||||
'Not Installed'),
|
||||
trailing: sortedApps[index].downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
|
||||
: (sortedApps[index].app.installedVersion != null &&
|
||||
sortedApps[index].app.installedVersion !=
|
||||
sortedApps[index].app.latestVersion
|
||||
? const Text('Update Available')
|
||||
: null),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: sortedApps[index].app.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}, childCount: sortedApps.length))
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import 'package:animations/animations.dart';
|
||||
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 {
|
||||
@ -11,40 +13,78 @@ class HomePage extends StatefulWidget {
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class NavigationPageItem {
|
||||
late String title;
|
||||
late IconData icon;
|
||||
late Widget widget;
|
||||
|
||||
NavigationPageItem(this.title, this.icon, this.widget);
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int selectedIndex = 1;
|
||||
List<Widget> pages = [
|
||||
const SettingsPage(),
|
||||
const AppsPage(),
|
||||
const AddAppPage()
|
||||
List<int> selectedIndexHistory = [];
|
||||
|
||||
List<NavigationPageItem> pages = [
|
||||
NavigationPageItem('Apps', Icons.apps, const AppsPage()),
|
||||
NavigationPageItem('Add App', Icons.add, const AddAppPage()),
|
||||
NavigationPageItem(
|
||||
'Import/Export', Icons.import_export, const ImportExportPage()),
|
||||
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Obtainium')),
|
||||
body: pages.elementAt(selectedIndex),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: PageTransitionSwitcher(
|
||||
transitionBuilder: (
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: pages
|
||||
.elementAt(selectedIndexHistory.isEmpty
|
||||
? 0
|
||||
: selectedIndexHistory.last)
|
||||
.widget,
|
||||
),
|
||||
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'),
|
||||
],
|
||||
destinations: pages
|
||||
.map((e) =>
|
||||
NavigationDestination(icon: Icon(e.icon), label: e.title))
|
||||
.toList(),
|
||||
onDestinationSelected: (int index) {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedback.selectionClick();
|
||||
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;
|
||||
}
|
||||
|
349
lib/pages/import_export.dart
Normal file
349
lib/pages/import_export.dart
Normal file
@ -0,0 +1,349 @@
|
||||
import 'dart:convert';
|
||||
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_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>();
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: MaterialStateProperty.all(
|
||||
StadiumBorder(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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 Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Import/Export'),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: outlineButtonStyle,
|
||||
onPressed: appsProvider.apps.isEmpty ||
|
||||
importInProgress
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider
|
||||
.exportApps()
|
||||
.then((String path) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Exported to $path')),
|
||||
);
|
||||
});
|
||||
},
|
||||
child: const Text('Obtainium Export'))),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: outlineButtonStyle,
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
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: () {
|
||||
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/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -17,244 +14,223 @@ 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();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: settingsProvider.prefs == null
|
||||
? Container()
|
||||
: Column(
|
||||
children: [
|
||||
DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'Theme'),
|
||||
value: settingsProvider.theme,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.dark,
|
||||
child: Text('Dark'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.light,
|
||||
child: Text('Light'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.system,
|
||||
child: Text('Follow System'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.theme = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration: const InputDecoration(labelText: 'Colour'),
|
||||
value: settingsProvider.colour,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.basic,
|
||||
child: Text('Obtainium'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.materialYou,
|
||||
child: Text('Material You'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.colour = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Background Update Checking Interval'),
|
||||
value: settingsProvider.updateInterval,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 15,
|
||||
child: Text('15 Minutes'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 30,
|
||||
child: Text('30 Minutes'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 60,
|
||||
child: Text('1 Hour'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 360,
|
||||
child: Text('6 Hours'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 720,
|
||||
child: Text('12 Hours'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 1440,
|
||||
child: Text('1 Day'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.updateInterval = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Show Source Webpage in App View'),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
settingsProvider.showAppWebpage = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
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,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor:
|
||||
MaterialStateProperty.resolveWith<Color>(
|
||||
(Set<MaterialState> states) {
|
||||
return Colors.grey;
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
'Source',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
));
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(slivers: <Widget>[
|
||||
const CustomAppBar(title: 'Settings'),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: settingsProvider.prefs == null
|
||||
? Container()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Appearance',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Theme'),
|
||||
value: settingsProvider.theme,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.dark,
|
||||
child: Text('Dark'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.light,
|
||||
child: Text('Light'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ThemeSettings.system,
|
||||
child: Text('Follow System'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.theme = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Colour'),
|
||||
value: settingsProvider.colour,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.basic,
|
||||
child: Text('Obtainium'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: ColourSettings.materialYou,
|
||||
child: Text('Material You'),
|
||||
)
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.colour = value;
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: 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(
|
||||
width: 16,
|
||||
),
|
||||
Expanded(
|
||||
child: 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: [
|
||||
const Text('Show Source Webpage in App View'),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
settingsProvider.showAppWebpage = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
const Divider(
|
||||
height: 16,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Text(
|
||||
'More',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
DropdownButtonFormField(
|
||||
decoration: const InputDecoration(
|
||||
labelText:
|
||||
'Background Update Checking Interval'),
|
||||
value: settingsProvider.updateInterval,
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 15,
|
||||
child: Text('15 Minutes'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 30,
|
||||
child: Text('30 Minutes'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 60,
|
||||
child: Text('1 Hour'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 360,
|
||||
child: Text('6 Hours'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 720,
|
||||
child: Text('12 Hours'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 1440,
|
||||
child: Text('1 Day'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 0,
|
||||
child: Text('Never - Manual Only'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsProvider.updateInterval = value;
|
||||
}
|
||||
}),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: ButtonStyle(
|
||||
foregroundColor:
|
||||
MaterialStateProperty.resolveWith<
|
||||
Color>((Set<MaterialState> states) {
|
||||
return Colors.grey;
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
'Source',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)))
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
@ -339,13 +339,12 @@ class _APKPickerState extends State<APKPicker> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(apkUrl);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
@ -376,13 +375,12 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
HapticFeedback.selectionClick();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -71,6 +71,12 @@ escapeRegEx(String s) {
|
||||
});
|
||||
}
|
||||
|
||||
const String couldNotFindReleases = 'Unable to fetch release info';
|
||||
const String couldNotFindLatestVersion =
|
||||
'Could not determine latest release version';
|
||||
const String notValidURL = 'Not a valid URL';
|
||||
const String noAPKFound = 'No APK found';
|
||||
|
||||
List<String> getLinksFromParsedHTML(
|
||||
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
||||
dom
|
||||
@ -98,44 +104,53 @@ class GitHub implements AppSource {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
||||
Response res = await get(Uri.parse(
|
||||
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
var parsedHtml = parse(res.body);
|
||||
var apkUrlList = getLinksFromParsedHTML(
|
||||
parsedHtml,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin);
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw 'No APK found';
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
// 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) {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
String getTag(String url) {
|
||||
List<String> parts = url.split('/');
|
||||
return parts[parts.length - 2];
|
||||
List<dynamic>? assets = latestRelease['assets'];
|
||||
List<String>? 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) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
String? version = latestRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} 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';
|
||||
}
|
||||
|
||||
String latestTag = getTag(apkUrlList[0]);
|
||||
String? version = parsedHtml
|
||||
.querySelector('.octicon-tag')
|
||||
?.nextElementSibling
|
||||
?.innerHtml
|
||||
.trim();
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version,
|
||||
apkUrlList.where((element) => getTag(element) == latestTag).toList());
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -156,7 +171,7 @@ class GitLab implements AppSource {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -184,18 +199,18 @@ class GitLab implements AppSource {
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw 'No APK found';
|
||||
throw noAPKFound;
|
||||
}
|
||||
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,15 +238,15 @@ class Signal implements AppSource {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw 'No APK found';
|
||||
throw noAPKFound;
|
||||
}
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,7 +263,7 @@ class FDroid implements AppSource {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -263,7 +278,7 @@ class FDroid implements AppSource {
|
||||
?.querySelector('.package-version-download a')
|
||||
?.attributes['href'];
|
||||
if (apkUrl == null) {
|
||||
throw 'No APK found';
|
||||
throw noAPKFound;
|
||||
}
|
||||
var version = latestReleaseDiv
|
||||
?.querySelector('.package-version-header b')
|
||||
@ -271,18 +286,17 @@ class FDroid implements AppSource {
|
||||
.split(' ')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,7 +309,7 @@ class Mullvad implements AppSource {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
@ -311,12 +325,12 @@ class Mullvad implements AppSource {
|
||||
?.split('/')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine the latest release version';
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(
|
||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,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(),
|
||||
IzzyOnDroid(),
|
||||
Mullvad(),
|
||||
Signal()
|
||||
];
|
||||
|
||||
List<MassAppSource> massSources = [GitHubStars()];
|
||||
|
||||
// Add more source classes here so they are available via the service
|
||||
AppSource getSource(String url) {
|
||||
@ -367,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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
45
pubspec.lock
45
pubspec.lock
@ -1,6 +1,13 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: animations
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -133,7 +140,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 +169,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
|
||||
@ -194,21 +208,28 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.9.1"
|
||||
version: "10.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
version: "1.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "6.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 +386,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 +442,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 +470,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 +559,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 +587,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 +650,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 +664,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.8+9 # When changing this, update the tag in main() accordingly
|
||||
version: 0.2.4+15 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
@ -38,7 +38,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.5
|
||||
path_provider: ^2.0.11
|
||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||
flutter_local_notifications: ^9.9.1
|
||||
flutter_local_notifications: ^10.0.0
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
@ -51,6 +51,8 @@ dependencies:
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^4.1.2
|
||||
file_picker: ^5.1.0
|
||||
animations: ^2.0.4
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
Reference in New Issue
Block a user