mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-31 05:23:28 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			970 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			970 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:convert';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:obtainium/app_sources/fdroidrepo.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/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:file_picker/file_picker.dart';
 | |
| import 'package:url_launcher/url_launcher_string.dart';
 | |
| 
 | |
| class ImportExportPage extends StatefulWidget {
 | |
|   const ImportExportPage({super.key});
 | |
| 
 | |
|   @override
 | |
|   State<ImportExportPage> createState() => _ImportExportPageState();
 | |
| }
 | |
| 
 | |
| class _ImportExportPageState extends State<ImportExportPage> {
 | |
|   bool importInProgress = false;
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     SourceProvider sourceProvider = SourceProvider();
 | |
|     var appsProvider = context.watch<AppsProvider>();
 | |
|     var settingsProvider = context.watch<SettingsProvider>();
 | |
| 
 | |
|     var outlineButtonStyle = ButtonStyle(
 | |
|       shape: WidgetStateProperty.all(
 | |
|         StadiumBorder(
 | |
|           side: BorderSide(
 | |
|             width: 1,
 | |
|             color: Theme.of(context).colorScheme.primary,
 | |
|           ),
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
| 
 | |
|     urlListImport({String? initValue, bool overrideInitValid = false}) {
 | |
|       showDialog<Map<String, dynamic>?>(
 | |
|         context: context,
 | |
|         builder: (BuildContext ctx) {
 | |
|           return GeneratedFormModal(
 | |
|             initValid: overrideInitValid,
 | |
|             title: tr('importFromURLList'),
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormTextField(
 | |
|                   'appURLList',
 | |
|                   defaultValue: initValue ?? '',
 | |
|                   label: tr('appURLList'),
 | |
|                   max: 7,
 | |
|                   additionalValidators: [
 | |
|                     (dynamic value) {
 | |
|                       if (value != null && value.isNotEmpty) {
 | |
|                         var lines = value.trim().split('\n');
 | |
|                         for (int i = 0; i < lines.length; i++) {
 | |
|                           try {
 | |
|                             sourceProvider.getSource(lines[i]);
 | |
|                           } catch (e) {
 | |
|                             return '${tr('line')} ${i + 1}: $e';
 | |
|                           }
 | |
|                         }
 | |
|                       }
 | |
|                       return null;
 | |
|                     },
 | |
|                   ],
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|           );
 | |
|         },
 | |
|       ).then((values) {
 | |
|         if (values != null) {
 | |
|           var urls = (values['appURLList'] as String).split('\n');
 | |
|           setState(() {
 | |
|             importInProgress = true;
 | |
|           });
 | |
|           appsProvider
 | |
|               .addAppsByURL(urls)
 | |
|               .then((errors) {
 | |
|                 if (errors.isEmpty) {
 | |
|                   showMessage(
 | |
|                     tr(
 | |
|                       'importedX',
 | |
|                       args: [plural('apps', urls.length).toLowerCase()],
 | |
|                     ),
 | |
|                     context,
 | |
|                   );
 | |
|                 } else {
 | |
|                   showDialog(
 | |
|                     context: context,
 | |
|                     builder: (BuildContext ctx) {
 | |
|                       return ImportErrorDialog(
 | |
|                         urlsLength: urls.length,
 | |
|                         errors: errors,
 | |
|                       );
 | |
|                     },
 | |
|                   );
 | |
|                 }
 | |
|               })
 | |
|               .catchError((e) {
 | |
|                 showError(e, context);
 | |
|               })
 | |
|               .whenComplete(() {
 | |
|                 setState(() {
 | |
|                   importInProgress = false;
 | |
|                 });
 | |
|               });
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     runObtainiumExport({bool pickOnly = false}) async {
 | |
|       HapticFeedback.selectionClick();
 | |
|       appsProvider
 | |
|           .export(
 | |
|             pickOnly:
 | |
|                 pickOnly || (await settingsProvider.getExportDir()) == null,
 | |
|             sp: settingsProvider,
 | |
|           )
 | |
|           .then((String? result) {
 | |
|             if (result != null) {
 | |
|               showMessage(tr('exportedTo', args: [result]), context);
 | |
|             }
 | |
|           })
 | |
|           .catchError((e) {
 | |
|             showError(e, context);
 | |
|           });
 | |
|     }
 | |
| 
 | |
|     runObtainiumImport() {
 | |
|       HapticFeedback.selectionClick();
 | |
|       FilePicker.platform
 | |
|           .pickFiles()
 | |
|           .then((result) {
 | |
|             setState(() {
 | |
|               importInProgress = true;
 | |
|             });
 | |
|             if (result != null) {
 | |
|               String data = File(result.files.single.path!).readAsStringSync();
 | |
|               try {
 | |
|                 jsonDecode(data);
 | |
|               } catch (e) {
 | |
|                 throw ObtainiumError(tr('invalidInput'));
 | |
|               }
 | |
|               appsProvider.import(data).then((value) {
 | |
|                 var cats = settingsProvider.categories;
 | |
|                 appsProvider.apps.forEach((key, value) {
 | |
|                   for (var c in value.app.categories) {
 | |
|                     if (!cats.containsKey(c)) {
 | |
|                       cats[c] = generateRandomLightColor().value;
 | |
|                     }
 | |
|                   }
 | |
|                 });
 | |
|                 appsProvider.addMissingCategories(settingsProvider);
 | |
|                 showMessage(
 | |
|                   '${tr('importedX', args: [plural('apps', value.key.length).toLowerCase()])}${value.value ? ' + ${tr('settings').toLowerCase()}' : ''}',
 | |
|                   context,
 | |
|                 );
 | |
|               });
 | |
|             } else {
 | |
|               // User canceled the picker
 | |
|             }
 | |
|           })
 | |
|           .catchError((e) {
 | |
|             showError(e, context);
 | |
|           })
 | |
|           .whenComplete(() {
 | |
|             setState(() {
 | |
|               importInProgress = false;
 | |
|             });
 | |
|           });
 | |
|     }
 | |
| 
 | |
|     runUrlImport() {
 | |
|       FilePicker.platform.pickFiles().then((result) {
 | |
|         if (result != null) {
 | |
|           urlListImport(
 | |
|             overrideInitValid: true,
 | |
|             initValue: RegExp('https?://[^"]+')
 | |
|                 .allMatches(File(result.files.single.path!).readAsStringSync())
 | |
|                 .map((e) => e.input.substring(e.start, e.end))
 | |
|                 .toSet()
 | |
|                 .toList()
 | |
|                 .where((url) {
 | |
|                   try {
 | |
|                     sourceProvider.getSource(url);
 | |
|                     return true;
 | |
|                   } catch (e) {
 | |
|                     return false;
 | |
|                   }
 | |
|                 })
 | |
|                 .join('\n'),
 | |
|           );
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     runSourceSearch(AppSource source) {
 | |
|       () async {
 | |
|             var values = await showDialog<Map<String, dynamic>?>(
 | |
|               context: context,
 | |
|               builder: (BuildContext ctx) {
 | |
|                 return GeneratedFormModal(
 | |
|                   title: tr('searchX', args: [source.name]),
 | |
|                   items: [
 | |
|                     [
 | |
|                       GeneratedFormTextField(
 | |
|                         'searchQuery',
 | |
|                         label: tr('searchQuery'),
 | |
|                         required: source.name != FDroidRepo().name,
 | |
|                       ),
 | |
|                     ],
 | |
|                     ...source.searchQuerySettingFormItems.map((e) => [e]),
 | |
|                     [
 | |
|                       GeneratedFormTextField(
 | |
|                         'url',
 | |
|                         label: source.hosts.isNotEmpty
 | |
|                             ? tr('overrideSource')
 | |
|                             : plural('url', 1).substring(2),
 | |
|                         defaultValue: source.hosts.isNotEmpty
 | |
|                             ? source.hosts[0]
 | |
|                             : '',
 | |
|                         required: true,
 | |
|                       ),
 | |
|                     ],
 | |
|                   ],
 | |
|                 );
 | |
|               },
 | |
|             );
 | |
|             if (values != null) {
 | |
|               setState(() {
 | |
|                 importInProgress = true;
 | |
|               });
 | |
|               if (source.hosts.isEmpty || values['url'] != source.hosts[0]) {
 | |
|                 source = sourceProvider.getSource(
 | |
|                   values['url'],
 | |
|                   overrideSource: source.runtimeType.toString(),
 | |
|                 );
 | |
|               }
 | |
|               var urlsWithDescriptions = await source.search(
 | |
|                 values['searchQuery'] as String,
 | |
|                 querySettings: values,
 | |
|               );
 | |
|               if (urlsWithDescriptions.isNotEmpty) {
 | |
|                 var selectedUrls =
 | |
|                     // ignore: use_build_context_synchronously
 | |
|                     await showDialog<List<String>?>(
 | |
|                       context: context,
 | |
|                       builder: (BuildContext ctx) {
 | |
|                         return SelectionModal(
 | |
|                           entries: urlsWithDescriptions,
 | |
|                           selectedByDefault: false,
 | |
|                         );
 | |
|                       },
 | |
|                     );
 | |
|                 if (selectedUrls != null && selectedUrls.isNotEmpty) {
 | |
|                   var errors = await appsProvider.addAppsByURL(
 | |
|                     selectedUrls,
 | |
|                     sourceOverride: source,
 | |
|                   );
 | |
|                   if (errors.isEmpty) {
 | |
|                     // ignore: use_build_context_synchronously
 | |
|                     showMessage(
 | |
|                       tr(
 | |
|                         'importedX',
 | |
|                         args: [
 | |
|                           plural('apps', selectedUrls.length).toLowerCase(),
 | |
|                         ],
 | |
|                       ),
 | |
|                       context,
 | |
|                     );
 | |
|                   } else {
 | |
|                     // ignore: use_build_context_synchronously
 | |
|                     showDialog(
 | |
|                       context: context,
 | |
|                       builder: (BuildContext ctx) {
 | |
|                         return ImportErrorDialog(
 | |
|                           urlsLength: selectedUrls.length,
 | |
|                           errors: errors,
 | |
|                         );
 | |
|                       },
 | |
|                     );
 | |
|                   }
 | |
|                 }
 | |
|               } else {
 | |
|                 throw ObtainiumError(tr('noResults'));
 | |
|               }
 | |
|             }
 | |
|           }()
 | |
|           .catchError((e) {
 | |
|             showError(e, context);
 | |
|           })
 | |
|           .whenComplete(() {
 | |
|             setState(() {
 | |
|               importInProgress = false;
 | |
|             });
 | |
|           });
 | |
|     }
 | |
| 
 | |
|     runMassSourceImport(MassAppUrlSource source) {
 | |
|       () async {
 | |
|             var values = await showDialog<Map<String, dynamic>?>(
 | |
|               context: context,
 | |
|               builder: (BuildContext ctx) {
 | |
|                 return GeneratedFormModal(
 | |
|                   title: tr('importX', args: [source.name]),
 | |
|                   items: source.requiredArgs
 | |
|                       .map((e) => [GeneratedFormTextField(e, label: e)])
 | |
|                       .toList(),
 | |
|                 );
 | |
|               },
 | |
|             );
 | |
|             if (values != null) {
 | |
|               setState(() {
 | |
|                 importInProgress = true;
 | |
|               });
 | |
|               var urlsWithDescriptions = await source.getUrlsWithDescriptions(
 | |
|                 values.values.map((e) => e.toString()).toList(),
 | |
|               );
 | |
|               var selectedUrls =
 | |
|                   // ignore: use_build_context_synchronously
 | |
|                   await showDialog<List<String>?>(
 | |
|                     context: context,
 | |
|                     builder: (BuildContext ctx) {
 | |
|                       return SelectionModal(entries: urlsWithDescriptions);
 | |
|                     },
 | |
|                   );
 | |
|               if (selectedUrls != null) {
 | |
|                 var errors = await appsProvider.addAppsByURL(selectedUrls);
 | |
|                 if (errors.isEmpty) {
 | |
|                   // ignore: use_build_context_synchronously
 | |
|                   showMessage(
 | |
|                     tr(
 | |
|                       'importedX',
 | |
|                       args: [plural('apps', selectedUrls.length).toLowerCase()],
 | |
|                     ),
 | |
|                     context,
 | |
|                   );
 | |
|                 } else {
 | |
|                   // ignore: use_build_context_synchronously
 | |
|                   showDialog(
 | |
|                     context: context,
 | |
|                     builder: (BuildContext ctx) {
 | |
|                       return ImportErrorDialog(
 | |
|                         urlsLength: selectedUrls.length,
 | |
|                         errors: errors,
 | |
|                       );
 | |
|                     },
 | |
|                   );
 | |
|                 }
 | |
|               }
 | |
|             }
 | |
|           }()
 | |
|           .catchError((e) {
 | |
|             showError(e, context);
 | |
|           })
 | |
|           .whenComplete(() {
 | |
|             setState(() {
 | |
|               importInProgress = false;
 | |
|             });
 | |
|           });
 | |
|     }
 | |
| 
 | |
|     var sourceStrings = <String, List<String>>{};
 | |
|     sourceProvider.sources.where((e) => e.canSearch).forEach((s) {
 | |
|       sourceStrings[s.name] = [s.name];
 | |
|     });
 | |
| 
 | |
|     return Scaffold(
 | |
|       backgroundColor: Theme.of(context).colorScheme.surface,
 | |
|       body: CustomScrollView(
 | |
|         slivers: <Widget>[
 | |
|           CustomAppBar(title: tr('importExport')),
 | |
|           SliverFillRemaining(
 | |
|             child: Padding(
 | |
|               padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
 | |
|               child: Column(
 | |
|                 crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                 children: [
 | |
|                   FutureBuilder(
 | |
|                     future: settingsProvider.getExportDir(),
 | |
|                     builder: (context, snapshot) {
 | |
|                       return Column(
 | |
|                         children: [
 | |
|                           Row(
 | |
|                             children: [
 | |
|                               Expanded(
 | |
|                                 child: TextButton(
 | |
|                                   style: outlineButtonStyle,
 | |
|                                   onPressed: importInProgress
 | |
|                                       ? null
 | |
|                                       : () {
 | |
|                                           runObtainiumExport(pickOnly: true);
 | |
|                                         },
 | |
|                                   child: Text(
 | |
|                                     tr('pickExportDir'),
 | |
|                                     textAlign: TextAlign.center,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                               const SizedBox(width: 16),
 | |
|                               Expanded(
 | |
|                                 child: TextButton(
 | |
|                                   style: outlineButtonStyle,
 | |
|                                   onPressed:
 | |
|                                       importInProgress || snapshot.data == null
 | |
|                                       ? null
 | |
|                                       : runObtainiumExport,
 | |
|                                   child: Text(
 | |
|                                     tr('obtainiumExport'),
 | |
|                                     textAlign: TextAlign.center,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                           const SizedBox(height: 8),
 | |
|                           Row(
 | |
|                             children: [
 | |
|                               Expanded(
 | |
|                                 child: TextButton(
 | |
|                                   style: outlineButtonStyle,
 | |
|                                   onPressed: importInProgress
 | |
|                                       ? null
 | |
|                                       : runObtainiumImport,
 | |
|                                   child: Text(
 | |
|                                     tr('obtainiumImport'),
 | |
|                                     textAlign: TextAlign.center,
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                           if (snapshot.data != null)
 | |
|                             Column(
 | |
|                               children: [
 | |
|                                 const SizedBox(height: 16),
 | |
|                                 GeneratedForm(
 | |
|                                   items: [
 | |
|                                     [
 | |
|                                       GeneratedFormSwitch(
 | |
|                                         'autoExportOnChanges',
 | |
|                                         label: tr('autoExportOnChanges'),
 | |
|                                         defaultValue: settingsProvider
 | |
|                                             .autoExportOnChanges,
 | |
|                                       ),
 | |
|                                     ],
 | |
|                                     [
 | |
|                                       GeneratedFormDropdown(
 | |
|                                         'exportSettings',
 | |
|                                         [
 | |
|                                           MapEntry('0', tr('none')),
 | |
|                                           MapEntry('1', tr('excludeSecrets')),
 | |
|                                           MapEntry('2', tr('all')),
 | |
|                                         ],
 | |
|                                         label: tr('includeSettings'),
 | |
|                                         defaultValue: settingsProvider
 | |
|                                             .exportSettings
 | |
|                                             .toString(),
 | |
|                                       ),
 | |
|                                     ],
 | |
|                                   ],
 | |
|                                   onValueChanges: (value, valid, isBuilding) {
 | |
|                                     if (valid && !isBuilding) {
 | |
|                                       if (value['autoExportOnChanges'] !=
 | |
|                                           null) {
 | |
|                                         settingsProvider.autoExportOnChanges =
 | |
|                                             value['autoExportOnChanges'] ==
 | |
|                                             true;
 | |
|                                       }
 | |
|                                       if (value['exportSettings'] != null) {
 | |
|                                         settingsProvider.exportSettings =
 | |
|                                             int.parse(value['exportSettings']);
 | |
|                                       }
 | |
|                                     }
 | |
|                                   },
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ),
 | |
|                         ],
 | |
|                       );
 | |
|                     },
 | |
|                   ),
 | |
|                   if (importInProgress)
 | |
|                     const Column(
 | |
|                       children: [
 | |
|                         SizedBox(height: 14),
 | |
|                         LinearProgressIndicator(),
 | |
|                         SizedBox(height: 14),
 | |
|                       ],
 | |
|                     )
 | |
|                   else
 | |
|                     Column(
 | |
|                       children: [
 | |
|                         SizedBox(height: 32),
 | |
|                         Row(
 | |
|                           children: [
 | |
|                             Expanded(
 | |
|                               child: TextButton(
 | |
|                                 onPressed: importInProgress
 | |
|                                     ? null
 | |
|                                     : () async {
 | |
|                                         var searchSourceName =
 | |
|                                             await showDialog<List<String>?>(
 | |
|                                               context: context,
 | |
|                                               builder: (BuildContext ctx) {
 | |
|                                                 return SelectionModal(
 | |
|                                                   title: tr(
 | |
|                                                     'selectX',
 | |
|                                                     args: [
 | |
|                                                       tr(
 | |
|                                                         'source',
 | |
|                                                       ).toLowerCase(),
 | |
|                                                     ],
 | |
|                                                   ),
 | |
|                                                   entries: sourceStrings,
 | |
|                                                   selectedByDefault: false,
 | |
|                                                   onlyOneSelectionAllowed: true,
 | |
|                                                   titlesAreLinks: false,
 | |
|                                                 );
 | |
|                                               },
 | |
|                                             ) ??
 | |
|                                             [];
 | |
|                                         var searchSource = sourceProvider
 | |
|                                             .sources
 | |
|                                             .where(
 | |
|                                               (e) => searchSourceName.contains(
 | |
|                                                 e.name,
 | |
|                                               ),
 | |
|                                             )
 | |
|                                             .toList();
 | |
|                                         if (searchSource.isNotEmpty) {
 | |
|                                           runSourceSearch(searchSource[0]);
 | |
|                                         }
 | |
|                                       },
 | |
|                                 child: Text(
 | |
|                                   tr(
 | |
|                                     'searchX',
 | |
|                                     args: [lowerCaseIfEnglish(tr('source'))],
 | |
|                                   ),
 | |
|                                 ),
 | |
|                               ),
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                         const SizedBox(height: 8),
 | |
|                         TextButton(
 | |
|                           onPressed: importInProgress ? null : urlListImport,
 | |
|                           child: Text(tr('importFromURLList')),
 | |
|                         ),
 | |
|                         const SizedBox(height: 8),
 | |
|                         TextButton(
 | |
|                           onPressed: importInProgress ? null : runUrlImport,
 | |
|                           child: Text(tr('importFromURLsInFile')),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ...sourceProvider.massUrlSources.map(
 | |
|                     (source) => Column(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                       children: [
 | |
|                         const SizedBox(height: 8),
 | |
|                         TextButton(
 | |
|                           onPressed: importInProgress
 | |
|                               ? null
 | |
|                               : () {
 | |
|                                   runMassSourceImport(source);
 | |
|                                 },
 | |
|                           child: Text(tr('importX', args: [source.name])),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                   const Spacer(),
 | |
|                   const Divider(height: 32),
 | |
|                   Text(
 | |
|                     tr('importedAppsIdDisclaimer'),
 | |
|                     textAlign: TextAlign.center,
 | |
|                     style: const TextStyle(
 | |
|                       fontStyle: FontStyle.italic,
 | |
|                       fontSize: 12,
 | |
|                     ),
 | |
|                   ),
 | |
|                   const SizedBox(height: 8),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class ImportErrorDialog extends StatefulWidget {
 | |
|   const ImportErrorDialog({
 | |
|     super.key,
 | |
|     required this.urlsLength,
 | |
|     required this.errors,
 | |
|   });
 | |
| 
 | |
|   final int urlsLength;
 | |
|   final List<List<String>> errors;
 | |
| 
 | |
|   @override
 | |
|   State<ImportErrorDialog> createState() => _ImportErrorDialogState();
 | |
| }
 | |
| 
 | |
| class _ImportErrorDialogState extends State<ImportErrorDialog> {
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return AlertDialog(
 | |
|       scrollable: true,
 | |
|       title: Text(tr('importErrors')),
 | |
|       content: Column(
 | |
|         crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|         children: [
 | |
|           Text(
 | |
|             tr(
 | |
|               'importedXOfYApps',
 | |
|               args: [
 | |
|                 (widget.urlsLength - widget.errors.length).toString(),
 | |
|                 widget.urlsLength.toString(),
 | |
|               ],
 | |
|             ),
 | |
|             style: Theme.of(context).textTheme.bodyLarge,
 | |
|           ),
 | |
|           const SizedBox(height: 16),
 | |
|           Text(
 | |
|             tr('followingURLsHadErrors'),
 | |
|             style: Theme.of(context).textTheme.bodyLarge,
 | |
|           ),
 | |
|           ...widget.errors.map((e) {
 | |
|             return Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|               children: [
 | |
|                 const SizedBox(height: 16),
 | |
|                 Text(e[0]),
 | |
|                 Text(e[1], style: const TextStyle(fontStyle: FontStyle.italic)),
 | |
|               ],
 | |
|             );
 | |
|           }),
 | |
|         ],
 | |
|       ),
 | |
|       actions: [
 | |
|         TextButton(
 | |
|           onPressed: () {
 | |
|             Navigator.of(context).pop(null);
 | |
|           },
 | |
|           child: Text(tr('ok')),
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| // ignore: must_be_immutable
 | |
| class SelectionModal extends StatefulWidget {
 | |
|   SelectionModal({
 | |
|     super.key,
 | |
|     required this.entries,
 | |
|     this.selectedByDefault = true,
 | |
|     this.onlyOneSelectionAllowed = false,
 | |
|     this.titlesAreLinks = true,
 | |
|     this.title,
 | |
|     this.deselectThese = const [],
 | |
|   });
 | |
| 
 | |
|   String? title;
 | |
|   Map<String, List<String>> entries;
 | |
|   bool selectedByDefault;
 | |
|   List<String> deselectThese;
 | |
|   bool onlyOneSelectionAllowed;
 | |
|   bool titlesAreLinks;
 | |
| 
 | |
|   @override
 | |
|   State<SelectionModal> createState() => _SelectionModalState();
 | |
| }
 | |
| 
 | |
| class _SelectionModalState extends State<SelectionModal> {
 | |
|   Map<MapEntry<String, List<String>>, bool> entrySelections = {};
 | |
|   String filterRegex = '';
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     for (var entry in widget.entries.entries) {
 | |
|       entrySelections.putIfAbsent(
 | |
|         entry,
 | |
|         () =>
 | |
|             widget.selectedByDefault &&
 | |
|             !widget.onlyOneSelectionAllowed &&
 | |
|             !widget.deselectThese.contains(entry.key),
 | |
|       );
 | |
|     }
 | |
|     if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
 | |
|       selectOnlyOne(widget.entries.entries.first.key);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void selectOnlyOne(String url) {
 | |
|     for (var e in entrySelections.keys) {
 | |
|       entrySelections[e] = e.key == url;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void selectAll({bool deselect = false}) {
 | |
|     for (var e in entrySelections.keys) {
 | |
|       entrySelections[e] = !deselect;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     Map<MapEntry<String, List<String>>, bool> filteredEntrySelections = {};
 | |
|     entrySelections.forEach((key, value) {
 | |
|       var searchableText = key.value.isEmpty ? key.key : key.value[0];
 | |
|       if (filterRegex.isEmpty || RegExp(filterRegex).hasMatch(searchableText)) {
 | |
|         filteredEntrySelections.putIfAbsent(key, () => value);
 | |
|       }
 | |
|     });
 | |
|     if (filterRegex.isNotEmpty && filteredEntrySelections.isEmpty) {
 | |
|       entrySelections.forEach((key, value) {
 | |
|         var searchableText = key.value.isEmpty ? key.key : key.value[0];
 | |
|         if (filterRegex.isEmpty ||
 | |
|             RegExp(
 | |
|               filterRegex,
 | |
|               caseSensitive: false,
 | |
|             ).hasMatch(searchableText)) {
 | |
|           filteredEntrySelections.putIfAbsent(key, () => value);
 | |
|         }
 | |
|       });
 | |
|     }
 | |
|     getSelectAllButton() {
 | |
|       if (widget.onlyOneSelectionAllowed) {
 | |
|         return SizedBox.shrink();
 | |
|       }
 | |
|       var noneSelected = entrySelections.values.where((v) => v == true).isEmpty;
 | |
|       return noneSelected
 | |
|           ? TextButton(
 | |
|               style: const ButtonStyle(visualDensity: VisualDensity.compact),
 | |
|               onPressed: () {
 | |
|                 setState(() {
 | |
|                   selectAll();
 | |
|                 });
 | |
|               },
 | |
|               child: Text(tr('selectAll')),
 | |
|             )
 | |
|           : TextButton(
 | |
|               style: const ButtonStyle(visualDensity: VisualDensity.compact),
 | |
|               onPressed: () {
 | |
|                 setState(() {
 | |
|                   selectAll(deselect: true);
 | |
|                 });
 | |
|               },
 | |
|               child: Text(tr('deselectX', args: [''])),
 | |
|             );
 | |
|     }
 | |
| 
 | |
|     return AlertDialog(
 | |
|       scrollable: true,
 | |
|       title: Text(widget.title ?? tr('pick')),
 | |
|       content: Column(
 | |
|         children: [
 | |
|           GeneratedForm(
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormTextField(
 | |
|                   'filter',
 | |
|                   label: tr('filter'),
 | |
|                   required: false,
 | |
|                   additionalValidators: [
 | |
|                     (value) {
 | |
|                       return regExValidator(value);
 | |
|                     },
 | |
|                   ],
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|             onValueChanges: (value, valid, isBuilding) {
 | |
|               if (valid && !isBuilding) {
 | |
|                 if (value['filter'] != null) {
 | |
|                   setState(() {
 | |
|                     filterRegex = value['filter'];
 | |
|                   });
 | |
|                 }
 | |
|               }
 | |
|             },
 | |
|           ),
 | |
|           ...filteredEntrySelections.keys.map((entry) {
 | |
|             selectThis(bool? value) {
 | |
|               setState(() {
 | |
|                 value ??= false;
 | |
|                 if (value! && widget.onlyOneSelectionAllowed) {
 | |
|                   selectOnlyOne(entry.key);
 | |
|                 } else {
 | |
|                   entrySelections[entry] = value!;
 | |
|                 }
 | |
|               });
 | |
|             }
 | |
| 
 | |
|             var urlLink = GestureDetector(
 | |
|               onTap: !widget.titlesAreLinks
 | |
|                   ? null
 | |
|                   : () {
 | |
|                       launchUrlString(
 | |
|                         entry.key,
 | |
|                         mode: LaunchMode.externalApplication,
 | |
|                       );
 | |
|                     },
 | |
|               child: Column(
 | |
|                 crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                 children: [
 | |
|                   Text(
 | |
|                     entry.value.isEmpty ? entry.key : entry.value[0],
 | |
|                     style: TextStyle(
 | |
|                       decoration: widget.titlesAreLinks
 | |
|                           ? TextDecoration.underline
 | |
|                           : null,
 | |
|                       fontWeight: FontWeight.bold,
 | |
|                     ),
 | |
|                     textAlign: TextAlign.start,
 | |
|                   ),
 | |
|                   if (widget.titlesAreLinks)
 | |
|                     Text(
 | |
|                       Uri.parse(entry.key).host,
 | |
|                       style: const TextStyle(
 | |
|                         decoration: TextDecoration.underline,
 | |
|                         fontSize: 12,
 | |
|                       ),
 | |
|                     ),
 | |
|                 ],
 | |
|               ),
 | |
|             );
 | |
| 
 | |
|             var descriptionText = entry.value.length <= 1
 | |
|                 ? const SizedBox.shrink()
 | |
|                 : Text(
 | |
|                     entry.value[1].length > 128
 | |
|                         ? '${entry.value[1].substring(0, 128)}...'
 | |
|                         : entry.value[1],
 | |
|                     style: const TextStyle(
 | |
|                       fontStyle: FontStyle.italic,
 | |
|                       fontSize: 12,
 | |
|                     ),
 | |
|                   );
 | |
| 
 | |
|             var selectedEntries = entrySelections.entries
 | |
|                 .where((e) => e.value)
 | |
|                 .toList();
 | |
| 
 | |
|             var singleSelectTile = ListTile(
 | |
|               title: GestureDetector(
 | |
|                 onTap: widget.titlesAreLinks
 | |
|                     ? null
 | |
|                     : () {
 | |
|                         selectThis(!(entrySelections[entry] ?? false));
 | |
|                       },
 | |
|                 child: urlLink,
 | |
|               ),
 | |
|               subtitle: entry.value.length <= 1
 | |
|                   ? null
 | |
|                   : GestureDetector(
 | |
|                       onTap: () {
 | |
|                         setState(() {
 | |
|                           selectOnlyOne(entry.key);
 | |
|                         });
 | |
|                       },
 | |
|                       child: descriptionText,
 | |
|                     ),
 | |
|               leading: Radio<String>(
 | |
|                 value: entry.key,
 | |
|                 groupValue: selectedEntries.isEmpty
 | |
|                     ? null
 | |
|                     : selectedEntries.first.key.key,
 | |
|                 onChanged: (value) {
 | |
|                   setState(() {
 | |
|                     selectOnlyOne(entry.key);
 | |
|                   });
 | |
|                 },
 | |
|               ),
 | |
|             );
 | |
| 
 | |
|             var multiSelectTile = Row(
 | |
|               children: [
 | |
|                 Checkbox(
 | |
|                   value: entrySelections[entry],
 | |
|                   onChanged: (value) {
 | |
|                     selectThis(value);
 | |
|                   },
 | |
|                 ),
 | |
|                 const SizedBox(width: 8),
 | |
|                 Expanded(
 | |
|                   child: Column(
 | |
|                     crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                     mainAxisAlignment: MainAxisAlignment.center,
 | |
|                     children: [
 | |
|                       const SizedBox(height: 8),
 | |
|                       GestureDetector(
 | |
|                         onTap: widget.titlesAreLinks
 | |
|                             ? null
 | |
|                             : () {
 | |
|                                 selectThis(!(entrySelections[entry] ?? false));
 | |
|                               },
 | |
|                         child: urlLink,
 | |
|                       ),
 | |
|                       entry.value.length <= 1
 | |
|                           ? const SizedBox.shrink()
 | |
|                           : GestureDetector(
 | |
|                               onTap: () {
 | |
|                                 selectThis(!(entrySelections[entry] ?? false));
 | |
|                               },
 | |
|                               child: descriptionText,
 | |
|                             ),
 | |
|                       const SizedBox(height: 8),
 | |
|                     ],
 | |
|                   ),
 | |
|                 ),
 | |
|               ],
 | |
|             );
 | |
| 
 | |
|             return widget.onlyOneSelectionAllowed
 | |
|                 ? singleSelectTile
 | |
|                 : multiSelectTile;
 | |
|           }),
 | |
|         ],
 | |
|       ),
 | |
|       actions: [
 | |
|         getSelectAllButton(),
 | |
|         TextButton(
 | |
|           onPressed: () {
 | |
|             Navigator.of(context).pop();
 | |
|           },
 | |
|           child: Text(tr('cancel')),
 | |
|         ),
 | |
|         TextButton(
 | |
|           onPressed: entrySelections.values.where((b) => b).isEmpty
 | |
|               ? null
 | |
|               : () {
 | |
|                   Navigator.of(context).pop(
 | |
|                     entrySelections.entries
 | |
|                         .where((entry) => entry.value)
 | |
|                         .map((e) => e.key.key)
 | |
|                         .toList(),
 | |
|                   );
 | |
|                 },
 | |
|           child: Text(
 | |
|             widget.onlyOneSelectionAllowed
 | |
|                 ? tr('pick')
 | |
|                 : tr(
 | |
|                     'selectX',
 | |
|                     args: [
 | |
|                       entrySelections.values.where((b) => b).length.toString(),
 | |
|                     ],
 | |
|                   ),
 | |
|           ),
 | |
|         ),
 | |
|       ],
 | |
|     );
 | |
|   }
 | |
| }
 |