Files
Obtainium/lib/pages/add_app.dart
2025-07-02 20:40:09 -04:00

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(),
],
),
),
),
],
),
);
}
}