import 'dart:convert'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.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'; import 'package:markdown/markdown.dart' as md; class AppsPage extends StatefulWidget { const AppsPage({super.key}); @override State createState() => AppsPageState(); } void showChangeLogDialog( BuildContext context, App app, String? changesUrl, AppSource appSource, String changeLog, ) { showDialog( context: context, builder: (BuildContext context) { return GeneratedFormModal( title: tr('changes'), items: const [], message: app.latestVersion, additionalWidgets: [ changesUrl != null ? GestureDetector( child: Text( changesUrl, style: const TextStyle( decoration: TextDecoration.underline, fontStyle: FontStyle.italic, ), ), onTap: () { launchUrlString( changesUrl, mode: LaunchMode.externalApplication, ); }, ) : const SizedBox.shrink(), changesUrl != null ? const SizedBox(height: 16) : const SizedBox.shrink(), appSource.changeLogIfAnyIsMarkDown ? SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height - 350, child: Markdown( styleSheet: MarkdownStyleSheet( blockquoteDecoration: BoxDecoration( color: Theme.of(context).cardColor, ), ), data: changeLog, onTapLink: (text, href, title) { if (href != null) { launchUrlString( href.startsWith('http://') || href.startsWith('https://') ? href : '${Uri.parse(app.url).origin}/$href', mode: LaunchMode.externalApplication, ); } }, extensionSet: md.ExtensionSet( md.ExtensionSet.gitHubFlavored.blockSyntaxes, [ md.EmojiSyntax(), ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes, ], ), ), ) : Text(changeLog), ], singleNullReturnButton: tr('ok'), ); }, ); } Null Function()? getChangeLogFn(BuildContext context, App app) { AppSource appSource = SourceProvider().getSource( app.url, overrideSource: app.overrideSource, ); String? changesUrl = appSource.changeLogPageFromStandardUrl(app.url); String? changeLog = app.changeLog; if (changeLog?.split('\n').length == 1) { if (RegExp( '(http|ftp|https)://([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?', ).hasMatch(changeLog!)) { if (changesUrl == null) { changesUrl = changeLog; changeLog = null; } } } return (changeLog == null && changesUrl == null) ? null : () { if (changeLog != null) { showChangeLogDialog(context, app, changesUrl, appSource, changeLog); } else { launchUrlString(changesUrl!, mode: LaunchMode.externalApplication); } }; } class AppsPageState extends State { AppsFilter filter = AppsFilter(); final AppsFilter neutralFilter = AppsFilter(); var updatesOnlyFilter = AppsFilter( includeUptodate: false, includeNonInstalled: false, ); Set selectedAppIds = {}; DateTime? refreshingSince; bool clearSelected() { if (selectedAppIds.isNotEmpty) { setState(() { selectedAppIds.clear(); }); return true; } return false; } void selectThese(List apps) { if (selectedAppIds.isEmpty) { setState(() { for (var a in apps) { selectedAppIds.add(a.id); } }); } } final GlobalKey _refreshIndicatorKey = GlobalKey(); late final ScrollController scrollController = ScrollController(); var sourceProvider = SourceProvider(); @override Widget build(BuildContext context) { var appsProvider = context.watch(); var settingsProvider = context.watch(); var listedApps = appsProvider.getAppValues().toList(); refresh() { HapticFeedback.lightImpact(); setState(() { refreshingSince = DateTime.now(); }); return appsProvider .checkUpdates() .catchError((e) { showError(e is Map ? e['errors'] : e, context); return []; }) .whenComplete(() { setState(() { refreshingSince = null; }); }); } if (!appsProvider.loadingApps && appsProvider.apps.isNotEmpty && settingsProvider.checkJustStarted() && settingsProvider.checkOnStart) { _refreshIndicatorKey.currentState?.show(); } selectedAppIds = selectedAppIds .where((element) => listedApps.map((e) => e.app.id).contains(element)) .toSet(); toggleAppSelected(App app) { setState(() { if (selectedAppIds.map((e) => e).contains(app.id)) { selectedAppIds.removeWhere((a) => a == app.id); } else { selectedAppIds.add(app.id); } }); } listedApps = listedApps.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) { if (!app.name.toLowerCase().contains(t.toLowerCase())) { return false; } } for (var t in authorTokens) { if (!app.author.toLowerCase().contains(t.toLowerCase())) { return false; } } } if (filter.idFilter.isNotEmpty) { if (!app.app.id.contains(filter.idFilter)) { return false; } } if (filter.categoryFilter.isNotEmpty && filter.categoryFilter .intersection(app.app.categories.toSet()) .isEmpty) { return false; } if (filter.sourceFilter.isNotEmpty && sourceProvider .getSource( app.app.url, overrideSource: app.app.overrideSource, ) .runtimeType .toString() != filter.sourceFilter) { return false; } return true; }).toList(); listedApps.sort((a, b) { int result = 0; if (settingsProvider.sortColumn == SortColumnSettings.authorName) { result = ((a.author + a.name).toLowerCase()).compareTo( (b.author + b.name).toLowerCase(), ); } else if (settingsProvider.sortColumn == SortColumnSettings.nameAuthor) { result = ((a.name + a.author).toLowerCase()).compareTo( (b.name + b.author).toLowerCase(), ); } else if (settingsProvider.sortColumn == SortColumnSettings.releaseDate) { result = (a.app.releaseDate)?.compareTo( b.app.releaseDate ?? DateTime.fromMicrosecondsSinceEpoch(0), ) ?? 0; } return result; }); if (settingsProvider.sortOrder == SortOrderSettings.descending) { listedApps = listedApps.reversed.toList(); } var existingUpdates = appsProvider.findExistingUpdates(installedOnly: true); var existingUpdateIdsAllOrSelected = existingUpdates .where( (element) => selectedAppIds.isEmpty ? listedApps.where((a) => a.app.id == element).isNotEmpty : selectedAppIds.map((e) => e).contains(element), ) .toList(); var newInstallIdsAllOrSelected = appsProvider .findExistingUpdates(nonInstalledOnly: true) .where( (element) => selectedAppIds.isEmpty ? listedApps.where((a) => a.app.id == element).isNotEmpty : selectedAppIds.map((e) => e).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 = []; listedApps = listedApps.where((sa) { if (existingUpdates.contains(sa.app.id)) { temp.add(sa); return false; } return true; }).toList(); listedApps = [...temp, ...listedApps]; } if (settingsProvider.buryNonInstalled) { var temp = []; listedApps = listedApps.where((sa) { if (sa.app.installedVersion == null) { temp.add(sa); return false; } return true; }).toList(); listedApps = [...listedApps, ...temp]; } var tempPinned = []; var tempNotPinned = []; for (var a in listedApps) { if (a.app.pinned) { tempPinned.add(a); } else { tempNotPinned.add(a); } } listedApps = [...tempPinned, ...tempNotPinned]; List getListedCategories() { var temp = listedApps.map( (e) => e.app.categories.isNotEmpty ? e.app.categories : [null], ); return temp.isNotEmpty ? { ...temp.reduce((v, e) => [...v, ...e]), }.toList() : []; } var listedCategories = getListedCategories(); listedCategories.sort((a, b) { return a != null && b != null ? a.toLowerCase().compareTo(b.toLowerCase()) : a == null ? 1 : -1; }); Set selectedApps = listedApps .map((e) => e.app) .where((a) => selectedAppIds.contains(a.id)) .toSet(); getLoadingWidgets() { return [ if (listedApps.isEmpty) SliverFillRemaining( child: Center( child: Text( appsProvider.apps.isEmpty ? appsProvider.loadingApps ? tr('pleaseWait') : tr('noApps') : tr('noAppsForFilter'), style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ), ), ), if (refreshingSince != null || appsProvider.loadingApps) SliverToBoxAdapter( child: LinearProgressIndicator( value: appsProvider.loadingApps ? null : appsProvider .getAppValues() .where( (element) => !(element.app.lastUpdateCheck?.isBefore( refreshingSince!, ) ?? true), ) .length / (appsProvider.apps.isNotEmpty ? appsProvider.apps.length : 1), ), ), ]; } getUpdateButton(int appIndex) { return IconButton( visualDensity: VisualDensity.compact, color: Theme.of(context).colorScheme.primary, tooltip: listedApps[appIndex].app.additionalSettings['trackOnly'] == true ? tr('markUpdated') : tr('update'), onPressed: appsProvider.areDownloadsRunning() ? null : () { appsProvider .downloadAndInstallLatestApps([ listedApps[appIndex].app.id, ], globalNavigatorKey.currentContext) .catchError((e) { showError(e, context); return []; }); }, icon: Icon( listedApps[appIndex].app.additionalSettings['trackOnly'] == true ? Icons.check_circle_outline : Icons.install_mobile, ), ); } getAppIcon(int appIndex) { return FutureBuilder( future: appsProvider.updateAppIcon(listedApps[appIndex].app.id), builder: (ctx, val) { return listedApps[appIndex].icon != null ? Image.memory( listedApps[appIndex].icon!, gaplessPlayback: true, opacity: AlwaysStoppedAnimation( listedApps[appIndex].installedInfo == null ? 0.6 : 1, ), ) : Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Transform( alignment: Alignment.center, transform: Matrix4.rotationZ(0.31), child: Padding( padding: const EdgeInsets.all(15), child: Image( image: const AssetImage( 'assets/graphics/icon_small.png', ), color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(0.4) : Colors.white.withOpacity(0.3), colorBlendMode: BlendMode.modulate, gaplessPlayback: true, ), ), ), ], ); }, ); } getVersionText(int appIndex) { return listedApps[appIndex].app.installedVersion ?? tr('notInstalled'); } getChangesButtonString(int appIndex, bool hasChangeLogFn) { return listedApps[appIndex].app.releaseDate == null ? hasChangeLogFn ? tr('changes') : '' : DateFormat( 'yyyy-MM-dd', ).format(listedApps[appIndex].app.releaseDate!.toLocal()); } getSingleAppHorizTile(int index) { var showChangesFn = getChangeLogFn(context, listedApps[index].app); var hasUpdate = listedApps[index].app.installedVersion != null && listedApps[index].app.installedVersion != listedApps[index].app.latestVersion; Widget trailingRow = Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ hasUpdate ? getUpdateButton(index) : const SizedBox.shrink(), hasUpdate ? const SizedBox(width: 5) : const SizedBox.shrink(), GestureDetector( onTap: showChangesFn, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: settingsProvider.highlightTouchTargets && showChangesFn != null ? (Theme.of(context).brightness == Brightness.light ? Theme.of(context).primaryColor : Theme.of(context).primaryColorLight) .withAlpha( Theme.of(context).brightness == Brightness.light ? 20 : 40, ) : null, ), padding: settingsProvider.highlightTouchTargets ? const EdgeInsetsDirectional.fromSTEB(12, 0, 12, 0) : const EdgeInsetsDirectional.fromSTEB(24, 0, 0, 0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width / 4, ), child: Text( getVersionText(index), overflow: TextOverflow.ellipsis, textAlign: TextAlign.end, style: isVersionPseudo(listedApps[index].app) ? TextStyle(fontStyle: FontStyle.italic) : null, ), ), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ Text( getChangesButtonString(index, showChangesFn != null), style: TextStyle( fontStyle: FontStyle.italic, decoration: showChangesFn != null ? TextDecoration.underline : TextDecoration.none, ), ), ], ), ], ), ), ), ], ); var transparent = Theme.of( context, ).colorScheme.surface.withAlpha(0).value; List stops = [ ...listedApps[index].app.categories.asMap().entries.map( (e) => ((e.key / (listedApps[index].app.categories.length - 1)) - 0.0001), ), 1, ]; if (stops.length == 2) { stops[0] = 0.9999; } return Container( decoration: BoxDecoration( gradient: LinearGradient( stops: stops, begin: const Alignment(-1, 0), end: const Alignment(-0.97, 0), colors: [ ...listedApps[index].app.categories.map( (e) => Color( settingsProvider.categories[e] ?? transparent, ).withAlpha(255), ), Color(transparent), ], ), ), child: ListTile( tileColor: listedApps[index].app.pinned ? Colors.grey.withOpacity(0.1) : Colors.transparent, selectedTileColor: Theme.of(context).colorScheme.primary.withOpacity( listedApps[index].app.pinned ? 0.2 : 0.1, ), selected: selectedAppIds .map((e) => e) .contains(listedApps[index].app.id), onLongPress: () { toggleAppSelected(listedApps[index].app); }, leading: getAppIcon(index), title: Text( maxLines: 1, listedApps[index].name, style: TextStyle( overflow: TextOverflow.ellipsis, fontWeight: listedApps[index].app.pinned ? FontWeight.bold : FontWeight.normal, ), ), subtitle: Text( tr('byX', args: [listedApps[index].author]), maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, fontWeight: listedApps[index].app.pinned ? FontWeight.bold : FontWeight.normal, ), ), trailing: listedApps[index].downloadProgress != null ? SizedBox( child: Text( listedApps[index].downloadProgress! >= 0 ? tr( 'percentProgress', args: [ listedApps[index].downloadProgress! .toInt() .toString(), ], ) : tr('installing'), textAlign: (listedApps[index].downloadProgress! >= 0) ? TextAlign.start : TextAlign.end, ), ) : trailingRow, onTap: () { if (selectedAppIds.isNotEmpty) { toggleAppSelected(listedApps[index].app); } else { Navigator.push( context, MaterialPageRoute( builder: (context) => AppPage(appId: listedApps[index].app.id), ), ); } }, ), ); } getCategoryCollapsibleTile(int index) { var tiles = listedApps .asMap() .entries .where( (e) => e.value.app.categories.contains(listedCategories[index]) || e.value.app.categories.isEmpty && listedCategories[index] == null, ) .map((e) => getSingleAppHorizTile(e.key)) .toList(); capFirstChar(String str) => str[0].toUpperCase() + str.substring(1); return ExpansionTile( initiallyExpanded: true, title: Text( capFirstChar(listedCategories[index] ?? tr('noCategory')), style: const TextStyle(fontWeight: FontWeight.bold), ), controlAffinity: ListTileControlAffinity.leading, trailing: Text(tiles.length.toString()), children: tiles, ); } getSelectAllButton() { return selectedAppIds.isEmpty ? TextButton.icon( style: const ButtonStyle(visualDensity: VisualDensity.compact), onPressed: () { selectThese(listedApps.map((e) => e.app).toList()); }, icon: Icon( Icons.select_all_outlined, color: Theme.of(context).colorScheme.primary, ), label: Text(listedApps.length.toString()), ) : TextButton.icon( style: const ButtonStyle(visualDensity: VisualDensity.compact), onPressed: () { selectedAppIds.isEmpty ? selectThese(listedApps.map((e) => e.app).toList()) : clearSelected(); }, icon: Icon( selectedAppIds.isEmpty ? Icons.select_all_outlined : Icons.deselect_outlined, color: Theme.of(context).colorScheme.primary, ), label: Text(selectedAppIds.length.toString()), ); } getMassObtainFunction() { return 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, ).toLowerCase(), ], ), defaultValue: true, ), ); } if (newInstallIdsAllOrSelected.isNotEmpty) { formItems.add( GeneratedFormSwitch( 'installs', label: tr( 'installX', args: [ plural( 'apps', newInstallIdsAllOrSelected.length, ).toLowerCase(), ], ), defaultValue: existingUpdateIdsAllOrSelected.isEmpty, ), ); } if (trackOnlyUpdateIdsAllOrSelected.isNotEmpty) { formItems.add( GeneratedFormSwitch( 'trackonlies', label: tr( 'markXTrackOnlyAsUpdated', args: [ plural('apps', trackOnlyUpdateIdsAllOrSelected.length), ], ), defaultValue: existingUpdateIdsAllOrSelected.isEmpty && newInstallIdsAllOrSelected.isEmpty, ), ); } showDialog?>( context: context, builder: (BuildContext ctx) { var totalApps = existingUpdateIdsAllOrSelected.length + newInstallIdsAllOrSelected.length + trackOnlyUpdateIdsAllOrSelected.length; return GeneratedFormModal( title: tr( 'changeX', args: [plural('apps', totalApps).toLowerCase()], ), items: formItems.map((e) => [e]).toList(), initValid: true, ); }, ).then((values) async { if (values != null) { if (values.isEmpty) { values = getDefaultValuesFromFormItems([formItems]); } bool shouldInstallUpdates = values['updates'] == true; bool shouldInstallNew = values['installs'] == true; bool shouldMarkTrackOnlies = values['trackonlies'] == true; 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); return []; }) .then((value) { if (value.isNotEmpty && shouldInstallUpdates) { showMessage(tr('appsUpdated'), context); } }); } }); }; } launchCategorizeDialog() { return () 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?>( 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?>( 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); } }; } showMassMarkDialog() { return showDialog( context: context, builder: (BuildContext ctx) { return AlertDialog( title: Text( tr( 'markXSelectedAppsAsUpdated', args: [selectedAppIds.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 && !appsProvider.isVersionDetectionPossible( appsProvider.apps[a.id], )) { a.installedVersion = a.latestVersion; } return a; }).toList(), ); Navigator.of(context).pop(); }, child: Text(tr('yes')), ), ], ); }, ).whenComplete(() { Navigator.of(context).pop(); }); } pinSelectedApps() { var pinStatus = selectedApps.where((element) => element.pinned).isEmpty; appsProvider.saveApps( selectedApps.map((e) { e.pinned = pinStatus; return e; }).toList(), ); Navigator.of(context).pop(); } showMoreOptionsDialog() { return showDialog( context: context, builder: (BuildContext ctx) { return AlertDialog( scrollable: true, content: Padding( padding: const EdgeInsets.only(top: 6), child: Column( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ TextButton( onPressed: pinSelectedApps, child: Text( selectedApps.where((element) => element.pinned).isEmpty ? tr('pinToTop') : tr('unpinFromTop'), ), ), const Divider(), TextButton( onPressed: () { String urls = ''; for (var a in selectedApps) { urls += '${a.url}\n'; } urls = urls.substring(0, urls.length - 1); Share.share( urls, subject: 'Obtainium - ${tr('appsString')}', ); Navigator.of(context).pop(); }, child: Text(tr('shareSelectedAppURLs')), ), const Divider(), TextButton( onPressed: selectedAppIds.isEmpty ? null : () { String urls = ''; for (var a in selectedApps) { urls += 'https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/${Uri.encodeComponent(jsonEncode({'id': a.id, 'url': a.url, 'author': a.author, 'name': a.name, 'preferredApkIndex': a.preferredApkIndex, 'additionalSettings': jsonEncode(a.additionalSettings), 'overrideSource': a.overrideSource}))}\n\n'; } Share.share( urls, subject: 'Obtainium - ${tr('appsString')}', ); }, child: Text(tr('shareAppConfigLinks')), ), const Divider(), TextButton( onPressed: selectedAppIds.isEmpty ? null : () { var encoder = const JsonEncoder.withIndent(" "); var exportJSON = encoder.convert( appsProvider.generateExportJSON( appIds: selectedApps.map((e) => e.id).toList(), overrideExportSettings: false, ), ); String fn = '${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}-count-${selectedApps.length}'; XFile f = XFile.fromData( Uint8List.fromList(utf8.encode(exportJSON)), mimeType: 'application/json', name: fn, ); Share.shareXFiles( [f], fileNameOverrides: ['$fn.json'], ); }, child: Text('${tr('share')} - ${tr('obtainiumExport')}'), ), const Divider(), TextButton( onPressed: () { appsProvider .downloadAppAssets( selectedApps.map((e) => e.id).toList(), globalNavigatorKey.currentContext ?? context, ) .catchError( // ignore: invalid_return_type_for_catch_error (e) => showError( e, globalNavigatorKey.currentContext ?? context, ), ); Navigator.of(context).pop(); }, child: Text( tr('downloadX', args: [tr('releaseAsset').toLowerCase()]), ), ), const Divider(), TextButton( onPressed: appsProvider.areDownloadsRunning() ? null : showMassMarkDialog, child: Text(tr('markSelectedAppsUpdated')), ), ], ), ), ); }, ); } getMainBottomButtons() { return [ IconButton( visualDensity: VisualDensity.compact, onPressed: getMassObtainFunction(), tooltip: selectedAppIds.isEmpty ? tr('installUpdateApps') : tr('installUpdateSelectedApps'), icon: const Icon(Icons.file_download_outlined), ), IconButton( visualDensity: VisualDensity.compact, onPressed: selectedAppIds.isEmpty ? null : () { appsProvider.removeAppsWithModal( context, selectedApps.toList(), ); }, tooltip: tr('removeSelectedApps'), icon: const Icon(Icons.delete_outline_outlined), ), IconButton( visualDensity: VisualDensity.compact, onPressed: selectedAppIds.isEmpty ? null : launchCategorizeDialog(), tooltip: tr('categorize'), icon: const Icon(Icons.category_outlined), ), IconButton( visualDensity: VisualDensity.compact, onPressed: selectedAppIds.isEmpty ? null : showMoreOptionsDialog, tooltip: tr('more'), icon: const Icon(Icons.more_horiz), ), ]; } showFilterDialog() async { var values = await 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'], ), ], [ GeneratedFormTextField( 'appId', label: tr('appId'), required: false, defaultValue: vals['appId'], ), ], [ GeneratedFormSwitch( 'upToDateApps', label: tr('upToDateApps'), defaultValue: vals['upToDateApps'], ), ], [ GeneratedFormSwitch( 'nonInstalledApps', label: tr('nonInstalledApps'), defaultValue: vals['nonInstalledApps'], ), ], [ GeneratedFormDropdown( 'sourceFilter', label: tr('appSource'), defaultValue: filter.sourceFilter, [ MapEntry('', tr('none')), ...sourceProvider.sources.map( (e) => MapEntry(e.runtimeType.toString(), e.name), ), ], ), ], ], additionalWidgets: [ const SizedBox(height: 16), CategoryEditorSelector( preselected: filter.categoryFilter, onSelected: (categories) { filter.categoryFilter = categories.toSet(); }, ), ], ); }, ); if (values != null) { setState(() { filter.setFormValuesFromMap(values); }); } } getFilterButtonsRow() { var isFilterOff = filter.isIdenticalTo(neutralFilter, settingsProvider); return Row( children: [ getSelectAllButton(), IconButton( color: Theme.of(context).colorScheme.primary, style: const ButtonStyle(visualDensity: VisualDensity.compact), tooltip: isFilterOff ? tr('filterApps') : '${tr('filter')} - ${tr('remove')}', onPressed: isFilterOff ? showFilterDialog : () { setState(() { filter = AppsFilter(); }); }, icon: Icon( isFilterOff ? Icons.search_rounded : Icons.search_off_rounded, ), ), const SizedBox(width: 10), const VerticalDivider(), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: getMainBottomButtons(), ), ), ], ); } getDisplayedList() { return settingsProvider.groupByCategory && !(listedCategories.isEmpty || (listedCategories.length == 1 && listedCategories[0] == null)) ? SliverList( delegate: SliverChildBuilderDelegate(( BuildContext context, int index, ) { return getCategoryCollapsibleTile(index); }, childCount: listedCategories.length), ) : SliverList( delegate: SliverChildBuilderDelegate(( BuildContext context, int index, ) { return getSingleAppHorizTile(index); }, childCount: listedApps.length), ); } return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: RefreshIndicator( key: _refreshIndicatorKey, onRefresh: refresh, child: Scrollbar( interactive: true, controller: scrollController, child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), controller: scrollController, slivers: [ CustomAppBar(title: tr('appsString')), ...getLoadingWidgets(), getDisplayedList(), ], ), ), ), persistentFooterButtons: appsProvider.apps.isEmpty ? null : [getFilterButtonsRow()], ); } } class AppsFilter { late String nameFilter; late String authorFilter; late String idFilter; late bool includeUptodate; late bool includeNonInstalled; late Set categoryFilter; late String sourceFilter; AppsFilter({ this.nameFilter = '', this.authorFilter = '', this.idFilter = '', this.includeUptodate = true, this.includeNonInstalled = true, this.categoryFilter = const {}, this.sourceFilter = '', }); Map toFormValuesMap() { return { 'appName': nameFilter, 'author': authorFilter, 'appId': idFilter, 'upToDateApps': includeUptodate, 'nonInstalledApps': includeNonInstalled, 'sourceFilter': sourceFilter, }; } void setFormValuesFromMap(Map values) { nameFilter = values['appName']!; authorFilter = values['author']!; idFilter = values['appId']!; includeUptodate = values['upToDateApps']; includeNonInstalled = values['nonInstalledApps']; sourceFilter = values['sourceFilter']; } bool isIdenticalTo(AppsFilter other, SettingsProvider settingsProvider) => authorFilter.trim() == other.authorFilter.trim() && nameFilter.trim() == other.nameFilter.trim() && idFilter.trim() == other.idFilter.trim() && includeUptodate == other.includeUptodate && includeNonInstalled == other.includeNonInstalled && settingsProvider.setEqual(categoryFilter, other.categoryFilter) && sourceFilter.trim() == other.sourceFilter.trim(); }