mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-31 05:23:28 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			1342 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			1342 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| 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<AppsPage> 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<AppsPage> {
 | |
|   AppsFilter filter = AppsFilter();
 | |
|   final AppsFilter neutralFilter = AppsFilter();
 | |
|   var updatesOnlyFilter = AppsFilter(
 | |
|     includeUptodate: false,
 | |
|     includeNonInstalled: false,
 | |
|   );
 | |
|   Set<String> selectedAppIds = {};
 | |
|   DateTime? refreshingSince;
 | |
| 
 | |
|   bool clearSelected() {
 | |
|     if (selectedAppIds.isNotEmpty) {
 | |
|       setState(() {
 | |
|         selectedAppIds.clear();
 | |
|       });
 | |
|       return true;
 | |
|     }
 | |
|     return false;
 | |
|   }
 | |
| 
 | |
|   void selectThese(List<App> apps) {
 | |
|     if (selectedAppIds.isEmpty) {
 | |
|       setState(() {
 | |
|         for (var a in apps) {
 | |
|           selectedAppIds.add(a.id);
 | |
|         }
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   final GlobalKey<RefreshIndicatorState> _refreshIndicatorKey =
 | |
|       GlobalKey<RefreshIndicatorState>();
 | |
| 
 | |
|   late final ScrollController scrollController = ScrollController();
 | |
| 
 | |
|   var sourceProvider = SourceProvider();
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     var appsProvider = context.watch<AppsProvider>();
 | |
|     var settingsProvider = context.watch<SettingsProvider>();
 | |
|     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 <App>[];
 | |
|           })
 | |
|           .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<String> nameTokens = filter.nameFilter
 | |
|             .split(' ')
 | |
|             .where((element) => element.trim().isNotEmpty)
 | |
|             .toList();
 | |
|         List<String> 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<String> 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<String?> 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<App> 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 <String>[];
 | |
|                     });
 | |
|               },
 | |
|         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<double> 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<GeneratedFormItem> 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<Map<String, dynamic>?>(
 | |
|                 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<String> 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 <String>[];
 | |
|                       })
 | |
|                       .then((value) {
 | |
|                         if (value.isNotEmpty && shouldInstallUpdates) {
 | |
|                           showMessage(tr('appsUpdated'), context);
 | |
|                         }
 | |
|                       });
 | |
|                 }
 | |
|               });
 | |
|             };
 | |
|     }
 | |
| 
 | |
|     launchCategorizeDialog() {
 | |
|       return () async {
 | |
|         try {
 | |
|           Set<String>? 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<String, dynamic>?>(
 | |
|                   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<String, dynamic>?>(
 | |
|               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: 0,
 | |
|                               ),
 | |
|                             );
 | |
|                             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: [lowerCaseIfEnglish(tr('releaseAsset'))],
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                   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<Map<String, dynamic>?>(
 | |
|         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: <Widget>[
 | |
|               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<String> categoryFilter;
 | |
|   late String sourceFilter;
 | |
| 
 | |
|   AppsFilter({
 | |
|     this.nameFilter = '',
 | |
|     this.authorFilter = '',
 | |
|     this.idFilter = '',
 | |
|     this.includeUptodate = true,
 | |
|     this.includeNonInstalled = true,
 | |
|     this.categoryFilter = const {},
 | |
|     this.sourceFilter = '',
 | |
|   });
 | |
| 
 | |
|   Map<String, dynamic> toFormValuesMap() {
 | |
|     return {
 | |
|       'appName': nameFilter,
 | |
|       'author': authorFilter,
 | |
|       'appId': idFilter,
 | |
|       'upToDateApps': includeUptodate,
 | |
|       'nonInstalledApps': includeNonInstalled,
 | |
|       'sourceFilter': sourceFilter,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   void setFormValuesFromMap(Map<String, dynamic> 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();
 | |
| }
 |