import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:obtainium/components/custom_app_bar.dart'; import 'package:obtainium/components/generated_form.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/custom_errors.dart'; import 'package:obtainium/main.dart'; import 'package:obtainium/pages/app.dart'; import 'package:obtainium/pages/settings.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:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; class AppsPage extends StatefulWidget { const AppsPage({super.key}); @override State createState() => AppsPageState(); } class AppsPageState extends State { AppsFilter filter = AppsFilter(); final AppsFilter neutralFilter = AppsFilter(); var updatesOnlyFilter = AppsFilter(includeUptodate: false, includeNonInstalled: false); Set selectedApps = {}; DateTime? refreshingSince; clearSelected() { if (selectedApps.isNotEmpty) { setState(() { selectedApps.clear(); }); return true; } return false; } selectThese(List apps) { if (selectedApps.isEmpty) { setState(() { for (var a in apps) { selectedApps.add(a); } }); } } @override Widget build(BuildContext context) { var appsProvider = context.watch(); var settingsProvider = context.watch(); var sortedApps = appsProvider.apps.values.toList(); var currentFilterIsUpdatesOnly = filter.isIdenticalTo(updatesOnlyFilter, settingsProvider); selectedApps = selectedApps .where((element) => sortedApps.map((e) => e.app).contains(element)) .toSet(); toggleAppSelected(App app) { setState(() { if (selectedApps.contains(app)) { selectedApps.remove(app); } else { selectedApps.add(app); } }); } sortedApps = sortedApps.where((app) { if (app.app.installedVersion == app.app.latestVersion && !(filter.includeUptodate)) { return false; } if (app.app.installedVersion == null && !(filter.includeNonInstalled)) { return false; } if (filter.nameFilter.isNotEmpty || filter.authorFilter.isNotEmpty) { List nameTokens = filter.nameFilter .split(' ') .where((element) => element.trim().isNotEmpty) .toList(); List authorTokens = filter.authorFilter .split(' ') .where((element) => element.trim().isNotEmpty) .toList(); for (var t in nameTokens) { var name = app.installedInfo?.name ?? app.app.name; if (!name.toLowerCase().contains(t.toLowerCase())) { return false; } } for (var t in authorTokens) { if (!app.app.author.toLowerCase().contains(t.toLowerCase())) { return false; } } } if (filter.categoryFilter.isNotEmpty && filter.categoryFilter .intersection(app.app.categories.toSet()) .isEmpty) { return false; } return true; }).toList(); sortedApps.sort((a, b) { var nameA = a.installedInfo?.name ?? a.app.name; var nameB = b.installedInfo?.name ?? b.app.name; int result = 0; if (settingsProvider.sortColumn == SortColumnSettings.authorName) { result = (a.app.author + nameA).compareTo(b.app.author + nameB); } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { result = (nameA + a.app.author).compareTo(nameB + b.app.author); } return result; }); if (settingsProvider.sortOrder == SortOrderSettings.descending) { sortedApps = sortedApps.reversed.toList(); } var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdateIdsAllOrSelected = existingUpdates .where((element) => selectedApps.isEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty : selectedApps.map((e) => e.id).contains(element)) .toList(); var newInstallIdsAllOrSelected = appsProvider .findExistingUpdates(nonInstalledOnly: true) .where((element) => selectedApps.isEmpty ? sortedApps.where((a) => a.app.id == element).isNotEmpty : selectedApps.map((e) => e.id).contains(element)) .toList(); List trackOnlyUpdateIdsAllOrSelected = []; existingUpdateIdsAllOrSelected = existingUpdateIdsAllOrSelected.where((id) { if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) { trackOnlyUpdateIdsAllOrSelected.add(id); return false; } return true; }).toList(); newInstallIdsAllOrSelected = newInstallIdsAllOrSelected.where((id) { if (appsProvider.apps[id]!.app.additionalSettings['trackOnly'] == true) { trackOnlyUpdateIdsAllOrSelected.add(id); return false; } return true; }).toList(); if (settingsProvider.pinUpdates) { var temp = []; sortedApps = sortedApps.where((sa) { if (existingUpdates.contains(sa.app.id)) { temp.add(sa); return false; } return true; }).toList(); sortedApps = [...temp, ...sortedApps]; } var tempPinned = []; var tempNotPinned = []; for (var a in sortedApps) { if (a.app.pinned) { tempPinned.add(a); } else { tempNotPinned.add(a); } } sortedApps = [...tempPinned, ...tempNotPinned]; return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: RefreshIndicator( onRefresh: () { HapticFeedback.lightImpact(); setState(() { refreshingSince = DateTime.now(); }); return appsProvider.checkUpdates().catchError((e) { showError(e, context); }).whenComplete(() { setState(() { refreshingSince = null; }); }); }, child: CustomScrollView(slivers: [ CustomAppBar(title: tr('appsString')), if (appsProvider.loadingApps || sortedApps.isEmpty) SliverFillRemaining( child: Center( child: appsProvider.loadingApps ? const CircularProgressIndicator() : Text( appsProvider.apps.isEmpty ? tr('noApps') : tr('noAppsForFilter'), style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ))), if (refreshingSince != null) SliverToBoxAdapter( child: LinearProgressIndicator( value: appsProvider.apps.values .where((element) => !(element.app.lastUpdateCheck ?.isBefore(refreshingSince!) ?? true)) .length / appsProvider.apps.length, ), ), SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { String? changesUrl = SourceProvider() .getSource(sortedApps[index].app.url) .changeLogPageFromStandardUrl(sortedApps[index].app.url); var transparent = const Color.fromARGB(0, 0, 0, 0).value; return Container( decoration: BoxDecoration( border: Border.symmetric( vertical: BorderSide( width: 4, color: Color( sortedApps[index].app.categories.isNotEmpty ? settingsProvider.categories[ sortedApps[index] .app .categories .first] ?? transparent : transparent)))), child: ListTile( tileColor: sortedApps[index].app.pinned ? Colors.grey.withOpacity(0.1) : Colors.transparent, selectedTileColor: Theme.of(context) .colorScheme .primary .withOpacity(sortedApps[index].app.pinned ? 0.2 : 0.1), selected: selectedApps.contains(sortedApps[index].app), onLongPress: () { toggleAppSelected(sortedApps[index].app); }, leading: sortedApps[index].installedInfo != null ? Image.memory( sortedApps[index].installedInfo!.icon!, gaplessPlayback: true, ) : null, title: Text( sortedApps[index].installedInfo?.name ?? sortedApps[index].app.name, style: TextStyle( fontWeight: sortedApps[index].app.pinned ? FontWeight.bold : FontWeight.normal, ), ), subtitle: Text( tr('byX', args: [sortedApps[index].app.author]), style: TextStyle( fontWeight: sortedApps[index].app.pinned ? FontWeight.bold : FontWeight.normal)), trailing: SingleChildScrollView( reverse: true, child: sortedApps[index].downloadProgress != null ? Text(tr('percentProgress', args: [ sortedApps[index] .downloadProgress ?.toInt() .toString() ?? '100' ])) : (Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ SizedBox( width: 100, child: Text( '${sortedApps[index].app.installedVersion ?? tr('notInstalled')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBrackets')}' : ''}', overflow: TextOverflow.fade, textAlign: TextAlign.end, )), sortedApps[index].app.installedVersion != null && sortedApps[index] .app .installedVersion != sortedApps[index] .app .latestVersion ? GestureDetector( onTap: changesUrl == null ? null : () { launchUrlString(changesUrl, mode: LaunchMode .externalApplication); }, child: appsProvider .areDownloadsRunning() ? Text(tr('pleaseWait')) : Text( '${tr('updateAvailable')}${sortedApps[index].app.additionalSettings['trackOnly'] == true ? ' ${tr('estimateInBracketsShort')}' : ''}', style: TextStyle( fontStyle: FontStyle.italic, decoration: changesUrl == null ? TextDecoration.none : TextDecoration .underline), )) : const SizedBox(), ], ))), onTap: () { if (selectedApps.isNotEmpty) { toggleAppSelected(sortedApps[index].app); } else { Navigator.push( context, MaterialPageRoute( builder: (context) => AppPage(appId: sortedApps[index].app.id)), ); } }, )); }, childCount: sortedApps.length)) ])), persistentFooterButtons: appsProvider.apps.isEmpty ? null : [ Row( children: [ selectedApps.isEmpty ? TextButton.icon( style: const ButtonStyle( visualDensity: VisualDensity.compact), onPressed: () { selectThese(sortedApps.map((e) => e.app).toList()); }, icon: Icon( Icons.select_all_outlined, color: Theme.of(context).colorScheme.primary, ), label: Text(sortedApps.length.toString())) : TextButton.icon( style: const ButtonStyle( visualDensity: VisualDensity.compact), onPressed: () { selectedApps.isEmpty ? selectThese( sortedApps.map((e) => e.app).toList()) : clearSelected(); }, icon: Icon( selectedApps.isEmpty ? Icons.select_all_outlined : Icons.deselect_outlined, color: Theme.of(context).colorScheme.primary, ), label: Text(selectedApps.length.toString())), const VerticalDivider(), Expanded( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( visualDensity: VisualDensity.compact, onPressed: selectedApps.isEmpty ? null : () { appsProvider.removeAppsWithModal( context, selectedApps.toList()); }, tooltip: tr('removeSelectedApps'), icon: const Icon(Icons.delete_outline_outlined), ), IconButton( visualDensity: VisualDensity.compact, onPressed: appsProvider .areDownloadsRunning() || (existingUpdateIdsAllOrSelected .isEmpty && newInstallIdsAllOrSelected .isEmpty && trackOnlyUpdateIdsAllOrSelected .isEmpty) ? null : () { HapticFeedback.heavyImpact(); List formItems = []; if (existingUpdateIdsAllOrSelected .isNotEmpty) { formItems.add(GeneratedFormSwitch( 'updates', label: tr('updateX', args: [ plural( 'apps', existingUpdateIdsAllOrSelected .length) ]), defaultValue: true)); } if (newInstallIdsAllOrSelected .isNotEmpty) { formItems.add(GeneratedFormSwitch( 'installs', label: tr('installX', args: [ plural( 'apps', newInstallIdsAllOrSelected .length) ]), defaultValue: existingUpdateIdsAllOrSelected .isNotEmpty)); } if (trackOnlyUpdateIdsAllOrSelected .isNotEmpty) { formItems.add(GeneratedFormSwitch( 'trackonlies', label: tr( 'markXTrackOnlyAsUpdated', args: [ plural( 'apps', trackOnlyUpdateIdsAllOrSelected .length) ]), defaultValue: existingUpdateIdsAllOrSelected .isNotEmpty || newInstallIdsAllOrSelected .isNotEmpty)); } showDialog?>( context: context, builder: (BuildContext ctx) { var totalApps = existingUpdateIdsAllOrSelected .length + newInstallIdsAllOrSelected .length + trackOnlyUpdateIdsAllOrSelected .length; return GeneratedFormModal( title: tr('changeX', args: [ plural('apps', totalApps) ]), items: formItems .map((e) => [e]) .toList(), initValid: true, ); }).then((values) { if (values != null) { if (values.isEmpty) { values = getDefaultValuesFromFormItems( [formItems]); } bool shouldInstallUpdates = values['updates'] == true; bool shouldInstallNew = values['installs'] == true; bool shouldMarkTrackOnlies = values['trackonlies'] == true; (() async { if (shouldInstallNew || shouldInstallUpdates) { await settingsProvider .getInstallPermission(); } })() .then((_) { List toInstall = []; if (shouldInstallUpdates) { toInstall.addAll( existingUpdateIdsAllOrSelected); } if (shouldInstallNew) { toInstall.addAll( newInstallIdsAllOrSelected); } if (shouldMarkTrackOnlies) { toInstall.addAll( trackOnlyUpdateIdsAllOrSelected); } appsProvider .downloadAndInstallLatestApps( toInstall, globalNavigatorKey .currentContext) .catchError((e) { showError(e, context); }); }); } }); }, tooltip: selectedApps.isEmpty ? tr('installUpdateApps') : tr('installUpdateSelectedApps'), icon: const Icon( Icons.file_download_outlined, )), IconButton( visualDensity: VisualDensity.compact, onPressed: selectedApps.isEmpty ? null : () async { try { Set? preselected; var showPrompt = false; for (var element in selectedApps) { var currentCats = element.categories.toSet(); if (preselected == null) { preselected = currentCats; } else { if (!settingsProvider.setEqual( currentCats, preselected)) { showPrompt = true; break; } } } var cont = true; if (showPrompt) { cont = await showDialog< Map?>( context: context, builder: (BuildContext ctx) { return GeneratedFormModal( title: tr('categorize'), items: const [], initValid: true, message: tr( 'selectedCategorizeWarning'), ); }) != null; } if (cont) { // ignore: use_build_context_synchronously await showDialog< Map?>( context: context, builder: (BuildContext ctx) { return GeneratedFormModal( title: tr('categorize'), items: const [], initValid: true, singleNullReturnButton: tr('continue'), additionalWidgets: [ CategoryEditorSelector( preselected: !showPrompt ? preselected ?? {} : {}, showLabelWhenNotEmpty: false, onSelected: (categories) { appsProvider.saveApps( selectedApps .map((e) { e.categories = categories; return e; }).toList()); }, ) ], ); }); } } catch (err) { showError(err, context); } }, tooltip: tr('categorize'), icon: const Icon(Icons.category_outlined), ), IconButton( visualDensity: VisualDensity.compact, onPressed: selectedApps.isEmpty ? null : () { showDialog( context: context, builder: (BuildContext ctx) { return AlertDialog( scrollable: true, content: Padding( padding: const EdgeInsets.only( top: 6), child: Row( mainAxisAlignment: MainAxisAlignment .spaceAround, children: [ IconButton( onPressed: appsProvider .areDownloadsRunning() ? null : () { showDialog( context: context, builder: (BuildContext ctx) { return AlertDialog( title: Text(tr('markXSelectedAppsAsUpdated', args: [ selectedApps.length.toString() ])), content: Text( tr('onlyWorksWithNonVersionDetectApps'), style: const TextStyle(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic), ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text(tr('no'))), TextButton( onPressed: () { HapticFeedback.selectionClick(); appsProvider.saveApps(selectedApps.map((a) { if (a.installedVersion != null && a.additionalSettings['noVersionDetection'] == true) { a.installedVersion = a.latestVersion; } return a; }).toList()); Navigator.of(context).pop(); }, child: Text(tr('yes'))) ], ); }).whenComplete(() { Navigator.of( context) .pop(); }); }, tooltip: tr( 'markSelectedAppsUpdated'), icon: const Icon( Icons.done)), IconButton( onPressed: () { var pinStatus = selectedApps .where((element) => element .pinned) .isEmpty; appsProvider .saveApps( selectedApps .map( (e) { e.pinned = pinStatus; return e; }).toList()); Navigator.of( context) .pop(); }, tooltip: selectedApps .where((element) => element .pinned) .isEmpty ? tr('pinToTop') : tr( 'unpinFromTop'), icon: Icon(selectedApps .where((element) => element .pinned) .isEmpty ? Icons .bookmark_outline_rounded : Icons .bookmark_remove_outlined), ), IconButton( onPressed: () { String urls = ''; for (var a in selectedApps) { urls += '${a.url}\n'; } urls = urls.substring( 0, urls.length - 1); Share.share(urls, subject: tr( 'selectedAppURLsFromObtainium')); Navigator.of( context) .pop(); }, tooltip: tr( 'shareSelectedAppURLs'), icon: const Icon( Icons.share), ), IconButton( onPressed: () { showDialog( context: context, builder: (BuildContext ctx) { return GeneratedFormModal( title: tr( 'resetInstallStatusForSelectedAppsQuestion'), items: const [], initValid: true, message: tr( 'installStatusOfXWillBeResetExplanation', args: [ plural( 'app', selectedApps.length) ]), ); }).then((values) { if (values != null) { appsProvider.saveApps( selectedApps .map( (e) { e.installedVersion = null; return e; }).toList()); } }).whenComplete(() { Navigator.of( context) .pop(); }); }, tooltip: tr( 'resetInstallStatus'), icon: const Icon(Icons .restore_page_outlined), ), ]), ), ); }); }, tooltip: tr('more'), icon: const Icon(Icons.more_horiz), ), ], ))), const VerticalDivider(), IconButton( visualDensity: VisualDensity.compact, onPressed: () { setState(() { if (currentFilterIsUpdatesOnly) { filter = AppsFilter(); } else { filter = updatesOnlyFilter; } }); }, tooltip: currentFilterIsUpdatesOnly ? tr('removeOutdatedFilter') : tr('showOutdatedOnly'), icon: Icon( currentFilterIsUpdatesOnly ? Icons.update_disabled_rounded : Icons.update_rounded, color: Theme.of(context).colorScheme.primary, ), ), TextButton.icon( style: const ButtonStyle( visualDensity: VisualDensity.compact), label: Text( filter.isIdenticalTo(neutralFilter, settingsProvider) ? tr('filter') : tr('filterActive'), style: TextStyle( fontWeight: filter.isIdenticalTo( neutralFilter, settingsProvider) ? FontWeight.normal : FontWeight.bold), ), onPressed: () { showDialog?>( context: context, builder: (BuildContext ctx) { var vals = filter.toFormValuesMap(); return GeneratedFormModal( initValid: true, title: tr('filterApps'), items: [ [ GeneratedFormTextField('appName', label: tr('appName'), required: false, defaultValue: vals['appName']), GeneratedFormTextField('author', label: tr('author'), required: false, defaultValue: vals['author']) ], [ GeneratedFormSwitch('upToDateApps', label: tr('upToDateApps'), defaultValue: vals['upToDateApps']) ], [ GeneratedFormSwitch('nonInstalledApps', label: tr('nonInstalledApps'), defaultValue: vals['nonInstalledApps']) ] ], additionalWidgets: [ const SizedBox( height: 16, ), CategoryEditorSelector( preselected: filter.categoryFilter, onSelected: (categories) { filter.categoryFilter = categories.toSet(); }, ) ], ); }).then((values) { if (values != null) { setState(() { filter.setFormValuesFromMap(values); }); } }); }, icon: const Icon(Icons.filter_list_rounded)) ], ), ], ); } } class AppsFilter { late String nameFilter; late String authorFilter; late bool includeUptodate; late bool includeNonInstalled; late Set categoryFilter; AppsFilter( {this.nameFilter = '', this.authorFilter = '', this.includeUptodate = true, this.includeNonInstalled = true, this.categoryFilter = const {}}); Map toFormValuesMap() { return { 'appName': nameFilter, 'author': authorFilter, 'upToDateApps': includeUptodate, 'nonInstalledApps': includeNonInstalled }; } setFormValuesFromMap(Map values) { nameFilter = values['appName']!; authorFilter = values['author']!; includeUptodate = values['upToDateApps']; includeNonInstalled = values['nonInstalledApps']; } bool isIdenticalTo(AppsFilter other, SettingsProvider settingsProvider) => authorFilter.trim() == other.authorFilter.trim() && nameFilter.trim() == other.nameFilter.trim() && includeUptodate == other.includeUptodate && includeNonInstalled == other.includeNonInstalled && settingsProvider.setEqual(categoryFilter, other.categoryFilter); }