mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-11-03 23:03:29 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			691 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			691 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();
 | 
						|
 | 
						|
  linkFn(String input) {
 | 
						|
    try {
 | 
						|
      if (input.isEmpty) {
 | 
						|
        throw UnsupportedURLError();
 | 
						|
      }
 | 
						|
      sourceProvider.getSource(input);
 | 
						|
      changeUserInput(input, true, false, updateUrlInput: true);
 | 
						|
    } catch (e) {
 | 
						|
      showError(e, context);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  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)]),
 | 
						|
                    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.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: Row(
 | 
						|
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
						|
            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(),
 | 
						|
                    ])),
 | 
						|
          )
 | 
						|
        ]));
 | 
						|
  }
 | 
						|
}
 |