mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-10-30 13:03:28 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			772 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			772 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter/services.dart';
 | |
| import 'package:obtainium/components/custom_app_bar.dart';
 | |
| import 'package:obtainium/components/generated_form.dart';
 | |
| import 'package:obtainium/components/generated_form_modal.dart';
 | |
| import 'package:obtainium/custom_errors.dart';
 | |
| import 'package:obtainium/main.dart';
 | |
| import 'package:obtainium/pages/app.dart';
 | |
| import 'package:obtainium/pages/import_export.dart';
 | |
| import 'package:obtainium/pages/settings.dart';
 | |
| import 'package:obtainium/providers/apps_provider.dart';
 | |
| import 'package:obtainium/providers/notifications_provider.dart';
 | |
| import 'package:obtainium/providers/settings_provider.dart';
 | |
| import 'package:obtainium/providers/source_provider.dart';
 | |
| import 'package:provider/provider.dart';
 | |
| import 'package:url_launcher/url_launcher_string.dart';
 | |
| 
 | |
| class AddAppPage extends StatefulWidget {
 | |
|   const AddAppPage({super.key});
 | |
| 
 | |
|   @override
 | |
|   State<AddAppPage> createState() => AddAppPageState();
 | |
| }
 | |
| 
 | |
| class AddAppPageState extends State<AddAppPage> {
 | |
|   bool gettingAppInfo = false;
 | |
|   bool searching = false;
 | |
| 
 | |
|   String userInput = '';
 | |
|   String searchQuery = '';
 | |
|   String? pickedSourceOverride;
 | |
|   String? previousPickedSourceOverride;
 | |
|   AppSource? pickedSource;
 | |
|   Map<String, dynamic> additionalSettings = {};
 | |
|   bool additionalSettingsValid = true;
 | |
|   bool inferAppIdIfOptional = true;
 | |
|   List<String> pickedCategories = [];
 | |
|   int urlInputKey = 0;
 | |
|   SourceProvider sourceProvider = SourceProvider();
 | |
| 
 | |
|   void linkFn(String input) {
 | |
|     try {
 | |
|       if (input.isEmpty) {
 | |
|         throw UnsupportedURLError();
 | |
|       }
 | |
|       sourceProvider.getSource(input);
 | |
|       changeUserInput(input, true, false, updateUrlInput: true);
 | |
|     } catch (e) {
 | |
|       showError(e, context);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void changeUserInput(
 | |
|     String input,
 | |
|     bool valid,
 | |
|     bool isBuilding, {
 | |
|     bool updateUrlInput = false,
 | |
|     String? overrideSource,
 | |
|   }) {
 | |
|     userInput = input;
 | |
|     if (!isBuilding) {
 | |
|       setState(() {
 | |
|         if (overrideSource != null) {
 | |
|           pickedSourceOverride = overrideSource;
 | |
|         }
 | |
|         bool overrideChanged =
 | |
|             pickedSourceOverride != previousPickedSourceOverride;
 | |
|         previousPickedSourceOverride = pickedSourceOverride;
 | |
|         if (updateUrlInput) {
 | |
|           urlInputKey++;
 | |
|         }
 | |
|         var prevHost = pickedSource?.hosts.isNotEmpty == true
 | |
|             ? pickedSource?.hosts[0]
 | |
|             : null;
 | |
|         var source = valid
 | |
|             ? sourceProvider.getSource(
 | |
|                 userInput,
 | |
|                 overrideSource: pickedSourceOverride,
 | |
|               )
 | |
|             : null;
 | |
|         if (pickedSource.runtimeType != source.runtimeType ||
 | |
|             overrideChanged ||
 | |
|             (prevHost != null && prevHost != source?.hosts[0])) {
 | |
|           pickedSource = source;
 | |
|           pickedSource?.runOnAddAppInputChange(userInput);
 | |
|           additionalSettings = source != null
 | |
|               ? getDefaultValuesFromFormItems(
 | |
|                   source.combinedAppSpecificSettingFormItems,
 | |
|                 )
 | |
|               : {};
 | |
|           additionalSettingsValid = source != null
 | |
|               ? !sourceProvider.ifRequiredAppSpecificSettingsExist(source)
 | |
|               : true;
 | |
|           inferAppIdIfOptional = true;
 | |
|         }
 | |
|       });
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     AppsProvider appsProvider = context.read<AppsProvider>();
 | |
|     SettingsProvider settingsProvider = context.watch<SettingsProvider>();
 | |
|     NotificationsProvider notificationsProvider = context
 | |
|         .read<NotificationsProvider>();
 | |
| 
 | |
|     bool doingSomething = gettingAppInfo || searching;
 | |
| 
 | |
|     Future<bool> getTrackOnlyConfirmationIfNeeded(
 | |
|       bool userPickedTrackOnly, {
 | |
|       bool ignoreHideSetting = false,
 | |
|     }) async {
 | |
|       var useTrackOnly = userPickedTrackOnly || pickedSource!.enforceTrackOnly;
 | |
|       if (useTrackOnly &&
 | |
|           (!settingsProvider.hideTrackOnlyWarning || ignoreHideSetting)) {
 | |
|         // ignore: use_build_context_synchronously
 | |
|         var values = await showDialog(
 | |
|           context: context,
 | |
|           builder: (BuildContext ctx) {
 | |
|             return GeneratedFormModal(
 | |
|               initValid: true,
 | |
|               title: tr(
 | |
|                 'xIsTrackOnly',
 | |
|                 args: [
 | |
|                   pickedSource!.enforceTrackOnly ? tr('source') : tr('app'),
 | |
|                 ],
 | |
|               ),
 | |
|               items: [
 | |
|                 [GeneratedFormSwitch('hide', label: tr('dontShowAgain'))],
 | |
|               ],
 | |
|               message:
 | |
|                   '${pickedSource!.enforceTrackOnly ? tr('appsFromSourceAreTrackOnly') : tr('youPickedTrackOnly')}\n\n${tr('trackOnlyAppDescription')}',
 | |
|             );
 | |
|           },
 | |
|         );
 | |
|         if (values != null) {
 | |
|           settingsProvider.hideTrackOnlyWarning = values['hide'] == true;
 | |
|         }
 | |
|         return useTrackOnly && values != null;
 | |
|       } else {
 | |
|         return true;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     getReleaseDateAsVersionConfirmationIfNeeded(
 | |
|       bool userPickedTrackOnly,
 | |
|     ) async {
 | |
|       return (!(additionalSettings['releaseDateAsVersion'] == true &&
 | |
|           // ignore: use_build_context_synchronously
 | |
|           await showDialog(
 | |
|                 context: context,
 | |
|                 builder: (BuildContext ctx) {
 | |
|                   return GeneratedFormModal(
 | |
|                     title: tr('releaseDateAsVersion'),
 | |
|                     items: const [],
 | |
|                     message: tr('releaseDateAsVersionExplanation'),
 | |
|                   );
 | |
|                 },
 | |
|               ) ==
 | |
|               null));
 | |
|     }
 | |
| 
 | |
|     addApp({bool resetUserInputAfter = false}) async {
 | |
|       setState(() {
 | |
|         gettingAppInfo = true;
 | |
|       });
 | |
|       try {
 | |
|         var userPickedTrackOnly = additionalSettings['trackOnly'] == true;
 | |
|         App? app;
 | |
|         if ((await getTrackOnlyConfirmationIfNeeded(userPickedTrackOnly)) &&
 | |
|             (await getReleaseDateAsVersionConfirmationIfNeeded(
 | |
|               userPickedTrackOnly,
 | |
|             ))) {
 | |
|           var trackOnly = pickedSource!.enforceTrackOnly || userPickedTrackOnly;
 | |
|           app = await sourceProvider.getApp(
 | |
|             pickedSource!,
 | |
|             userInput.trim(),
 | |
|             additionalSettings,
 | |
|             trackOnlyOverride: trackOnly,
 | |
|             sourceIsOverriden: pickedSourceOverride != null,
 | |
|             inferAppIdIfOptional: inferAppIdIfOptional,
 | |
|           );
 | |
|           // Only download the APK here if you need to for the package ID
 | |
|           if (isTempId(app) && app.additionalSettings['trackOnly'] != true) {
 | |
|             // ignore: use_build_context_synchronously
 | |
|             var apkUrl = await appsProvider.confirmAppFileUrl(
 | |
|               app,
 | |
|               context,
 | |
|               false,
 | |
|             );
 | |
|             if (apkUrl == null) {
 | |
|               throw ObtainiumError(tr('cancelled'));
 | |
|             }
 | |
|             app.preferredApkIndex = app.apkUrls
 | |
|                 .map((e) => e.value)
 | |
|                 .toList()
 | |
|                 .indexOf(apkUrl.value);
 | |
|             // ignore: use_build_context_synchronously
 | |
|             var downloadedArtifact = await appsProvider.downloadApp(
 | |
|               app,
 | |
|               globalNavigatorKey.currentContext,
 | |
|               notificationsProvider: notificationsProvider,
 | |
|             );
 | |
|             DownloadedApk? downloadedFile;
 | |
|             DownloadedXApkDir? downloadedDir;
 | |
|             if (downloadedArtifact is DownloadedApk) {
 | |
|               downloadedFile = downloadedArtifact;
 | |
|             } else {
 | |
|               downloadedDir = downloadedArtifact as DownloadedXApkDir;
 | |
|             }
 | |
|             app.id = downloadedFile?.appId ?? downloadedDir!.appId;
 | |
|           }
 | |
|           if (appsProvider.apps.containsKey(app.id)) {
 | |
|             throw ObtainiumError(tr('appAlreadyAdded'));
 | |
|           }
 | |
|           if (app.additionalSettings['trackOnly'] == true ||
 | |
|               app.additionalSettings['versionDetection'] != true) {
 | |
|             app.installedVersion = app.latestVersion;
 | |
|           }
 | |
|           app.categories = pickedCategories;
 | |
|           await appsProvider.saveApps([app], onlyIfExists: false);
 | |
|         }
 | |
|         if (app != null) {
 | |
|           Navigator.push(
 | |
|             globalNavigatorKey.currentContext ?? context,
 | |
|             MaterialPageRoute(builder: (context) => AppPage(appId: app!.id)),
 | |
|           );
 | |
|         }
 | |
|       } catch (e) {
 | |
|         showError(e, context);
 | |
|       } finally {
 | |
|         setState(() {
 | |
|           gettingAppInfo = false;
 | |
|           if (resetUserInputAfter) {
 | |
|             changeUserInput('', false, true);
 | |
|           }
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     Widget getUrlInputRow() => Row(
 | |
|       children: [
 | |
|         Expanded(
 | |
|           child: GeneratedForm(
 | |
|             key: Key(urlInputKey.toString()),
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormTextField(
 | |
|                   'appSourceURL',
 | |
|                   label: tr('appSourceURL'),
 | |
|                   defaultValue: userInput,
 | |
|                   additionalValidators: [
 | |
|                     (value) {
 | |
|                       try {
 | |
|                         sourceProvider
 | |
|                             .getSource(
 | |
|                               value ?? '',
 | |
|                               overrideSource: pickedSourceOverride,
 | |
|                             )
 | |
|                             .standardizeUrl(value ?? '');
 | |
|                       } catch (e) {
 | |
|                         return e is String
 | |
|                             ? e
 | |
|                             : e is ObtainiumError
 | |
|                             ? e.toString()
 | |
|                             : tr('error');
 | |
|                       }
 | |
|                       return null;
 | |
|                     },
 | |
|                   ],
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|             onValueChanges: (values, valid, isBuilding) {
 | |
|               changeUserInput(values['appSourceURL']!, valid, isBuilding);
 | |
|             },
 | |
|           ),
 | |
|         ),
 | |
|         const SizedBox(width: 16),
 | |
|         gettingAppInfo
 | |
|             ? const CircularProgressIndicator()
 | |
|             : ElevatedButton(
 | |
|                 onPressed:
 | |
|                     doingSomething ||
 | |
|                         pickedSource == null ||
 | |
|                         (pickedSource!
 | |
|                                 .combinedAppSpecificSettingFormItems
 | |
|                                 .isNotEmpty &&
 | |
|                             !additionalSettingsValid)
 | |
|                     ? null
 | |
|                     : () {
 | |
|                         HapticFeedback.selectionClick();
 | |
|                         addApp();
 | |
|                       },
 | |
|                 child: Text(tr('add')),
 | |
|               ),
 | |
|       ],
 | |
|     );
 | |
| 
 | |
|     runSearch({bool filtered = true}) async {
 | |
|       setState(() {
 | |
|         searching = true;
 | |
|       });
 | |
|       var sourceStrings = <String, List<String>>{};
 | |
|       sourceProvider.sources.where((e) => e.canSearch).forEach((s) {
 | |
|         sourceStrings[s.name] = [s.name];
 | |
|       });
 | |
|       try {
 | |
|         var searchSources =
 | |
|             await showDialog<List<String>?>(
 | |
|               context: context,
 | |
|               builder: (BuildContext ctx) {
 | |
|                 return SelectionModal(
 | |
|                   title: tr(
 | |
|                     'selectX',
 | |
|                     args: [plural('source', 2).toLowerCase()],
 | |
|                   ),
 | |
|                   entries: sourceStrings,
 | |
|                   selectedByDefault: true,
 | |
|                   onlyOneSelectionAllowed: false,
 | |
|                   titlesAreLinks: false,
 | |
|                   deselectThese: settingsProvider.searchDeselected,
 | |
|                 );
 | |
|               },
 | |
|             ) ??
 | |
|             [];
 | |
|         if (searchSources.isNotEmpty) {
 | |
|           settingsProvider.searchDeselected = sourceStrings.keys
 | |
|               .where((s) => !searchSources.contains(s))
 | |
|               .toList();
 | |
|           List<MapEntry<String, Map<String, List<String>>>?>
 | |
|           results = (await Future.wait(
 | |
|             sourceProvider.sources
 | |
|                 .where((e) => searchSources.contains(e.name))
 | |
|                 .map((e) async {
 | |
|                   try {
 | |
|                     Map<String, dynamic>? querySettings = {};
 | |
|                     if (e.includeAdditionalOptsInMainSearch) {
 | |
|                       querySettings = await showDialog<Map<String, dynamic>?>(
 | |
|                         context: context,
 | |
|                         builder: (BuildContext ctx) {
 | |
|                           return GeneratedFormModal(
 | |
|                             title: tr('searchX', args: [e.name]),
 | |
|                             items: [
 | |
|                               ...e.searchQuerySettingFormItems.map((e) => [e]),
 | |
|                               [
 | |
|                                 GeneratedFormTextField(
 | |
|                                   'url',
 | |
|                                   label: e.hosts.isNotEmpty
 | |
|                                       ? tr('overrideSource')
 | |
|                                       : plural('url', 1).substring(2),
 | |
|                                   autoCompleteOptions: [
 | |
|                                     ...(e.hosts.isNotEmpty ? [e.hosts[0]] : []),
 | |
|                                     ...appsProvider.apps.values
 | |
|                                         .where(
 | |
|                                           (a) =>
 | |
|                                               sourceProvider
 | |
|                                                   .getSource(
 | |
|                                                     a.app.url,
 | |
|                                                     overrideSource:
 | |
|                                                         a.app.overrideSource,
 | |
|                                                   )
 | |
|                                                   .runtimeType ==
 | |
|                                               e.runtimeType,
 | |
|                                         )
 | |
|                                         .map((a) {
 | |
|                                           var uri = Uri.parse(a.app.url);
 | |
|                                           return '${uri.origin}${uri.path}';
 | |
|                                         }),
 | |
|                                   ],
 | |
|                                   defaultValue: e.hosts.isNotEmpty
 | |
|                                       ? e.hosts[0]
 | |
|                                       : '',
 | |
|                                   required: true,
 | |
|                                 ),
 | |
|                               ],
 | |
|                             ],
 | |
|                           );
 | |
|                         },
 | |
|                       );
 | |
|                       if (querySettings == null) {
 | |
|                         return null;
 | |
|                       }
 | |
|                     }
 | |
|                     return MapEntry(
 | |
|                       e.runtimeType.toString(),
 | |
|                       await e.search(searchQuery, querySettings: querySettings),
 | |
|                     );
 | |
|                   } catch (err) {
 | |
|                     if (err is! CredsNeededError) {
 | |
|                       rethrow;
 | |
|                     } else {
 | |
|                       err.unexpected = true;
 | |
|                       showError(err, context);
 | |
|                       return null;
 | |
|                     }
 | |
|                   }
 | |
|                 }),
 | |
|           )).where((a) => a != null).toList();
 | |
| 
 | |
|           // Interleave results instead of simple reduce
 | |
|           Map<String, MapEntry<String, List<String>>> res = {};
 | |
|           var si = 0;
 | |
|           var done = false;
 | |
|           while (!done) {
 | |
|             done = true;
 | |
|             for (var r in results) {
 | |
|               var sourceName = r!.key;
 | |
|               if (r.value.length > si) {
 | |
|                 done = false;
 | |
|                 var singleRes = r.value.entries.elementAt(si);
 | |
|                 res[singleRes.key] = MapEntry(sourceName, singleRes.value);
 | |
|               }
 | |
|             }
 | |
|             si++;
 | |
|           }
 | |
|           if (res.isEmpty) {
 | |
|             throw ObtainiumError(tr('noResults'));
 | |
|           }
 | |
|           List<String>? selectedUrls = res.isEmpty
 | |
|               ? []
 | |
|               // ignore: use_build_context_synchronously
 | |
|               : await showDialog<List<String>?>(
 | |
|                   context: context,
 | |
|                   builder: (BuildContext ctx) {
 | |
|                     return SelectionModal(
 | |
|                       entries: res.map((k, v) => MapEntry(k, v.value)),
 | |
|                       selectedByDefault: false,
 | |
|                       onlyOneSelectionAllowed: true,
 | |
|                     );
 | |
|                   },
 | |
|                 );
 | |
|           if (selectedUrls != null && selectedUrls.isNotEmpty) {
 | |
|             var sourceName = res[selectedUrls[0]]?.key;
 | |
|             changeUserInput(
 | |
|               selectedUrls[0],
 | |
|               true,
 | |
|               false,
 | |
|               updateUrlInput: true,
 | |
|               overrideSource: sourceName,
 | |
|             );
 | |
|           }
 | |
|         }
 | |
|       } catch (e) {
 | |
|         showError(e, context);
 | |
|       } finally {
 | |
|         setState(() {
 | |
|           searching = false;
 | |
|         });
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     Widget getHTMLSourceOverrideDropdown() => Column(
 | |
|       children: [
 | |
|         Row(
 | |
|           children: [
 | |
|             Expanded(
 | |
|               child: GeneratedForm(
 | |
|                 items: [
 | |
|                   [
 | |
|                     GeneratedFormDropdown(
 | |
|                       'overrideSource',
 | |
|                       defaultValue: pickedSourceOverride ?? '',
 | |
|                       [
 | |
|                         MapEntry('', tr('none')),
 | |
|                         ...sourceProvider.sources
 | |
|                             .where(
 | |
|                               (s) =>
 | |
|                                   s.allowOverride ||
 | |
|                                   (pickedSource != null &&
 | |
|                                       pickedSource.runtimeType ==
 | |
|                                           s.runtimeType),
 | |
|                             )
 | |
|                             .map(
 | |
|                               (s) => MapEntry(s.runtimeType.toString(), s.name),
 | |
|                             ),
 | |
|                       ],
 | |
|                       label: tr('overrideSource'),
 | |
|                     ),
 | |
|                   ],
 | |
|                 ],
 | |
|                 onValueChanges: (values, valid, isBuilding) {
 | |
|                   fn() {
 | |
|                     pickedSourceOverride =
 | |
|                         (values['overrideSource'] == null ||
 | |
|                             values['overrideSource'] == '')
 | |
|                         ? null
 | |
|                         : values['overrideSource'];
 | |
|                   }
 | |
| 
 | |
|                   if (!isBuilding) {
 | |
|                     setState(() {
 | |
|                       fn();
 | |
|                     });
 | |
|                   } else {
 | |
|                     fn();
 | |
|                   }
 | |
|                   changeUserInput(userInput, valid, isBuilding);
 | |
|                 },
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|         const SizedBox(height: 16),
 | |
|       ],
 | |
|     );
 | |
| 
 | |
|     bool shouldShowSearchBar() =>
 | |
|         sourceProvider.sources.where((e) => e.canSearch).isNotEmpty &&
 | |
|         pickedSource == null &&
 | |
|         userInput.isEmpty;
 | |
| 
 | |
|     Widget getSearchBarRow() => Row(
 | |
|       children: [
 | |
|         Expanded(
 | |
|           child: GeneratedForm(
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormTextField(
 | |
|                   'searchSomeSources',
 | |
|                   label: tr('searchSomeSourcesLabel'),
 | |
|                   required: false,
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|             onValueChanges: (values, valid, isBuilding) {
 | |
|               if (values.isNotEmpty && valid && !isBuilding) {
 | |
|                 setState(() {
 | |
|                   searchQuery = values['searchSomeSources']!.trim();
 | |
|                 });
 | |
|               }
 | |
|             },
 | |
|           ),
 | |
|         ),
 | |
|         const SizedBox(width: 16),
 | |
|         searching
 | |
|             ? const CircularProgressIndicator()
 | |
|             : ElevatedButton(
 | |
|                 onPressed: searchQuery.isEmpty || doingSomething
 | |
|                     ? null
 | |
|                     : () {
 | |
|                         runSearch();
 | |
|                       },
 | |
|                 child: Text(tr('search')),
 | |
|               ),
 | |
|       ],
 | |
|     );
 | |
| 
 | |
|     Widget getAdditionalOptsCol() => Column(
 | |
|       crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|       children: [
 | |
|         const SizedBox(height: 16),
 | |
|         Text(
 | |
|           tr('additionalOptsFor', args: [pickedSource?.name ?? tr('source')]),
 | |
|           style: TextStyle(
 | |
|             color: Theme.of(context).colorScheme.primary,
 | |
|             fontWeight: FontWeight.bold,
 | |
|           ),
 | |
|         ),
 | |
|         const SizedBox(height: 16),
 | |
|         GeneratedForm(
 | |
|           key: Key(
 | |
|             '${pickedSource.runtimeType.toString()}-${pickedSource?.hostChanged.toString()}-${pickedSource?.hostIdenticalDespiteAnyChange.toString()}',
 | |
|           ),
 | |
|           items: [
 | |
|             ...pickedSource!.combinedAppSpecificSettingFormItems,
 | |
|             ...(pickedSourceOverride != null
 | |
|                 ? pickedSource!.sourceConfigSettingFormItems.map((e) => [e])
 | |
|                 : []),
 | |
|           ],
 | |
|           onValueChanges: (values, valid, isBuilding) {
 | |
|             if (!isBuilding) {
 | |
|               setState(() {
 | |
|                 additionalSettings = values;
 | |
|                 additionalSettingsValid = valid;
 | |
|               });
 | |
|             }
 | |
|           },
 | |
|         ),
 | |
|         Column(
 | |
|           children: [
 | |
|             const SizedBox(height: 16),
 | |
|             CategoryEditorSelector(
 | |
|               alignment: WrapAlignment.start,
 | |
|               onSelected: (categories) {
 | |
|                 pickedCategories = categories;
 | |
|               },
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|         if (pickedSource != null && pickedSource!.appIdInferIsOptional)
 | |
|           GeneratedForm(
 | |
|             key: const Key('inferAppIdIfOptional'),
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormSwitch(
 | |
|                   'inferAppIdIfOptional',
 | |
|                   label: tr('tryInferAppIdFromCode'),
 | |
|                   defaultValue: inferAppIdIfOptional,
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|             onValueChanges: (values, valid, isBuilding) {
 | |
|               if (!isBuilding) {
 | |
|                 setState(() {
 | |
|                   inferAppIdIfOptional = values['inferAppIdIfOptional'];
 | |
|                 });
 | |
|               }
 | |
|             },
 | |
|           ),
 | |
|         if (pickedSource != null && pickedSource!.enforceTrackOnly)
 | |
|           GeneratedForm(
 | |
|             key: Key(
 | |
|               '${pickedSource.runtimeType.toString()}-${pickedSource?.hostChanged.toString()}-${pickedSource?.hostIdenticalDespiteAnyChange.toString()}-appId',
 | |
|             ),
 | |
|             items: [
 | |
|               [
 | |
|                 GeneratedFormTextField(
 | |
|                   'appId',
 | |
|                   label: '${tr('appId')} - ${tr('custom')}',
 | |
|                   required: false,
 | |
|                   additionalValidators: [
 | |
|                     (value) {
 | |
|                       if (value == null || value.isEmpty) {
 | |
|                         return null;
 | |
|                       }
 | |
|                       final isValid = RegExp(
 | |
|                         r'^([A-Za-z]{1}[A-Za-z\d_]*\.)+[A-Za-z][A-Za-z\d_]*$',
 | |
|                       ).hasMatch(value);
 | |
|                       if (!isValid) {
 | |
|                         return tr('invalidInput');
 | |
|                       }
 | |
|                       return null;
 | |
|                     },
 | |
|                   ],
 | |
|                 ),
 | |
|               ],
 | |
|             ],
 | |
|             onValueChanges: (values, valid, isBuilding) {
 | |
|               if (!isBuilding) {
 | |
|                 setState(() {
 | |
|                   additionalSettings['appId'] = values['appId'];
 | |
|                 });
 | |
|               }
 | |
|             },
 | |
|           ),
 | |
|       ],
 | |
|     );
 | |
| 
 | |
|     Widget getSourcesListWidget() => Padding(
 | |
|       padding: const EdgeInsets.all(16),
 | |
|       child: Wrap(
 | |
|         direction: Axis.horizontal,
 | |
|         alignment: WrapAlignment.spaceBetween,
 | |
|         spacing: 12,
 | |
|         children: [
 | |
|           GestureDetector(
 | |
|             onTap: () {
 | |
|               showDialog(
 | |
|                 context: context,
 | |
|                 builder: (context) {
 | |
|                   return GeneratedFormModal(
 | |
|                     singleNullReturnButton: tr('ok'),
 | |
|                     title: tr('supportedSources'),
 | |
|                     items: const [],
 | |
|                     additionalWidgets: [
 | |
|                       ...sourceProvider.sources.map(
 | |
|                         (e) => Padding(
 | |
|                           padding: const EdgeInsets.symmetric(vertical: 4),
 | |
|                           child: GestureDetector(
 | |
|                             onTap: e.hosts.isNotEmpty
 | |
|                                 ? () {
 | |
|                                     launchUrlString(
 | |
|                                       'https://${e.hosts[0]}',
 | |
|                                       mode: LaunchMode.externalApplication,
 | |
|                                     );
 | |
|                                   }
 | |
|                                 : null,
 | |
|                             child: Text(
 | |
|                               '${e.name}${e.enforceTrackOnly ? ' ${tr('trackOnlyInBrackets')}' : ''}${e.canSearch ? ' ${tr('searchableInBrackets')}' : ''}',
 | |
|                               style: TextStyle(
 | |
|                                 decoration: e.hosts.isNotEmpty
 | |
|                                     ? TextDecoration.underline
 | |
|                                     : TextDecoration.none,
 | |
|                               ),
 | |
|                             ),
 | |
|                           ),
 | |
|                         ),
 | |
|                       ),
 | |
|                       const SizedBox(height: 16),
 | |
|                       Text(
 | |
|                         '${tr('note')}:',
 | |
|                         style: const TextStyle(fontWeight: FontWeight.bold),
 | |
|                       ),
 | |
|                       const SizedBox(height: 4),
 | |
|                       Text(tr('selfHostedNote', args: [tr('overrideSource')])),
 | |
|                     ],
 | |
|                   );
 | |
|                 },
 | |
|               );
 | |
|             },
 | |
|             child: Text(
 | |
|               tr('supportedSources'),
 | |
|               style: const TextStyle(
 | |
|                 fontWeight: FontWeight.bold,
 | |
|                 decoration: TextDecoration.underline,
 | |
|                 fontStyle: FontStyle.italic,
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|           GestureDetector(
 | |
|             onTap: () {
 | |
|               launchUrlString(
 | |
|                 'https://apps.obtainium.imranr.dev/',
 | |
|                 mode: LaunchMode.externalApplication,
 | |
|               );
 | |
|             },
 | |
|             child: Text(
 | |
|               tr('crowdsourcedConfigsShort'),
 | |
|               style: const TextStyle(
 | |
|                 fontWeight: FontWeight.bold,
 | |
|                 decoration: TextDecoration.underline,
 | |
|                 fontStyle: FontStyle.italic,
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
| 
 | |
|     return Scaffold(
 | |
|       backgroundColor: Theme.of(context).colorScheme.surface,
 | |
|       bottomNavigationBar: pickedSource == null ? getSourcesListWidget() : null,
 | |
|       body: CustomScrollView(
 | |
|         shrinkWrap: true,
 | |
|         slivers: <Widget>[
 | |
|           CustomAppBar(title: tr('addApp')),
 | |
|           SliverToBoxAdapter(
 | |
|             child: Padding(
 | |
|               padding: const EdgeInsets.all(16),
 | |
|               child: Column(
 | |
|                 mainAxisSize: MainAxisSize.min,
 | |
|                 crossAxisAlignment: CrossAxisAlignment.stretch,
 | |
|                 children: [
 | |
|                   getUrlInputRow(),
 | |
|                   const SizedBox(height: 16),
 | |
|                   if (pickedSource != null) getHTMLSourceOverrideDropdown(),
 | |
|                   if (shouldShowSearchBar()) getSearchBarRow(),
 | |
|                   if (pickedSource != null)
 | |
|                     FutureBuilder(
 | |
|                       builder: (ctx, val) {
 | |
|                         return val.data != null && val.data!.isNotEmpty
 | |
|                             ? Text(
 | |
|                                 val.data!,
 | |
|                                 style: Theme.of(context).textTheme.bodySmall,
 | |
|                               )
 | |
|                             : const SizedBox();
 | |
|                       },
 | |
|                       future: pickedSource?.getSourceNote(),
 | |
|                     ),
 | |
|                   if (pickedSource != null) getAdditionalOptsCol(),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |