Files
Obtainium/lib/pages/add_app.dart

612 lines
23 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;
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}) {
userInput = input;
if (!isBuilding) {
setState(() {
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 ||
(prevHost != null && prevHost != source?.hosts[0])) {
pickedSource = source;
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();
var 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),
defaultValue:
e.hosts.isNotEmpty ? e.hosts[0] : '',
required: true)
],
],
);
});
if (querySettings == null) {
return <String, List<String>>{};
}
}
return await e.search(searchQuery, querySettings: querySettings);
} catch (err) {
if (err is! CredsNeededError) {
rethrow;
} else {
err.unexpected = true;
showError(err, context);
return <String, List<String>>{};
}
}
}));
// Interleave results instead of simple reduce
Map<String, List<String>> res = {};
var si = 0;
var done = false;
while (!done) {
done = true;
for (var r in results) {
if (r.length > si) {
done = false;
res.addEntries([r.entries.elementAt(si)]);
}
}
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,
selectedByDefault: false,
onlyOneSelectionAllowed: true,
);
});
if (selectedUrls != null && selectedUrls.isNotEmpty) {
changeUserInput(selectedUrls[0], true, false, updateUrlInput: true);
}
}
} catch (e) {
showError(e, context);
} finally {
setState(() {
searching = false;
});
}
}
Widget getHTMLSourceOverrideDropdown() => Column(children: [
Row(
children: [
Expanded(
child: GeneratedForm(
items: [
[
GeneratedFormDropdown(
'overrideSource',
defaultValue: '',
[
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()),
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'];
});
}
}),
],
);
Widget getSourcesListWidget() => Padding(
padding: const EdgeInsets.all(16),
child: Row(
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),
))
],
),
);
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(),
])),
)
]));
}
}