Merge pull request #31 from ImranR98/apps-list-improvements

Added
- Multi select on the Apps page with share, delete, and install actions - #23 
- (Related to above) Ability to filter and update all out of date Apps - #27 
- Notifying users to return to the App to complete installs is less buggy thanks to the new installer plugin - #24
This commit is contained in:
Imran Remtulla
2022-09-25 02:01:51 -04:00
committed by GitHub
14 changed files with 502 additions and 259 deletions

View File

@@ -21,9 +21,9 @@ class GitHub implements AppSource {
Future<APKDetails> getLatestAPKDetails( Future<APKDetails> getLatestAPKDetails(
String standardUrl, List<String> additionalData) async { String standardUrl, List<String> additionalData) async {
var includePrereleases = var includePrereleases =
additionalData.isNotEmpty && additionalData[0] == "true"; additionalData.isNotEmpty && additionalData[0] == 'true';
var fallbackToOlderReleases = var fallbackToOlderReleases =
additionalData.length >= 2 && additionalData[1] == "true"; additionalData.length >= 2 && additionalData[1] == 'true';
var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty var regexFilter = additionalData.length >= 3 && additionalData[2].isNotEmpty
? additionalData[2] ? additionalData[2]
: null; : null;
@@ -92,14 +92,14 @@ class GitHub implements AppSource {
@override @override
List<List<GeneratedFormItem>> additionalDataFormItems = [ List<List<GeneratedFormItem>> additionalDataFormItems = [
[GeneratedFormItem(label: "Include prereleases", type: FormItemType.bool)], [GeneratedFormItem(label: 'Include prereleases', type: FormItemType.bool)],
[ [
GeneratedFormItem( GeneratedFormItem(
label: "Fallback to older releases", type: FormItemType.bool) label: 'Fallback to older releases', type: FormItemType.bool)
], ],
[ [
GeneratedFormItem( GeneratedFormItem(
label: "Filter Release Titles by Regular Expression", label: 'Filter Release Titles by Regular Expression',
type: FormItemType.string, type: FormItemType.string,
required: false, required: false,
additionalValidators: [ additionalValidators: [
@@ -110,7 +110,7 @@ class GitHub implements AppSource {
try { try {
RegExp(value); RegExp(value);
} catch (e) { } catch (e) {
return "Invalid regular expression"; return 'Invalid regular expression';
} }
return null; return null;
} }
@@ -119,5 +119,5 @@ class GitHub implements AppSource {
]; ];
@override @override
List<String> additionalDataDefaults = ["true", "true", ""]; List<String> additionalDataDefaults = ['true', 'true', ''];
} }

View File

@@ -12,7 +12,7 @@ class GeneratedFormItem {
late List<String? Function(String? value)> additionalValidators; late List<String? Function(String? value)> additionalValidators;
GeneratedFormItem( GeneratedFormItem(
{this.label = "Input", {this.label = 'Input',
this.type = FormItemType.string, this.type = FormItemType.string,
this.required = true, this.required = true,
this.max = 1, this.max = 1,
@@ -69,7 +69,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
.map((row) => row.map((e) { .map((row) => row.map((e) {
return j < widget.defaultValues.length return j < widget.defaultValues.length
? widget.defaultValues[j++] ? widget.defaultValues[j++]
: ""; : '';
}).toList()) }).toList())
.toList(); .toList();
@@ -89,7 +89,7 @@ class _GeneratedFormState extends State<GeneratedForm> {
}); });
}, },
decoration: InputDecoration( decoration: InputDecoration(
helperText: e.value.label + (e.value.required ? " *" : "")), helperText: e.value.label + (e.value.required ? ' *' : '')),
minLines: e.value.max <= 1 ? null : e.value.max, minLines: e.value.max <= 1 ? null : e.value.max,
maxLines: e.value.max <= 1 ? 1 : e.value.max, maxLines: e.value.max <= 1 ? 1 : e.value.max,
validator: (value) { validator: (value) {
@@ -122,10 +122,10 @@ class _GeneratedFormState extends State<GeneratedForm> {
children: [ children: [
Text(widget.items[r][e].label), Text(widget.items[r][e].label),
Switch( Switch(
value: values[r][e] == "true", value: values[r][e] == 'true',
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
values[r][e] = value ? "true" : ""; values[r][e] = value ? 'true' : '';
someValueChanged(); someValueChanged();
}); });
}) })

View File

@@ -7,11 +7,15 @@ class GeneratedFormModal extends StatefulWidget {
{super.key, {super.key,
required this.title, required this.title,
required this.items, required this.items,
required this.defaultValues}); required this.defaultValues,
this.initValid = false,
this.message = ''});
final String title; final String title;
final String message;
final List<List<GeneratedFormItem>> items; final List<List<GeneratedFormItem>> items;
final List<String> defaultValues; final List<String> defaultValues;
final bool initValid;
@override @override
State<GeneratedFormModal> createState() => _GeneratedFormModalState(); State<GeneratedFormModal> createState() => _GeneratedFormModalState();
@@ -21,20 +25,34 @@ class _GeneratedFormModalState extends State<GeneratedFormModal> {
List<String> values = []; List<String> values = [];
bool valid = false; bool valid = false;
@override
void initState() {
super.initState();
valid = widget.initValid;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
scrollable: true, scrollable: true,
title: Text(widget.title), title: Text(widget.title),
content: GeneratedForm( content:
items: widget.items, Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
onValueChanges: (values, valid) { if (widget.message.isNotEmpty) Text(widget.message),
setState(() { if (widget.message.isNotEmpty)
this.values = values; const SizedBox(
this.valid = valid; height: 16,
}); ),
}, GeneratedForm(
defaultValues: widget.defaultValues), items: widget.items,
onValueChanges: (values, valid) {
setState(() {
this.values = values;
this.valid = valid;
});
},
defaultValues: widget.defaultValues)
]),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {

View File

@@ -13,7 +13,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.3.2-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES 'v0.4.0-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
@pragma('vm:entry-point') @pragma('vm:entry-point')
void bgTaskCallback() { void bgTaskCallback() {
@@ -99,7 +99,7 @@ class MyApp extends StatelessWidget {
if (settingsProvider.updateInterval > 0) { if (settingsProvider.updateInterval > 0) {
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check', Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
frequency: Duration(minutes: settingsProvider.updateInterval), frequency: Duration(minutes: settingsProvider.updateInterval),
// initialDelay: Duration(minutes: settingsProvider.updateInterval), initialDelay: Duration(minutes: settingsProvider.updateInterval),
constraints: Constraints(networkType: NetworkType.connected), constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.replace); existingWorkPolicy: ExistingWorkPolicy.replace);
} else { } else {
@@ -109,16 +109,18 @@ class MyApp extends StatelessWidget {
if (isFirstRun) { if (isFirstRun) {
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list // If this is the first run, ask for notification permissions and add Obtainium to the Apps list
Permission.notification.request(); Permission.notification.request();
appsProvider.saveApp(App( appsProvider.saveApps([
'imranr98_obtainium_${GitHub().host}', App(
'https://github.com/ImranR98/Obtainium', 'imranr98_obtainium_${GitHub().host}',
'ImranR98', 'https://github.com/ImranR98/Obtainium',
'Obtainium', 'ImranR98',
currentReleaseTag, 'Obtainium',
currentReleaseTag, currentReleaseTag,
[], currentReleaseTag,
0, [],
["true"])); 0,
['true'])
]);
} }
} }

View File

@@ -19,7 +19,7 @@ class AddAppPage extends StatefulWidget {
class _AddAppPageState extends State<AddAppPage> { class _AddAppPageState extends State<AddAppPage> {
bool gettingAppInfo = false; bool gettingAppInfo = false;
String userInput = ""; String userInput = '';
AppSource? pickedSource; AppSource? pickedSource;
List<String> additionalData = []; List<String> additionalData = [];
bool validAdditionalData = true; bool validAdditionalData = true;
@@ -44,19 +44,19 @@ class _AddAppPageState extends State<AddAppPage> {
items: [ items: [
[ [
GeneratedFormItem( GeneratedFormItem(
label: "App Source Url", label: 'App Source Url',
additionalValidators: [ additionalValidators: [
(value) { (value) {
try { try {
sourceProvider sourceProvider
.getSource(value ?? "") .getSource(value ?? '')
.standardizeURL( .standardizeURL(
makeUrlHttps( makeUrlHttps(
value ?? "")); value ?? ''));
} catch (e) { } catch (e) {
return e is String return e is String
? e ? e
: "Error"; : 'Error';
} }
return null; return null;
} }
@@ -113,7 +113,8 @@ class _AddAppPageState extends State<AddAppPage> {
settingsProvider settingsProvider
.getInstallPermission() .getInstallPermission()
.then((_) { .then((_) {
appsProvider.saveApp(app).then((_) { appsProvider
.saveApps([app]).then((_) {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(

View File

@@ -126,8 +126,8 @@ class _AppPageState extends State<AppPage> {
.installedVersion = .installedVersion =
updatedApp updatedApp
.latestVersion; .latestVersion;
appsProvider.saveApp( appsProvider.saveApps(
updatedApp); [updatedApp]);
} }
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
@@ -167,8 +167,8 @@ class _AppPageState extends State<AppPage> {
updatedApp updatedApp
.installedVersion = .installedVersion =
null; null;
appsProvider.saveApp( appsProvider.saveApps(
updatedApp); [updatedApp]);
} }
Navigator.of(context) Navigator.of(context)
.pop(); .pop();
@@ -202,10 +202,11 @@ class _AppPageState extends State<AppPage> {
if (app != null && values != null) { if (app != null && values != null) {
var changedApp = app.app; var changedApp = app.app;
changedApp.additionalData = values; changedApp.additionalData = values;
appsProvider.saveApp(changedApp); appsProvider.saveApps([changedApp]);
} }
}); });
}, },
tooltip: 'Additional Options',
icon: const Icon(Icons.settings)), icon: const Icon(Icons.settings)),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
Expanded( Expanded(
@@ -247,9 +248,8 @@ class _AppPageState extends State<AppPage> {
onPressed: () { onPressed: () {
HapticFeedback HapticFeedback
.selectionClick(); .selectionClick();
appsProvider appsProvider.removeApps(
.removeApp(app!.app.id) [app!.app.id]).then((_) {
.then((_) {
int count = 0; int count = 0;
Navigator.of(context) Navigator.of(context)
.popUntil((_) => .popUntil((_) =>

View File

@@ -7,28 +7,67 @@ 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';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
class AppsPage extends StatefulWidget { class AppsPage extends StatefulWidget {
const AppsPage({super.key}); const AppsPage({super.key});
@override @override
State<AppsPage> createState() => _AppsPageState(); State<AppsPage> createState() => AppsPageState();
} }
class _AppsPageState extends State<AppsPage> { class AppsPageState extends State<AppsPage> {
AppsFilter? filter; AppsFilter? filter;
Set<String> selectedIds = {};
clearSelected() {
if (selectedIds.isNotEmpty) {
setState(() {
selectedIds.clear();
});
return true;
}
return false;
}
selectThese(List<String> appIds) {
if (selectedIds.isEmpty) {
setState(() {
for (var a in appIds) {
selectedIds.add(a);
}
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var appsProvider = context.watch<AppsProvider>(); var appsProvider = context.watch<AppsProvider>();
var settingsProvider = context.watch<SettingsProvider>(); var settingsProvider = context.watch<SettingsProvider>();
var existingUpdateAppIds = appsProvider.getExistingUpdates();
var sortedApps = appsProvider.apps.values.toList(); var sortedApps = appsProvider.apps.values.toList();
selectedIds = selectedIds
.where((element) => sortedApps.map((e) => e.app.id).contains(element))
.toSet();
toggleAppSelected(String appId) {
setState(() {
if (selectedIds.contains(appId)) {
selectedIds.remove(appId);
} else {
selectedIds.add(appId);
}
});
}
if (filter != null) { if (filter != null) {
sortedApps = sortedApps.where((app) { sortedApps = sortedApps.where((app) {
if (app.app.installedVersion == app.app.latestVersion && if (app.app.installedVersion == app.app.latestVersion &&
filter!.onlyNonLatest) { !(filter!.includeUptodate)) {
return false;
}
if (app.app.installedVersion == null &&
!(filter!.includeNonInstalled)) {
return false; return false;
} }
if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) { if (filter!.nameFilter.isEmpty && filter!.authorFilter.isEmpty) {
@@ -74,136 +113,307 @@ class _AppsPageState extends State<AppsPage> {
} }
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
floatingActionButton: body: RefreshIndicator(
Row(mainAxisAlignment: MainAxisAlignment.end, children: [ onRefresh: () {
existingUpdateAppIds.isEmpty || filter != null HapticFeedback.lightImpact();
? const SizedBox() return appsProvider.checkUpdates().catchError((e) {
: ElevatedButton.icon( ScaffoldMessenger.of(context).showSnackBar(
onPressed: appsProvider.areDownloadsRunning() SnackBar(content: Text(e.toString())),
? null );
: () { });
HapticFeedback.heavyImpact(); },
settingsProvider.getInstallPermission().then((_) { child: CustomScrollView(slivers: <Widget>[
appsProvider.downloadAndInstallLatestApp( const CustomAppBar(title: 'Apps'),
existingUpdateAppIds, context); if (appsProvider.loadingApps || sortedApps.isEmpty)
}); SliverFillRemaining(
}, child: Center(
icon: const Icon(Icons.install_mobile_outlined), child: appsProvider.loadingApps
label: const Text('Install All')), ? const CircularProgressIndicator()
const SizedBox( : Text(
width: 16, appsProvider.apps.isEmpty
), ? 'No Apps'
appsProvider.apps.isEmpty : 'No Search Results',
? const SizedBox() style: Theme.of(context).textTheme.headlineMedium,
: ElevatedButton.icon( ))),
onPressed: () { SliverList(
showDialog<List<String>?>( delegate: SliverChildBuilderDelegate(
context: context, (BuildContext context, int index) {
builder: (BuildContext ctx) { return ListTile(
return GeneratedFormModal( selectedTileColor:
title: 'Filter Apps', Theme.of(context).colorScheme.primary.withOpacity(0.1),
items: [ selected: selectedIds.contains(sortedApps[index].app.id),
[ onLongPress: () {
GeneratedFormItem( toggleAppSelected(sortedApps[index].app.id);
label: "App Name", required: false), },
GeneratedFormItem( title: Text(sortedApps[index].app.name),
label: "Author", required: false) subtitle: Text('By ${sortedApps[index].app.author}'),
], trailing: sortedApps[index].downloadProgress != null
[ ? Text(
GeneratedFormItem( 'Downloading - ${sortedApps[index].downloadProgress?.toInt()}%')
label: "Ignore Up-to-Date Apps", : (sortedApps[index].app.installedVersion != null &&
type: FormItemType.bool) sortedApps[index].app.installedVersion !=
] sortedApps[index].app.latestVersion
], ? const Text('Update Available')
defaultValues: filter == null : Text(sortedApps[index].app.installedVersion ??
? [] 'Not Installed')),
: [ onTap: () {
filter!.nameFilter, if (selectedIds.isNotEmpty) {
filter!.authorFilter, toggleAppSelected(sortedApps[index].app.id);
filter!.onlyNonLatest ? 'true' : '' } else {
]);
}).then((values) {
if (values != null &&
values
.where((element) => element.isNotEmpty)
.isNotEmpty) {
setState(() {
filter = AppsFilter(
nameFilter: values[0],
authorFilter: values[1],
onlyNonLatest: values[2] == "true");
});
} else {
setState(() {
filter = null;
});
}
});
},
label: Text(filter == null ? 'Search' : 'Modify Search'),
icon: Icon(
filter == null ? Icons.search : Icons.manage_search)),
]),
body: RefreshIndicator(
onRefresh: () {
HapticFeedback.lightImpact();
return appsProvider.checkUpdates().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
});
},
child: CustomScrollView(slivers: <Widget>[
const CustomAppBar(title: 'Apps'),
if (appsProvider.loadingApps || sortedApps.isEmpty)
SliverFillRemaining(
child: Center(
child: appsProvider.loadingApps
? const CircularProgressIndicator()
: Text(
appsProvider.apps.isEmpty
? 'No Apps'
: 'No Search Results',
style:
Theme.of(context).textTheme.headlineMedium,
))),
SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return ListTile(
title: Text(sortedApps[index].app.name),
subtitle: Text('By ${sortedApps[index].app.author}'),
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')
: Text(sortedApps[index].app.installedVersion ??
'Not Installed')),
onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
AppPage(appId: sortedApps[index].app.id)), AppPage(appId: sortedApps[index].app.id)),
); );
}, }
); },
}, childCount: sortedApps.length)) );
]))); }, childCount: sortedApps.length))
])),
persistentFooterButtons: [
Row(
children: [
TextButton.icon(
onPressed: () {
selectedIds.isEmpty
? selectThese(sortedApps.map((e) => e.app.id).toList())
: clearSelected();
},
icon: Icon(selectedIds.isEmpty
? Icons.select_all_outlined
: Icons.deselect_outlined),
label: Text(selectedIds.isEmpty
? 'Select All'
: 'Deselect ${selectedIds.length.toString()}')),
const VerticalDivider(),
Expanded(
child: selectedIds.isEmpty
? Container()
: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Remove Selected Apps?',
items: const [],
defaultValues: const [],
initValid: true,
message:
'${selectedIds.length} App${selectedIds.length == 1 ? '' : 's'} will be removed from Obtainium but remain installed. You still need to uninstall ${selectedIds.length == 1 ? 'it' : 'them'} manually.',
);
}).then((values) {
if (values != null) {
appsProvider.removeApps(selectedIds.toList());
}
});
},
tooltip: 'Remove Selected Apps',
icon: const Icon(Icons.delete_outline_outlined),
),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: appsProvider.areDownloadsRunning() ||
selectedIds
.where((id) =>
appsProvider.apps[id]!.app
.installedVersion !=
appsProvider
.apps[id]!.app.latestVersion)
.isEmpty
? null
: () {
HapticFeedback.heavyImpact();
var existingUpdateIdsSelected =
appsProvider
.getExistingUpdates(
installedOnly: true)
.where((element) =>
selectedIds.contains(element))
.toList();
var newInstallIdsSelected = appsProvider
.getExistingUpdates(
nonInstalledOnly: true)
.where((element) =>
selectedIds.contains(element))
.toList();
List<List<GeneratedFormItem>> formInputs =
[];
if (existingUpdateIdsSelected
.isNotEmpty &&
newInstallIdsSelected.isNotEmpty) {
formInputs.add([
GeneratedFormItem(
label:
'Update ${existingUpdateIdsSelected.length} Apps?',
type: FormItemType.bool)
]);
formInputs.add([
GeneratedFormItem(
label:
'Install ${newInstallIdsSelected.length} new Apps?',
type: FormItemType.bool)
]);
}
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Install Selected Apps?',
message:
'${existingUpdateIdsSelected.length} update${existingUpdateIdsSelected.length == 1 ? '' : 's'} and ${newInstallIdsSelected.length} new install${newInstallIdsSelected.length == 1 ? '' : 's'}.',
items: formInputs,
defaultValues: const [
'true',
'true'
],
initValid: true,
);
}).then((values) {
if (values != null) {
bool shouldInstallUpdates =
values.length < 2 ||
values[0] == 'true';
bool shouldInstallNew =
values.length < 2 ||
values[1] == 'true';
settingsProvider
.getInstallPermission()
.then((_) {
List<String> toInstall = [];
if (shouldInstallUpdates) {
toInstall.addAll(
existingUpdateIdsSelected);
}
if (shouldInstallNew) {
toInstall.addAll(
newInstallIdsSelected);
}
appsProvider
.downloadAndInstallLatestApp(
toInstall, context);
});
}
});
},
tooltip: 'Install/Update Selected Apps',
icon: const Icon(
Icons.file_download_outlined,
)),
IconButton(
visualDensity: VisualDensity.compact,
onPressed: () {
String urls = '';
for (var id in selectedIds) {
urls += '${appsProvider.apps[id]!.app.url}\n';
}
urls = urls.substring(0, urls.length - 1);
Share.share(urls,
subject: 'Selected App URLs from Obtainium');
},
tooltip: 'Share Selected App URLs',
icon: const Icon(Icons.share),
),
],
)),
const VerticalDivider(),
appsProvider.apps.isEmpty
? const SizedBox()
: TextButton.icon(
label: Text(
filter == null ? 'Filter' : 'Filter *',
style: TextStyle(
fontWeight: filter == null
? FontWeight.normal
: FontWeight.bold),
),
onPressed: () {
showDialog<List<String>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: 'Filter Apps',
items: [
[
GeneratedFormItem(
label: 'App Name', required: false),
GeneratedFormItem(
label: 'Author', required: false)
],
[
GeneratedFormItem(
label: 'Up to Date Apps',
type: FormItemType.bool)
],
[
GeneratedFormItem(
label: 'Non-Installed Apps',
type: FormItemType.bool)
]
],
defaultValues: filter == null
? AppsFilter().toValuesArray()
: filter!.toValuesArray());
}).then((values) {
if (values != null) {
setState(() {
filter = AppsFilter.fromValuesArray(values);
if (AppsFilter().isIdenticalTo(filter!)) {
filter = null;
}
});
} else {
setState(() {
filter = null;
});
}
});
},
icon: const Icon(Icons.filter_list_rounded))
],
),
],
);
} }
} }
class AppsFilter { class AppsFilter {
late String nameFilter; late String nameFilter;
late String authorFilter; late String authorFilter;
late bool onlyNonLatest; late bool includeUptodate;
late bool includeNonInstalled;
AppsFilter( AppsFilter(
{this.nameFilter = "", {this.nameFilter = '',
this.authorFilter = "", this.authorFilter = '',
this.onlyNonLatest = false}); this.includeUptodate = true,
this.includeNonInstalled = true});
List<String> toValuesArray() {
return [
nameFilter,
authorFilter,
includeUptodate ? 'true' : '',
includeNonInstalled ? 'true' : ''
];
}
AppsFilter.fromValuesArray(List<String> values) {
nameFilter = values[0];
authorFilter = values[1];
includeUptodate = values[2] == 'true';
includeNonInstalled = values[3] == 'true';
}
bool isIdenticalTo(AppsFilter other) =>
authorFilter.trim() == other.authorFilter.trim() &&
nameFilter.trim() == other.nameFilter.trim() &&
includeUptodate == other.includeUptodate &&
includeNonInstalled == other.includeNonInstalled;
} }

View File

@@ -25,7 +25,8 @@ class _HomePageState extends State<HomePage> {
List<int> selectedIndexHistory = []; List<int> selectedIndexHistory = [];
List<NavigationPageItem> pages = [ List<NavigationPageItem> pages = [
NavigationPageItem('Apps', Icons.apps, const AppsPage()), NavigationPageItem(
'Apps', Icons.apps, AppsPage(key: GlobalKey<AppsPageState>())),
NavigationPageItem('Add App', Icons.add, const AddAppPage()), NavigationPageItem('Add App', Icons.add, const AddAppPage()),
NavigationPageItem( NavigationPageItem(
'Import/Export', Icons.import_export, const ImportExportPage()), 'Import/Export', Icons.import_export, const ImportExportPage()),
@@ -88,7 +89,10 @@ class _HomePageState extends State<HomePage> {
}); });
return false; return false;
} }
return true; return !(pages[0].widget.key as GlobalKey<AppsPageState>)
.currentState
?.clearSelected();
// return !appsPageKey.currentState?.clearSelected();
}); });
} }
} }

View File

@@ -47,7 +47,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
if (appsProvider.apps.containsKey(app.id)) { if (appsProvider.apps.containsKey(app.id)) {
errorsMap.addAll({app.id: 'App already added'}); errorsMap.addAll({app.id: 'App already added'});
} else { } else {
await appsProvider.saveApp(app); await appsProvider.saveApps([app]);
} }
} }
List<List<String>> errors = List<List<String>> errors =

View File

@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:obtainium/components/generated_form.dart';
class TestPage extends StatefulWidget {
const TestPage({super.key});
@override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
List<String?>? sourceSpecificData;
bool valid = false;
List<List<GeneratedFormItem>> sourceSpecificInputs = [
[GeneratedFormItem(label: 'Test Item 1')],
[
GeneratedFormItem(label: 'Test Item 2', required: false),
GeneratedFormItem(label: 'Test Item 3')
],
[GeneratedFormItem(label: 'Test Item 4', type: FormItemType.bool)]
];
List<String> defaultInputValues = ["ABC"];
void onSourceSpecificDataChanges(
List<String?> valuesFromForm, bool formValid) {
setState(() {
sourceSpecificData = valuesFromForm;
valid = formValid;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Test Page')),
backgroundColor: Theme.of(context).colorScheme.surface,
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: [
GeneratedForm(
items: sourceSpecificInputs,
onValueChanges: onSourceSpecificDataChanges,
defaultValues: defaultInputValues,
),
...(sourceSpecificData != null
? (sourceSpecificData as List<String?>)
.map((e) => Text(e ?? ""))
: [Container()])
])));
}
}

View File

@@ -98,24 +98,24 @@ class AppsProvider with ChangeNotifier {
.isNotEmpty; .isNotEmpty;
Future<bool> canInstallSilently(App app) async { Future<bool> canInstallSilently(App app) async {
// TODO: This is unreliable - try to get from OS // TODO: This is unreliable - try to get from OS in the future
var osInfo = await DeviceInfoPlugin().androidInfo; var osInfo = await DeviceInfoPlugin().androidInfo;
return app.installedVersion != null && return app.installedVersion != null &&
osInfo.version.sdkInt! >= 30 && osInfo.version.sdkInt! >= 30 &&
osInfo.version.release!.compareTo('12') >= 0; osInfo.version.release!.compareTo('12') >= 0;
} }
Future<void> askUserToReturnToForeground(BuildContext context) async { Future<void> askUserToReturnToForeground(BuildContext context,
{bool waitForFG = false}) async {
NotificationsProvider notificationsProvider = NotificationsProvider notificationsProvider =
context.read<NotificationsProvider>(); context.read<NotificationsProvider>();
if (!isForeground) { if (!isForeground) {
await notificationsProvider.notify(completeInstallationNotification, await notificationsProvider.notify(completeInstallationNotification,
cancelExisting: true); cancelExisting: true);
await FGBGEvents.stream.first == FGBGType.foreground; if (waitForFG) {
await notificationsProvider.cancel(completeInstallationNotification.id); await FGBGEvents.stream.first == FGBGType.foreground;
// We need to wait for the App to come to the foreground to install it await notificationsProvider.cancel(completeInstallationNotification.id);
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of: }
// https://github.com/flutter/flutter/issues/13937
} }
} }
@@ -127,7 +127,7 @@ class AppsProvider with ChangeNotifier {
await AppInstaller.installApk(file.file.path, actionRequired: false); await AppInstaller.installApk(file.file.path, actionRequired: false);
apps[file.appId]!.app.installedVersion = apps[file.appId]!.app.installedVersion =
apps[file.appId]!.app.latestVersion; apps[file.appId]!.app.latestVersion;
await saveApp(apps[file.appId]!.app); await saveApps([apps[file.appId]!.app]);
} }
// Given a list of AppIds, uses stored info about the apps to download APKs and install them // Given a list of AppIds, uses stored info about the apps to download APKs and install them
@@ -146,8 +146,6 @@ class AppsProvider with ChangeNotifier {
// If the App has more than one APK, the user should pick one (if context provided) // If the App has more than one APK, the user should pick one (if context provided)
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex]; String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
if (apps[id]!.app.apkUrls.length > 1 && context != null) { if (apps[id]!.app.apkUrls.length > 1 && context != null) {
// ignore: use_build_context_synchronously
await askUserToReturnToForeground(context);
apkUrl = await showDialog( apkUrl = await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@@ -158,8 +156,6 @@ class AppsProvider with ChangeNotifier {
if (apkUrl != null && if (apkUrl != null &&
Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin && Uri.parse(apkUrl).origin != Uri.parse(apps[id]!.app.url).origin &&
context != null) { context != null) {
// ignore: use_build_context_synchronously
await askUserToReturnToForeground(context);
if (await showDialog( if (await showDialog(
context: context, context: context,
builder: (BuildContext ctx) { builder: (BuildContext ctx) {
@@ -174,7 +170,7 @@ class AppsProvider with ChangeNotifier {
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl); int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
if (urlInd != apps[id]!.app.preferredApkIndex) { if (urlInd != apps[id]!.app.preferredApkIndex) {
apps[id]!.app.preferredApkIndex = urlInd; apps[id]!.app.preferredApkIndex = urlInd;
await saveApp(apps[id]!.app); await saveApps([apps[id]!.app]);
} }
if (context != null || if (context != null ||
(await canInstallSilently(apps[id]!.app) && (await canInstallSilently(apps[id]!.app) &&
@@ -203,9 +199,11 @@ class AppsProvider with ChangeNotifier {
} }
if (context != null) { if (context != null) {
for (var i in regularInstalls) { if (regularInstalls.isNotEmpty) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await askUserToReturnToForeground(context); await askUserToReturnToForeground(context);
}
for (var i in regularInstalls) {
await installApk(i); await installApk(i);
} }
} }
@@ -248,23 +246,30 @@ class AppsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> saveApp(App app) async { Future<void> saveApps(List<App> apps) async {
File('${(await getAppsDir()).path}/${app.id}.json') for (var app in apps) {
.writeAsStringSync(jsonEncode(app.toJson())); File('${(await getAppsDir()).path}/${app.id}.json')
apps.update(app.id, (value) => AppInMemory(app, value.downloadProgress), .writeAsStringSync(jsonEncode(app.toJson()));
ifAbsent: () => AppInMemory(app, null)); this.apps.update(
app.id, (value) => AppInMemory(app, value.downloadProgress),
ifAbsent: () => AppInMemory(app, null));
}
notifyListeners(); notifyListeners();
} }
Future<void> removeApp(String appId) async { Future<void> removeApps(List<String> appIds) async {
File file = File('${(await getAppsDir()).path}/$appId.json'); for (var appId in appIds) {
if (file.existsSync()) { File file = File('${(await getAppsDir()).path}/$appId.json');
file.deleteSync(); if (file.existsSync()) {
file.deleteSync();
}
if (apps.containsKey(appId)) {
apps.remove(appId);
}
} }
if (apps.containsKey(appId)) { if (appIds.isNotEmpty) {
apps.remove(appId); notifyListeners();
} }
notifyListeners();
} }
bool checkAppObjectForUpdate(App app) { bool checkAppObjectForUpdate(App app) {
@@ -286,7 +291,7 @@ class AppsProvider with ChangeNotifier {
if (currentApp.preferredApkIndex < newApp.apkUrls.length) { if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
newApp.preferredApkIndex = currentApp.preferredApkIndex; newApp.preferredApkIndex = currentApp.preferredApkIndex;
} }
await saveApp(newApp); await saveApps([newApp]);
return newApp; return newApp;
} }
return null; return null;
@@ -309,14 +314,20 @@ class AppsProvider with ChangeNotifier {
return updates; return updates;
} }
List<String> getExistingUpdates({bool installedOnly = false}) { List<String> getExistingUpdates(
{bool installedOnly = false, bool nonInstalledOnly = false}) {
List<String> updateAppIds = []; List<String> updateAppIds = [];
List<String> appIds = apps.keys.toList(); List<String> appIds = apps.keys.toList();
for (int i = 0; i < appIds.length; i++) { for (int i = 0; i < appIds.length; i++) {
App? app = apps[appIds[i]]!.app; App? app = apps[appIds[i]]!.app;
if (app.installedVersion != app.latestVersion && if (app.installedVersion != app.latestVersion &&
(app.installedVersion != null || !installedOnly)) { (!installedOnly || !nonInstalledOnly)) {
updateAppIds.add(app.id); if ((app.installedVersion == null &&
(nonInstalledOnly || !installedOnly) ||
(app.installedVersion != null &&
(installedOnly || !nonInstalledOnly)))) {
updateAppIds.add(app.id);
}
} }
} }
return updateAppIds; return updateAppIds;
@@ -344,7 +355,7 @@ class AppsProvider with ChangeNotifier {
for (App a in importedApps) { for (App a in importedApps) {
a.installedVersion = a.installedVersion =
apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null; apps.containsKey(a.id) ? apps[a]?.app.installedVersion : null;
await saveApp(a); await saveApps([a]);
} }
notifyListeners(); notifyListeners();
return importedApps.length; return importedApps.length;

View File

@@ -85,7 +85,7 @@ class App {
escapeRegEx(String s) { escapeRegEx(String s) {
return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) { return s.replaceAllMapped(RegExp(r'[.*+?^${}()|[\]\\]'), (x) {
return "\\${x[0]}"; return '\\${x[0]}';
}); });
} }

View File

@@ -324,6 +324,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.0" version: "1.8.0"
mime:
dependency: transitive
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -457,6 +464,48 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.3" version: "6.0.3"
share_plus:
dependency: "direct main"
description:
name: share_plus
url: "https://pub.dartlang.org"
source: hosted
version: "4.4.0"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.3"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:

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.3.2+18 # When changing this, update the tag in main() accordingly version: 0.4.0+19 # 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'
@@ -53,6 +53,7 @@ dependencies:
file_picker: ^5.1.0 file_picker: ^5.1.0
animations: ^2.0.4 animations: ^2.0.4
flutter_install_app: ^1.3.0 flutter_install_app: ^1.3.0
share_plus: ^4.4.0
dev_dependencies: dev_dependencies: