Compare commits

...

15 Commits

14 changed files with 833 additions and 595 deletions

View File

@ -10,6 +10,7 @@ Currently supported App sources:
- [GitHub](https://github.com/) - [GitHub](https://github.com/)
- [GitLab](https://gitlab.com/) - [GitLab](https://gitlab.com/)
- [F-Droid](https://f-droid.org/) - [F-Droid](https://f-droid.org/)
- [IzzyOnDroid](https://android.izzysoft.de/)
- [Mullvad](https://mullvad.net/en/) - [Mullvad](https://mullvad.net/en/)
- [Signal](https://signal.org/) - [Signal](https://signal.org/)

View 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),
),
),
);
}
}

View File

@ -59,14 +59,13 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
if (_formKey.currentState?.validate() == true) { if (_formKey.currentState?.validate() == true) {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(formInputs Navigator.of(context).pop(formInputs
.map((e) => (e[0] as TextEditingController).value.text) .map((e) => (e[0] as TextEditingController).value.text)
.toList()); .toList());

View File

@ -12,7 +12,7 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
const String currentReleaseTag = const String currentReleaseTag =
'v0.2.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v0.2.3-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point') @pragma('vm:entry-point')
void bgTaskCallback() { void bgTaskCallback() {

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -22,113 +23,136 @@ class _AddAppPageState extends State<AddAppPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
return Center( return Scaffold(
child: Form( backgroundColor: Theme.of(context).colorScheme.surface,
key: _formKey, body: CustomScrollView(slivers: <Widget>[
child: Column( const CustomAppBar(title: 'Add App'),
mainAxisAlignment: MainAxisAlignment.spaceBetween, SliverFillRemaining(
crossAxisAlignment: CrossAxisAlignment.stretch, hasScrollBody: false,
children: [ child: Center(
Container(), child: Form(
Padding( key: _formKey,
padding: const EdgeInsets.all(16), child: Column(
child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
TextFormField( Container(),
decoration: const InputDecoration( Padding(
hintText: 'https://github.com/Author/Project', padding: const EdgeInsets.all(16),
helperText: 'Enter the App source URL'), child: Column(
controller: urlInputController, crossAxisAlignment: CrossAxisAlignment.stretch,
validator: (value) { children: [
if (value == null || TextFormField(
value.isEmpty || decoration: const InputDecoration(
Uri.tryParse(value) == null) { hintText:
return 'Please enter a supported source URL'; 'https://github.com/Author/Project',
} helperText: 'Enter the App source URL'),
return null; controller: urlInputController,
}, validator: (value) {
), if (value == null ||
Padding( value.isEmpty ||
padding: const EdgeInsets.symmetric(vertical: 16.0), Uri.tryParse(value) == null) {
child: ElevatedButton( return 'Please enter a supported source URL';
onPressed: gettingAppInfo }
? null return null;
: () { },
HapticFeedback.mediumImpact(); ),
if (_formKey.currentState!.validate()) { Padding(
setState(() { padding:
gettingAppInfo = true; const EdgeInsets.symmetric(vertical: 16.0),
}); child: ElevatedButton(
sourceProvider onPressed: gettingAppInfo
.getApp(urlInputController.value.text) ? null
.then((app) { : () {
var appsProvider = HapticFeedback.selectionClick();
context.read<AppsProvider>(); if (_formKey.currentState!
var settingsProvider = .validate()) {
context.read<SettingsProvider>(); setState(() {
if (appsProvider.apps.containsKey(app.id)) { gettingAppInfo = true;
throw 'App already added'; });
} sourceProvider
settingsProvider .getApp(urlInputController
.getInstallPermission() .value.text)
.then((_) { .then((app) {
appsProvider.saveApp(app).then((_) { var appsProvider =
urlInputController.clear(); context.read<AppsProvider>();
Navigator.push( var settingsProvider = context
context, .read<SettingsProvider>();
MaterialPageRoute( if (appsProvider.apps
builder: (context) => .containsKey(app.id)) {
AppPage(appId: app.id))); throw 'App already added';
}); }
}); settingsProvider
}).catchError((e) { .getInstallPermission()
ScaffoldMessenger.of(context).showSnackBar( .then((_) {
SnackBar(content: Text(e.toString())), appsProvider
); .saveApp(app)
}).whenComplete(() { .then((_) {
setState(() { urlInputController.clear();
gettingAppInfo = false; Navigator.push(
}); context,
}); MaterialPageRoute(
} builder: (context) =>
}, AppPage(
child: const Text('Add'), appId:
), app.id)));
), });
], });
), }).catchError((e) {
), ScaffoldMessenger.of(context)
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ .showSnackBar(
const Text( SnackBar(
'Supported Sources:', content:
// style: TextStyle(fontWeight: FontWeight.bold), Text(e.toString())),
// style: Theme.of(context).textTheme.bodySmall, );
), }).whenComplete(() {
const SizedBox( setState(() {
height: 8, gettingAppInfo = false;
), });
...sourceProvider });
.getSourceHosts() }
.map((e) => GestureDetector( },
onTap: () { child: const Text('Add'),
launchUrlString('https://$e', ),
mode: LaunchMode.externalApplication); ),
}, ],
child: Text( ),
e, ),
style: const TextStyle( Column(
decoration: TextDecoration.underline, crossAxisAlignment: CrossAxisAlignment.center,
fontStyle: FontStyle.italic), children: [
))) const Text(
.toList() 'Supported Sources:',
]), // style: TextStyle(fontWeight: FontWeight.bold),
if (gettingAppInfo) // style: Theme.of(context).textTheme.bodySmall,
const LinearProgressIndicator() ),
else const SizedBox(
Container(), 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(),
],
)),
))
]));
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -25,61 +26,64 @@ class _AppPageState extends State<AppPage> {
appsProvider.getUpdate(app!.app.id); appsProvider.getUpdate(app!.app.id);
} }
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.surface,
title: Text('${app?.app.author}/${app?.app.name}'), body: CustomScrollView(slivers: <Widget>[
), CustomAppBar(title: '${app?.app.name}'),
body: settingsProvider.showAppWebpage SliverFillRemaining(
? WebView( child: settingsProvider.showAppWebpage
initialUrl: app?.app.url, ? WebView(
javascriptMode: JavascriptMode.unrestricted, initialUrl: app?.app.url,
) javascriptMode: JavascriptMode.unrestricted,
: Column( )
mainAxisAlignment: MainAxisAlignment.center, : Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
Text( children: [
app?.app.name ?? 'App', Text(
textAlign: TextAlign.center, app?.app.name ?? 'App',
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, textAlign: TextAlign.center,
style: const TextStyle( style: Theme.of(context).textTheme.displayLarge,
decoration: TextDecoration.underline, ),
fontStyle: FontStyle.italic, Text(
fontSize: 12), 'By ${app?.app.author ?? 'Unknown'}',
)), textAlign: TextAlign.center,
const SizedBox( style: Theme.of(context).textTheme.headlineMedium,
height: 32, ),
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( bottomSheet: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
0, 0, 0, MediaQuery.of(context).padding.bottom), 0, 0, 0, MediaQuery.of(context).padding.bottom),
@ -91,15 +95,15 @@ class _AppPageState extends State<AppPage> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
if (app?.app.installedVersion == null) if (app?.app.installedVersion != app?.app.latestVersion)
IconButton( IconButton(
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
return AlertDialog( return AlertDialog(
title: const Text( title: Text(
'App Already Installed?'), 'App Already ${app?.app.installedVersion == null ? 'Installed' : 'Updated'}?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
@ -108,6 +112,7 @@ class _AppPageState extends State<AppPage> {
child: const Text('No')), child: const Text('No')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.selectionClick();
var updatedApp = app?.app; var updatedApp = app?.app;
if (updatedApp != null) { if (updatedApp != null) {
updatedApp.installedVersion = updatedApp.installedVersion =
@ -124,9 +129,42 @@ class _AppPageState extends State<AppPage> {
}); });
}, },
tooltip: 'Mark as Installed', tooltip: 'Mark as Installed',
icon: const Icon(Icons.done)), icon: const Icon(Icons.done))
if (app?.app.installedVersion == null) else
const SizedBox(width: 16.0), 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( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: (app?.app.installedVersion == null || onPressed: (app?.app.installedVersion == null ||
@ -154,7 +192,6 @@ class _AppPageState extends State<AppPage> {
onPressed: app?.downloadProgress != null onPressed: app?.downloadProgress != null
? null ? null
: () { : () {
HapticFeedback.lightImpact();
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@ -165,7 +202,8 @@ class _AppPageState extends State<AppPage> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback
.selectionClick();
appsProvider appsProvider
.removeApp(app!.app.id) .removeApp(app!.app.id)
.then((_) { .then((_) {
@ -178,7 +216,6 @@ class _AppPageState extends State<AppPage> {
child: const Text('Remove')), child: const Text('Remove')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
child: const Text('Cancel')) child: const Text('Cancel'))

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/app.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
@ -35,6 +36,7 @@ class _AppsPageState extends State<AppsPage> {
} }
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
floatingActionButton: existingUpdateAppIds.isEmpty floatingActionButton: existingUpdateAppIds.isEmpty
? null ? null
: ElevatedButton.icon( : ElevatedButton.icon(
@ -47,49 +49,51 @@ class _AppsPageState extends State<AppsPage> {
existingUpdateAppIds, context); existingUpdateAppIds, context);
}); });
}, },
icon: const Icon(Icons.update), icon: const Icon(Icons.install_mobile_outlined),
label: const Text('Update All')), label: const Text('Install All')),
body: Center( body: RefreshIndicator(
child: appsProvider.loadingApps onRefresh: () {
? const CircularProgressIndicator() HapticFeedback.lightImpact();
: appsProvider.apps.isEmpty return appsProvider.checkUpdates();
? Text( },
'No Apps', child: CustomScrollView(slivers: <Widget>[
style: Theme.of(context).textTheme.headlineMedium, const CustomAppBar(title: 'Apps'),
) if (appsProvider.loadingApps || appsProvider.apps.isEmpty)
: RefreshIndicator( SliverFillRemaining(
onRefresh: () { child: Center(
HapticFeedback.lightImpact(); child: appsProvider.loadingApps
return appsProvider.checkUpdates(); ? const CircularProgressIndicator()
}, : Text(
child: ListView( 'No Apps',
children: sortedApps style:
.map( Theme.of(context).textTheme.headlineMedium,
(e) => ListTile( ))),
title: Text('${e.app.author}/${e.app.name}'), SliverList(
subtitle: Text( delegate: SliverChildBuilderDelegate(
e.app.installedVersion ?? 'Not Installed'), (BuildContext context, int index) {
trailing: e.downloadProgress != null return ListTile(
? Text( title: Text(
'Downloading - ${e.downloadProgress?.toInt()}%') '${sortedApps[index].app.author}/${sortedApps[index].app.name}'),
: (e.app.installedVersion != null && subtitle: Text(sortedApps[index].app.installedVersion ??
e.app.installedVersion != 'Not Installed'),
e.app.latestVersion trailing: sortedApps[index].downloadProgress != null
? const Text('Update Available') ? Text(
: null), 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
onTap: () { : (sortedApps[index].app.installedVersion != null &&
Navigator.push( sortedApps[index].app.installedVersion !=
context, sortedApps[index].app.latestVersion
MaterialPageRoute( ? const Text('Update Available')
builder: (context) => : null),
AppPage(appId: e.app.id)), onTap: () {
); Navigator.push(
}, context,
), MaterialPageRoute(
) builder: (context) =>
.toList(), AppPage(appId: sortedApps[index].app.id)),
), );
), },
)); );
}, childCount: sortedApps.length))
])));
} }
} }

View File

@ -1,3 +1,4 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:obtainium/pages/add_app.dart'; import 'package:obtainium/pages/add_app.dart';
@ -12,33 +13,56 @@ class HomePage extends StatefulWidget {
State<HomePage> createState() => _HomePageState(); 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> { class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = []; List<int> selectedIndexHistory = [];
List<Widget> pages = [
const AppsPage(), List<NavigationPageItem> pages = [
const AddAppPage(), NavigationPageItem('Apps', Icons.apps, const AppsPage()),
const ImportExportPage(), NavigationPageItem('Add App', Icons.add, const AddAppPage()),
const SettingsPage() NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()),
NavigationPageItem('Settings', Icons.settings, const SettingsPage())
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(
child: Scaffold( child: Scaffold(
appBar: AppBar(title: const Text('Obtainium')), backgroundColor: Theme.of(context).colorScheme.surface,
body: pages.elementAt( body: PageTransitionSwitcher(
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last), 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( bottomNavigationBar: NavigationBar(
destinations: const [ destinations: pages
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'), .map((e) =>
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'), NavigationDestination(icon: Icon(e.icon), label: e.title))
NavigationDestination( .toList(),
icon: Icon(Icons.import_export), label: 'Import/Export'),
NavigationDestination(
icon: Icon(Icons.settings), label: 'Settings'),
],
onDestinationSelected: (int index) { onDestinationSelected: (int index) {
HapticFeedback.lightImpact(); HapticFeedback.selectionClick();
setState(() { setState(() {
if (index == 0) { if (index == 0) {
selectedIndexHistory.clear(); selectedIndexHistory.clear();

View File

@ -1,12 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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/components/generated_form_modal.dart';
import 'package:obtainium/providers/apps_provider.dart'; import 'package:obtainium/providers/apps_provider.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart'; import 'package:obtainium/providers/source_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:file_picker/file_picker.dart';
class ImportExportPage extends StatefulWidget { class ImportExportPage extends StatefulWidget {
const ImportExportPage({super.key}); const ImportExportPage({super.key});
@ -16,13 +19,23 @@ class ImportExportPage extends StatefulWidget {
} }
class _ImportExportPageState extends State<ImportExportPage> { class _ImportExportPageState extends State<ImportExportPage> {
bool gettingAppInfo = false; bool importInProgress = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
SourceProvider sourceProvider = SourceProvider(); SourceProvider sourceProvider = SourceProvider();
var settingsProvider = context.read<SettingsProvider>(); var settingsProvider = context.read<SettingsProvider>();
var appsProvider = context.read<AppsProvider>(); 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 { Future<List<List<String>>> addApps(List<String> urls) async {
await settingsProvider.getInstallPermission(); await settingsProvider.getInstallPermission();
@ -41,190 +54,243 @@ class _ImportExportPageState extends State<ImportExportPage> {
return errors; return errors;
} }
return Padding( return Scaffold(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), backgroundColor: Theme.of(context).colorScheme.surface,
child: Column( body: CustomScrollView(slivers: <Widget>[
crossAxisAlignment: CrossAxisAlignment.stretch, const CustomAppBar(title: 'Import/Export'),
children: [ SliverFillRemaining(
ElevatedButton( hasScrollBody: false,
onPressed: appsProvider.apps.isEmpty || gettingAppInfo child: Padding(
? null padding:
: () { const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
HapticFeedback.lightImpact(); child: Column(
appsProvider.exportApps().then((String path) { crossAxisAlignment: CrossAxisAlignment.stretch,
ScaffoldMessenger.of(context).showSnackBar( children: [
SnackBar(content: Text('Exported to $path')), Row(
);
});
},
child: const Text('Obtainium Export')),
const SizedBox(
height: 8,
),
ElevatedButton(
onPressed: gettingAppInfo
? null
: () {
HapticFeedback.lightImpact();
showDialog(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Obtainium Import',
items: [
GeneratedFormItem(
'Obtainium Export JSON Data', true, 7)
]);
}).then((values) {
if (values != null) {
try {
jsonDecode(values[0]);
} catch (e) {
throw 'Invalid input';
}
appsProvider.importApps(values[0]).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())),
);
});
},
child: const Text('Obtainium Import')),
if (gettingAppInfo)
Column(
children: const [
SizedBox(
height: 14,
),
LinearProgressIndicator(),
SizedBox(
height: 14,
),
],
)
else
const Divider(
height: 32,
),
TextButton(
onPressed: gettingAppInfo
? 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(() {
gettingAppInfo = 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(() {
gettingAppInfo = false;
});
});
}
});
},
child: const Text('Import from URL List')),
...sourceProvider.massSources
.map((source) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: 8), Expanded(
TextButton( child: TextButton(
onPressed: gettingAppInfo style: outlineButtonStyle,
? null onPressed: appsProvider.apps.isEmpty ||
: () { importInProgress
showDialog( ? null
context: context, : () {
builder: (BuildContext ctx) { HapticFeedback.selectionClick();
return GeneratedFormModal( appsProvider
title: 'Import ${source.name}', .exportApps()
items: source.requiredArgs .then((String path) {
.map((e) => ScaffoldMessenger.of(context)
GeneratedFormItem( .showSnackBar(
e, true, 1)) SnackBar(
.toList()); content: Text(
}).then((values) { 'Exported to $path')),
if (values != null) { );
source.getUrls(values).then((urls) { });
},
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(() { setState(() {
gettingAppInfo = true; importInProgress = true;
}); });
addApps(urls).then((errors) { if (result != null) {
if (errors.isEmpty) { String data = File(
result.files.single.path!)
.readAsStringSync();
try {
jsonDecode(data);
} catch (e) {
throw 'Invalid input';
}
appsProvider
.importApps(data)
.then((value) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar( .showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'Imported ${urls.length} Apps')), '$value App${value == 1 ? '' : 's'} Imported')),
); );
} else {
showDialog(
context: context,
builder:
(BuildContext ctx) {
return ImportErrorDialog(
urlsLength:
urls.length,
errors: errors);
});
}
}).whenComplete(() {
setState(() {
gettingAppInfo = false;
}); });
}); } else {
// User canceled the picker
}
}).catchError((e) { }).catchError((e) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
.showSnackBar( .showSnackBar(
SnackBar( SnackBar(
content: Text(e.toString())), 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) {
child: Text('Import ${source.name}')) if (errors.isEmpty) {
])) ScaffoldMessenger.of(context)
.toList() .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()
],
)))
]));
} }
} }
@ -274,7 +340,6 @@ class _ImportErrorDialogState extends State<ImportErrorDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Okay')) child: const Text('Okay'))

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart';
import 'package:obtainium/providers/settings_provider.dart'; import 'package:obtainium/providers/settings_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -18,185 +18,219 @@ class _SettingsPageState extends State<SettingsPage> {
if (settingsProvider.prefs == null) { if (settingsProvider.prefs == null) {
settingsProvider.initializeSettings(); settingsProvider.initializeSettings();
} }
return Padding( return Scaffold(
padding: const EdgeInsets.all(16), backgroundColor: Theme.of(context).colorScheme.surface,
child: settingsProvider.prefs == null body: CustomScrollView(slivers: <Widget>[
? Container() const CustomAppBar(title: 'Add App'),
: Column( SliverFillRemaining(
children: [ hasScrollBody: true,
DropdownButtonFormField( child: Padding(
decoration: const InputDecoration(labelText: 'Theme'), padding: const EdgeInsets.all(16),
value: settingsProvider.theme, child: settingsProvider.prefs == null
items: const [ ? Container()
DropdownMenuItem( : Column(
value: ThemeSettings.dark, crossAxisAlignment: CrossAxisAlignment.start,
child: Text('Dark'), children: [
), Text(
DropdownMenuItem( 'Appearance',
value: ThemeSettings.light, style: TextStyle(
child: Text('Light'), color: Theme.of(context).colorScheme.primary),
), ),
DropdownMenuItem( DropdownButtonFormField(
value: ThemeSettings.system, decoration:
child: Text('Follow System'), const InputDecoration(labelText: 'Theme'),
) value: settingsProvider.theme,
], items: const [
onChanged: (value) { DropdownMenuItem(
if (value != null) { value: ThemeSettings.dark,
settingsProvider.theme = value; child: Text('Dark'),
} ),
}), DropdownMenuItem(
const SizedBox( value: ThemeSettings.light,
height: 16, child: Text('Light'),
), ),
DropdownButtonFormField( DropdownMenuItem(
decoration: const InputDecoration(labelText: 'Colour'), value: ThemeSettings.system,
value: settingsProvider.colour, child: Text('Follow System'),
items: const [ )
DropdownMenuItem( ],
value: ColourSettings.basic, onChanged: (value) {
child: Text('Obtainium'), if (value != null) {
), settingsProvider.theme = value;
DropdownMenuItem( }
value: ColourSettings.materialYou, }),
child: Text('Material You'), const SizedBox(
) height: 16,
], ),
onChanged: (value) { DropdownButtonFormField(
if (value != null) { decoration:
settingsProvider.colour = value; const InputDecoration(labelText: 'Colour'),
} value: settingsProvider.colour,
}), items: const [
const SizedBox( DropdownMenuItem(
height: 16, value: ColourSettings.basic,
), child: Text('Obtainium'),
DropdownButtonFormField( ),
decoration: const InputDecoration( DropdownMenuItem(
labelText: 'Background Update Checking Interval'), value: ColourSettings.materialYou,
value: settingsProvider.updateInterval, child: Text('Material You'),
items: const [ )
DropdownMenuItem( ],
value: 15, onChanged: (value) {
child: Text('15 Minutes'), if (value != null) {
), settingsProvider.colour = value;
DropdownMenuItem( }
value: 30, }),
child: Text('30 Minutes'), const SizedBox(
), height: 16,
DropdownMenuItem( ),
value: 60, Row(
child: Text('1 Hour'), mainAxisAlignment: MainAxisAlignment.start,
), crossAxisAlignment: CrossAxisAlignment.start,
DropdownMenuItem( children: [
value: 360, Expanded(
child: Text('6 Hours'), child: DropdownButtonFormField(
), decoration: const InputDecoration(
DropdownMenuItem( labelText: 'App Sort By'),
value: 720, value: settingsProvider.sortColumn,
child: Text('12 Hours'), items: const [
), DropdownMenuItem(
DropdownMenuItem( value:
value: 1440, SortColumnSettings.authorName,
child: Text('1 Day'), child: Text('Author/Name'),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 0, value:
child: Text('Never - Manual Only'), SortColumnSettings.nameAuthor,
), child: Text('Name/Author'),
], ),
onChanged: (value) { DropdownMenuItem(
if (value != null) { value: SortColumnSettings.added,
settingsProvider.updateInterval = value; child: Text('As Added'),
} )
}), ],
const SizedBox( onChanged: (value) {
height: 16, if (value != null) {
), settingsProvider.sortColumn = value;
DropdownButtonFormField( }
decoration: })),
const InputDecoration(labelText: 'App Sort By'), const SizedBox(
value: settingsProvider.sortColumn, width: 16,
items: const [ ),
DropdownMenuItem( Expanded(
value: SortColumnSettings.authorName, child: DropdownButtonFormField(
child: Text('Author/Name'), decoration: const InputDecoration(
), labelText: 'App Sort Order'),
DropdownMenuItem( value: settingsProvider.sortOrder,
value: SortColumnSettings.nameAuthor, items: const [
child: Text('Name/Author'), DropdownMenuItem(
), value: SortOrderSettings.ascending,
DropdownMenuItem( child: Text('Ascending'),
value: SortColumnSettings.added, ),
child: Text('As Added'), DropdownMenuItem(
) value: SortOrderSettings.descending,
], child: Text('Descending'),
onChanged: (value) { ),
if (value != null) { ],
settingsProvider.sortColumn = value; onChanged: (value) {
} if (value != null) {
}), settingsProvider.sortOrder = value;
const SizedBox( }
height: 16, })),
), ],
DropdownButtonFormField( ),
decoration: const SizedBox(
const InputDecoration(labelText: 'App Sort Order'), height: 16,
value: settingsProvider.sortOrder, ),
items: const [ Row(
DropdownMenuItem( mainAxisAlignment: MainAxisAlignment.spaceBetween,
value: SortOrderSettings.ascending, children: [
child: Text('Ascending'), const Text('Show Source Webpage in App View'),
), Switch(
DropdownMenuItem( value: settingsProvider.showAppWebpage,
value: SortOrderSettings.descending, onChanged: (value) {
child: Text('Descending'), settingsProvider.showAppWebpage = value;
), })
], ],
onChanged: (value) { ),
if (value != null) { const Divider(
settingsProvider.sortOrder = value; height: 16,
} ),
}), const SizedBox(
const SizedBox( height: 16,
height: 16, ),
), Text(
Row( 'More',
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: TextStyle(
children: [ color: Theme.of(context).colorScheme.primary),
const Text('Show Source Webpage in App View'), ),
Switch( DropdownButtonFormField(
value: settingsProvider.showAppWebpage, decoration: const InputDecoration(
onChanged: (value) { labelText:
settingsProvider.showAppWebpage = value; 'Background Update Checking Interval'),
}) value: settingsProvider.updateInterval,
], items: const [
), DropdownMenuItem(
const Spacer(), value: 15,
Row( child: Text('15 Minutes'),
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ DropdownMenuItem(
TextButton.icon( value: 30,
style: ButtonStyle( child: Text('30 Minutes'),
foregroundColor: ),
MaterialStateProperty.resolveWith<Color>( DropdownMenuItem(
(Set<MaterialState> states) { value: 60,
return Colors.grey; child: Text('1 Hour'),
}), ),
), DropdownMenuItem(
onPressed: () { value: 360,
HapticFeedback.lightImpact(); child: Text('6 Hours'),
launchUrlString(settingsProvider.sourceUrl, ),
mode: LaunchMode.externalApplication); DropdownMenuItem(
}, value: 720,
icon: const Icon(Icons.code), child: Text('12 Hours'),
label: Text( ),
'Source', DropdownMenuItem(
style: Theme.of(context).textTheme.bodySmall, 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,
),
)
],
),
],
)))
]));
} }
} }

View File

@ -339,13 +339,12 @@ class _APKPickerState extends State<APKPicker> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(apkUrl); Navigator.of(context).pop(apkUrl);
}, },
child: const Text('Continue')) child: const Text('Continue'))
@ -376,13 +375,12 @@ class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.lightImpact();
Navigator.of(context).pop(null); Navigator.of(context).pop(null);
}, },
child: const Text('Cancel')), child: const Text('Cancel')),
TextButton( TextButton(
onPressed: () { onPressed: () {
HapticFeedback.heavyImpact(); HapticFeedback.selectionClick();
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
child: const Text('Continue')) child: const Text('Continue'))

View File

@ -399,9 +399,9 @@ class SourceProvider {
GitHub(), GitHub(),
GitLab(), GitLab(),
FDroid(), FDroid(),
IzzyOnDroid(),
Mullvad(), Mullvad(),
Signal(), Signal()
IzzyOnDroid()
]; ];
List<MassAppSource> massSources = [GitHubStars()]; List<MassAppSource> massSources = [GitHubStars()];

View File

@ -1,6 +1,13 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
animations:
dependency: "direct main"
description:
name: animations
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.4"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@ -162,6 +169,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.1.4" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -194,21 +208,28 @@ packages:
name: flutter_local_notifications name: flutter_local_notifications
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "9.9.1" version: "10.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.5.1" version: "1.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter

View File

@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 0.2.0+11 # When changing this, update the tag in main() accordingly version: 0.2.3+14 # When changing this, update the tag in main() accordingly
environment: environment:
sdk: '>=2.19.0-79.0.dev <3.0.0' sdk: '>=2.19.0-79.0.dev <3.0.0'
@ -38,7 +38,7 @@ dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
path_provider: ^2.0.11 path_provider: ^2.0.11
flutter_fgbg: ^0.2.0 # Try removing reliance on this 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 provider: ^6.0.3
http: ^0.13.5 http: ^0.13.5
webview_flutter: ^3.0.4 webview_flutter: ^3.0.4
@ -51,6 +51,8 @@ dependencies:
permission_handler: ^10.0.0 permission_handler: ^10.0.0
fluttertoast: ^8.0.9 fluttertoast: ^8.0.9
device_info_plus: ^4.1.2 device_info_plus: ^4.1.2
file_picker: ^5.1.0
animations: ^2.0.4
dev_dependencies: dev_dependencies: