Repo created
This commit is contained in:
parent
2fd78f5dc9
commit
489cf0b2ea
148 changed files with 30898 additions and 2 deletions
771
lib/pages/add_app.dart
Normal file
771
lib/pages/add_app.dart
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
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;
|
||||
DownloadedDir? downloadedDir;
|
||||
if (downloadedArtifact is DownloadedApk) {
|
||||
downloadedFile = downloadedArtifact;
|
||||
} else {
|
||||
downloadedDir = downloadedArtifact as DownloadedDir;
|
||||
}
|
||||
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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
695
lib/pages/app.dart
Normal file
695
lib/pages/app.dart
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/main.dart';
|
||||
import 'package:obtainium/pages/apps.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class AppPage extends StatefulWidget {
|
||||
const AppPage({
|
||||
super.key,
|
||||
required this.appId,
|
||||
this.showOppositeOfPreferredView = false,
|
||||
});
|
||||
|
||||
final String appId;
|
||||
final bool showOppositeOfPreferredView;
|
||||
|
||||
@override
|
||||
State<AppPage> createState() => _AppPageState();
|
||||
}
|
||||
|
||||
class _AppPageState extends State<AppPage> {
|
||||
late final WebViewController _webViewController;
|
||||
bool _wasWebViewOpened = false;
|
||||
AppInMemory? prevApp;
|
||||
bool updating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_webViewController = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
if (error.isForMainFrame == true) {
|
||||
showError(
|
||||
ObtainiumError(error.description, unexpected: true),
|
||||
context,
|
||||
);
|
||||
}
|
||||
},
|
||||
onNavigationRequest: (NavigationRequest request) =>
|
||||
!(request.url.startsWith("http://") ||
|
||||
request.url.startsWith("https://") ||
|
||||
request.url.startsWith("ftp://") ||
|
||||
request.url.startsWith("ftps://"))
|
||||
? NavigationDecision.prevent
|
||||
: NavigationDecision.navigate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
var showAppWebpageFinal =
|
||||
(settingsProvider.showAppWebpage &&
|
||||
!widget.showOppositeOfPreferredView) ||
|
||||
(!settingsProvider.showAppWebpage &&
|
||||
widget.showOppositeOfPreferredView);
|
||||
getUpdate(String id, {bool resetVersion = false}) async {
|
||||
try {
|
||||
setState(() {
|
||||
updating = true;
|
||||
});
|
||||
await appsProvider.checkUpdate(id);
|
||||
if (resetVersion) {
|
||||
appsProvider.apps[id]?.app.additionalSettings['versionDetection'] =
|
||||
true;
|
||||
if (appsProvider.apps[id]?.app.installedVersion != null) {
|
||||
appsProvider.apps[id]?.app.installedVersion =
|
||||
appsProvider.apps[id]?.app.latestVersion;
|
||||
}
|
||||
appsProvider.saveApps([appsProvider.apps[id]!.app]);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(err, context);
|
||||
} finally {
|
||||
setState(() {
|
||||
updating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bool areDownloadsRunning = appsProvider.areDownloadsRunning();
|
||||
|
||||
var sourceProvider = SourceProvider();
|
||||
AppInMemory? app = appsProvider.apps[widget.appId]?.deepCopy();
|
||||
var source = app != null
|
||||
? sourceProvider.getSource(
|
||||
app.app.url,
|
||||
overrideSource: app.app.overrideSource,
|
||||
)
|
||||
: null;
|
||||
if (!areDownloadsRunning &&
|
||||
prevApp == null &&
|
||||
app != null &&
|
||||
settingsProvider.checkUpdateOnDetailPage) {
|
||||
prevApp = app;
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
var trackOnly = app?.app.additionalSettings['trackOnly'] == true;
|
||||
|
||||
bool isVersionDetectionStandard =
|
||||
app?.app.additionalSettings['versionDetection'] == true;
|
||||
|
||||
bool installedVersionIsEstimate = app?.app != null
|
||||
? isVersionPseudo(app!.app)
|
||||
: false;
|
||||
|
||||
if (app != null && !_wasWebViewOpened) {
|
||||
_wasWebViewOpened = true;
|
||||
_webViewController.loadRequest(Uri.parse(app.app.url));
|
||||
}
|
||||
|
||||
getInfoColumn() {
|
||||
String versionLines = '';
|
||||
bool installed = app?.app.installedVersion != null;
|
||||
bool upToDate = app?.app.installedVersion == app?.app.latestVersion;
|
||||
if (installed) {
|
||||
versionLines = '${app?.app.installedVersion} ${tr('installed')}';
|
||||
if (upToDate) {
|
||||
versionLines += '/${tr('latest')}';
|
||||
}
|
||||
} else {
|
||||
versionLines = tr('notInstalled');
|
||||
}
|
||||
if (!upToDate) {
|
||||
versionLines += '\n${app?.app.latestVersion} ${tr('latest')}';
|
||||
}
|
||||
String infoLines = tr(
|
||||
'lastUpdateCheckX',
|
||||
args: [
|
||||
app?.app.lastUpdateCheck == null
|
||||
? tr('never')
|
||||
: '${app?.app.lastUpdateCheck?.toLocal()}',
|
||||
],
|
||||
);
|
||||
if (trackOnly) {
|
||||
infoLines = '${tr('xIsTrackOnly', args: [tr('app')])}\n$infoLines';
|
||||
}
|
||||
if (installedVersionIsEstimate) {
|
||||
infoLines = '${tr('pseudoVersionInUse')}\n$infoLines';
|
||||
}
|
||||
if ((app?.app.apkUrls.length ?? 0) > 0) {
|
||||
infoLines =
|
||||
'$infoLines\n${app?.app.apkUrls.length == 1 ? app?.app.apkUrls[0].key : plural('apk', app?.app.apkUrls.length ?? 0)}';
|
||||
}
|
||||
var changeLogFn = app != null ? getChangeLogFn(context, app.app) : null;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
versionLines,
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
changeLogFn != null || app?.app.releaseDate != null
|
||||
? GestureDetector(
|
||||
onTap: changeLogFn,
|
||||
child: Text(
|
||||
app?.app.releaseDate == null
|
||||
? tr('changes')
|
||||
: app!.app.releaseDate!.toLocal().toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall!
|
||||
.copyWith(
|
||||
decoration: changeLogFn != null
|
||||
? TextDecoration.underline
|
||||
: null,
|
||||
fontStyle: changeLogFn != null
|
||||
? FontStyle.italic
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
infoLines,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
|
||||
),
|
||||
if (app?.app.apkUrls.isNotEmpty == true ||
|
||||
app?.app.otherAssetUrls.isNotEmpty == true)
|
||||
GestureDetector(
|
||||
onTap: app?.app == null || updating
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
await appsProvider.downloadAppAssets([
|
||||
app!.app.id,
|
||||
], context);
|
||||
} catch (e) {
|
||||
showError(e, context);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: settingsProvider.highlightTouchTargets
|
||||
? (Theme.of(context).brightness == Brightness.light
|
||||
? Theme.of(context).primaryColor
|
||||
: Theme.of(context).primaryColorLight)
|
||||
.withAlpha(
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.light
|
||||
? 20
|
||||
: 40,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
padding: settingsProvider.highlightTouchTargets
|
||||
? const EdgeInsetsDirectional.fromSTEB(12, 6, 12, 6)
|
||||
: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 6),
|
||||
margin: const EdgeInsetsDirectional.fromSTEB(0, 6, 0, 0),
|
||||
child: Text(
|
||||
tr(
|
||||
'downloadX',
|
||||
args: [lowerCaseIfEnglish(tr('releaseAsset'))],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
CategoryEditorSelector(
|
||||
alignment: WrapAlignment.center,
|
||||
preselected: app?.app.categories != null
|
||||
? app!.app.categories.toSet()
|
||||
: {},
|
||||
onSelected: (categories) {
|
||||
if (app != null) {
|
||||
app.app.categories = categories;
|
||||
appsProvider.saveApps([app.app]);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (app?.app.additionalSettings['about'] is String &&
|
||||
app?.app.additionalSettings['about'].isNotEmpty)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 48),
|
||||
GestureDetector(
|
||||
onLongPress: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: app?.app.additionalSettings['about'] ?? '',
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(tr('copiedToClipboard'))),
|
||||
);
|
||||
},
|
||||
child: Markdown(
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
),
|
||||
textAlign: WrapAlignment.center,
|
||||
),
|
||||
data: app?.app.additionalSettings['about'],
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
},
|
||||
extensionSet: md.ExtensionSet(
|
||||
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
|
||||
[
|
||||
md.EmojiSyntax(),
|
||||
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
getFullInfoColumn({bool small = false}) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: small ? 5 : 20),
|
||||
FutureBuilder(
|
||||
future: appsProvider.updateAppIcon(app?.app.id, ignoreCache: true),
|
||||
builder: (ctx, val) {
|
||||
return app?.icon != null
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: app == null
|
||||
? null
|
||||
: () => pm.openApp(app.app.id),
|
||||
child: Image.memory(
|
||||
app!.icon!,
|
||||
height: small ? 70 : 150,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Container();
|
||||
},
|
||||
),
|
||||
SizedBox(height: small ? 10 : 25),
|
||||
Text(
|
||||
app?.name ?? tr('app'),
|
||||
textAlign: TextAlign.center,
|
||||
style: small
|
||||
? Theme.of(context).textTheme.displaySmall
|
||||
: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
tr('byX', args: [app?.author ?? tr('unknown')]),
|
||||
textAlign: TextAlign.center,
|
||||
style: small
|
||||
? Theme.of(context).textTheme.headlineSmall
|
||||
: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(
|
||||
app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
Clipboard.setData(ClipboardData(text: app?.app.url ?? ''));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(tr('copiedToClipboard'))));
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
app?.app.id ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
getInfoColumn(),
|
||||
const SizedBox(height: 150),
|
||||
],
|
||||
);
|
||||
|
||||
getAppWebView() => app != null
|
||||
? WebViewWidget(
|
||||
key: ObjectKey(_webViewController),
|
||||
controller: _webViewController
|
||||
..setBackgroundColor(Theme.of(context).colorScheme.surface),
|
||||
)
|
||||
: Container();
|
||||
|
||||
showMarkUpdatedDialog() {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr('alreadyUpToDateQuestion')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('no')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.selectionClick();
|
||||
var updatedApp = app?.app;
|
||||
if (updatedApp != null) {
|
||||
updatedApp.installedVersion = updatedApp.latestVersion;
|
||||
appsProvider.saveApps([updatedApp]);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('yesMarkUpdated')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
showAdditionalOptionsDialog() async {
|
||||
return await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
var items = (source?.combinedAppSpecificSettingFormItems ?? []).map((
|
||||
row,
|
||||
) {
|
||||
row = row.map((e) {
|
||||
if (app?.app.additionalSettings[e.key] != null) {
|
||||
e.defaultValue = app?.app.additionalSettings[e.key];
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
return row;
|
||||
}).toList();
|
||||
|
||||
return GeneratedFormModal(
|
||||
title: tr('additionalOptions'),
|
||||
items: items,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
handleAdditionalOptionChanges(Map<String, dynamic>? values) {
|
||||
if (app != null && values != null) {
|
||||
Map<String, dynamic> originalSettings = app.app.additionalSettings;
|
||||
app.app.additionalSettings = values;
|
||||
if (source?.enforceTrackOnly == true) {
|
||||
app.app.additionalSettings['trackOnly'] = true;
|
||||
// ignore: use_build_context_synchronously
|
||||
showMessage(tr('appsFromSourceAreTrackOnly'), context);
|
||||
}
|
||||
var versionDetectionEnabled =
|
||||
app.app.additionalSettings['versionDetection'] == true &&
|
||||
originalSettings['versionDetection'] != true;
|
||||
var releaseDateVersionEnabled =
|
||||
app.app.additionalSettings['releaseDateAsVersion'] == true &&
|
||||
originalSettings['releaseDateAsVersion'] != true;
|
||||
var releaseDateVersionDisabled =
|
||||
app.app.additionalSettings['releaseDateAsVersion'] != true &&
|
||||
originalSettings['releaseDateAsVersion'] == true;
|
||||
if (releaseDateVersionEnabled) {
|
||||
if (app.app.releaseDate != null) {
|
||||
bool isUpdated = app.app.installedVersion == app.app.latestVersion;
|
||||
app.app.latestVersion = app.app.releaseDate!.microsecondsSinceEpoch
|
||||
.toString();
|
||||
if (isUpdated) {
|
||||
app.app.installedVersion = app.app.latestVersion;
|
||||
}
|
||||
}
|
||||
} else if (releaseDateVersionDisabled) {
|
||||
app.app.installedVersion =
|
||||
app.installedInfo?.versionName ?? app.app.installedVersion;
|
||||
}
|
||||
if (versionDetectionEnabled) {
|
||||
app.app.additionalSettings['versionDetection'] = true;
|
||||
app.app.additionalSettings['releaseDateAsVersion'] = false;
|
||||
}
|
||||
appsProvider.saveApps([app.app]).then((value) {
|
||||
getUpdate(app.app.id, resetVersion: versionDetectionEnabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getInstallOrUpdateButton() => TextButton(
|
||||
onPressed:
|
||||
!updating &&
|
||||
(app?.app.installedVersion == null ||
|
||||
app?.app.installedVersion != app?.app.latestVersion) &&
|
||||
!areDownloadsRunning
|
||||
? () async {
|
||||
try {
|
||||
var successMessage = app?.app.installedVersion == null
|
||||
? tr('installed')
|
||||
: tr('appsUpdated');
|
||||
HapticFeedback.heavyImpact();
|
||||
var res = await appsProvider.downloadAndInstallLatestApps(
|
||||
app?.app.id != null ? [app!.app.id] : [],
|
||||
globalNavigatorKey.currentContext,
|
||||
);
|
||||
if (res.isNotEmpty && !trackOnly) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showMessage(successMessage, context);
|
||||
}
|
||||
if (res.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showError(e, context);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
app?.app.installedVersion == null
|
||||
? !trackOnly
|
||||
? tr('install')
|
||||
: tr('markInstalled')
|
||||
: !trackOnly
|
||||
? tr('update')
|
||||
: tr('markUpdated'),
|
||||
),
|
||||
);
|
||||
|
||||
getBottomSheetMenu() => Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (source != null &&
|
||||
source.combinedAppSpecificSettingFormItems.isNotEmpty)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null || updating
|
||||
? null
|
||||
: () async {
|
||||
var values = await showAdditionalOptionsDialog();
|
||||
handleAdditionalOptionChanges(values);
|
||||
},
|
||||
tooltip: tr('additionalOptions'),
|
||||
icon: const Icon(Icons.edit),
|
||||
),
|
||||
if (app != null && app.installedInfo != null)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
appsProvider.openAppSettings(app.app.id);
|
||||
},
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: tr('settings'),
|
||||
),
|
||||
if (app != null && showAppWebpageFinal)
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
content: getFullInfoColumn(small: true),
|
||||
title: Text(app.name),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('continue')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
tooltip: tr('more'),
|
||||
),
|
||||
if (app?.app.installedVersion != null &&
|
||||
app?.app.installedVersion != app?.app.latestVersion &&
|
||||
!isVersionDetectionStandard &&
|
||||
!trackOnly)
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null || updating
|
||||
? null
|
||||
: showMarkUpdatedDialog,
|
||||
tooltip: tr('markUpdated'),
|
||||
icon: const Icon(Icons.done),
|
||||
),
|
||||
if ((!isVersionDetectionStandard || trackOnly) &&
|
||||
app?.app.installedVersion != null &&
|
||||
app?.app.installedVersion == app?.app.latestVersion)
|
||||
IconButton(
|
||||
onPressed: app?.app == null || updating
|
||||
? null
|
||||
: () {
|
||||
app!.app.installedVersion = null;
|
||||
appsProvider.saveApps([app.app]);
|
||||
},
|
||||
icon: const Icon(Icons.restore_rounded),
|
||||
tooltip: tr('resetInstallStatus'),
|
||||
),
|
||||
const SizedBox(width: 16.0),
|
||||
Expanded(child: getInstallOrUpdateButton()),
|
||||
const SizedBox(width: 16.0),
|
||||
IconButton(
|
||||
onPressed: app?.downloadProgress != null || updating
|
||||
? null
|
||||
: () {
|
||||
appsProvider
|
||||
.removeAppsWithModal(
|
||||
context,
|
||||
app != null ? [app.app] : [],
|
||||
)
|
||||
.then((value) {
|
||||
if (value == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
},
|
||||
tooltip: tr('remove'),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (app?.downloadProgress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
|
||||
child: LinearProgressIndicator(
|
||||
value: app!.downloadProgress! >= 0
|
||||
? app.downloadProgress! / 100
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
appScreenAppBar() => AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: showAppWebpageFinal ? AppBar() : appScreenAppBar(),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: RefreshIndicator(
|
||||
child: showAppWebpageFinal
|
||||
? getAppWebView()
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Column(children: [getFullInfoColumn()]),
|
||||
),
|
||||
],
|
||||
),
|
||||
onRefresh: () async {
|
||||
if (app != null) {
|
||||
getUpdate(app.app.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
bottomSheet: getBottomSheetMenu(),
|
||||
);
|
||||
}
|
||||
}
|
||||
1372
lib/pages/apps.dart
Normal file
1372
lib/pages/apps.dart
Normal file
File diff suppressed because it is too large
Load diff
351
lib/pages/home.dart
Normal file
351
lib/pages/home.dart
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:android_intent_plus/android_intent.dart';
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/pages/add_app.dart';
|
||||
import 'package:obtainium/pages/apps.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/settings_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class NavigationPageItem {
|
||||
late String title;
|
||||
late IconData icon;
|
||||
late Widget widget;
|
||||
|
||||
NavigationPageItem(this.title, this.icon, this.widget);
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
List<int> selectedIndexHistory = [];
|
||||
bool isReversing = false;
|
||||
int prevAppCount = -1;
|
||||
bool prevIsLoading = true;
|
||||
late AppLinks _appLinks;
|
||||
StreamSubscription<Uri>? _linkSubscription;
|
||||
bool isLinkActivity = false;
|
||||
|
||||
List<NavigationPageItem> pages = [
|
||||
NavigationPageItem(
|
||||
tr('appsString'),
|
||||
Icons.apps,
|
||||
AppsPage(key: GlobalKey<AppsPageState>()),
|
||||
),
|
||||
NavigationPageItem(
|
||||
tr('addApp'),
|
||||
Icons.add,
|
||||
AddAppPage(key: GlobalKey<AddAppPageState>()),
|
||||
),
|
||||
NavigationPageItem(
|
||||
tr('importExport'),
|
||||
Icons.import_export,
|
||||
const ImportExportPage(),
|
||||
),
|
||||
NavigationPageItem(tr('settings'), Icons.settings, const SettingsPage()),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDeepLinks();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
var sp = context.read<SettingsProvider>();
|
||||
if (!sp.welcomeShown) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
title: Text(tr('welcome')),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 20,
|
||||
children: [
|
||||
Text(tr('documentationLinksNote')),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString(
|
||||
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'https://github.com/ImranR98/Obtainium/blob/main/README.md',
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(tr('batteryOptimizationNote')),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final intent = AndroidIntent(
|
||||
action:
|
||||
'android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS',
|
||||
package:
|
||||
obtainiumId, // Replace with your app's package name
|
||||
);
|
||||
|
||||
intent.launch();
|
||||
},
|
||||
child: Text(
|
||||
tr('settings'),
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
sp.welcomeShown = true;
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: Text(tr('ok')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> initDeepLinks() async {
|
||||
_appLinks = AppLinks();
|
||||
|
||||
goToAddApp(String data) async {
|
||||
switchToPage(1);
|
||||
while ((pages[1].widget.key as GlobalKey<AddAppPageState>?)
|
||||
?.currentState ==
|
||||
null) {
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
(pages[1].widget.key as GlobalKey<AddAppPageState>?)?.currentState
|
||||
?.linkFn(data);
|
||||
}
|
||||
|
||||
interpretLink(Uri uri) async {
|
||||
isLinkActivity = true;
|
||||
var action = uri.host;
|
||||
var data = uri.path.length > 1 ? uri.path.substring(1) : "";
|
||||
try {
|
||||
if (action == 'add') {
|
||||
await goToAddApp(data);
|
||||
} else if (action == 'app' || action == 'apps') {
|
||||
var dataStr = Uri.decodeComponent(data);
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr(
|
||||
'importX',
|
||||
args: [
|
||||
(action == 'app' ? tr('app') : tr('appsString'))
|
||||
.toLowerCase(),
|
||||
],
|
||||
),
|
||||
items: const [],
|
||||
additionalWidgets: [
|
||||
ExpansionTile(
|
||||
title: const Text('Raw JSON'),
|
||||
children: [
|
||||
Text(
|
||||
dataStr,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
) !=
|
||||
null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var result = await appsProvider.import(
|
||||
action == 'app'
|
||||
? '{ "apps": [$dataStr] }'
|
||||
: '{ "apps": $dataStr }',
|
||||
);
|
||||
// ignore: use_build_context_synchronously
|
||||
showMessage(
|
||||
tr(
|
||||
'importedX',
|
||||
args: [plural('apps', result.key.length).toLowerCase()],
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(tr('unknown'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e, context);
|
||||
}
|
||||
}
|
||||
|
||||
// Check initial link if app was in cold state (terminated)
|
||||
final appLink = await _appLinks.getInitialLink();
|
||||
var initLinked = false;
|
||||
if (appLink != null) {
|
||||
await interpretLink(appLink);
|
||||
initLinked = true;
|
||||
}
|
||||
// Handle link when app is in warm state (front or background)
|
||||
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
|
||||
if (!initLinked) {
|
||||
await interpretLink(uri);
|
||||
} else {
|
||||
initLinked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setIsReversing(int targetIndex) {
|
||||
bool reversing =
|
||||
selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last > targetIndex;
|
||||
setState(() {
|
||||
isReversing = reversing;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> switchToPage(int index) async {
|
||||
setIsReversing(index);
|
||||
if (index == 0) {
|
||||
while ((pages[0].widget.key as GlobalKey<AppsPageState>).currentState !=
|
||||
null) {
|
||||
// Avoid duplicate GlobalKey error
|
||||
await Future.delayed(const Duration(microseconds: 1));
|
||||
}
|
||||
setState(() {
|
||||
selectedIndexHistory.clear();
|
||||
});
|
||||
} else if (selectedIndexHistory.isEmpty ||
|
||||
(selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last != index)) {
|
||||
setState(() {
|
||||
int existingInd = selectedIndexHistory.indexOf(index);
|
||||
if (existingInd >= 0) {
|
||||
selectedIndexHistory.removeAt(existingInd);
|
||||
}
|
||||
selectedIndexHistory.add(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppsProvider appsProvider = context.watch<AppsProvider>();
|
||||
SettingsProvider settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
if (!prevIsLoading &&
|
||||
prevAppCount >= 0 &&
|
||||
appsProvider.apps.length > prevAppCount &&
|
||||
selectedIndexHistory.isNotEmpty &&
|
||||
selectedIndexHistory.last == 1 &&
|
||||
!isLinkActivity) {
|
||||
switchToPage(0);
|
||||
}
|
||||
prevAppCount = appsProvider.apps.length;
|
||||
prevIsLoading = appsProvider.loadingApps;
|
||||
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: PageTransitionSwitcher(
|
||||
duration: Duration(
|
||||
milliseconds: settingsProvider.disablePageTransitions ? 0 : 300,
|
||||
),
|
||||
reverse: settingsProvider.reversePageTransitions
|
||||
? !isReversing
|
||||
: isReversing,
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: pages
|
||||
.elementAt(
|
||||
selectedIndexHistory.isEmpty ? 0 : selectedIndexHistory.last,
|
||||
)
|
||||
.widget,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: pages
|
||||
.map(
|
||||
(e) =>
|
||||
NavigationDestination(icon: Icon(e.icon), label: e.title),
|
||||
)
|
||||
.toList(),
|
||||
onDestinationSelected: (int index) async {
|
||||
HapticFeedback.selectionClick();
|
||||
switchToPage(index);
|
||||
},
|
||||
selectedIndex: selectedIndexHistory.isEmpty
|
||||
? 0
|
||||
: selectedIndexHistory.last,
|
||||
),
|
||||
),
|
||||
onWillPop: () async {
|
||||
if (isLinkActivity &&
|
||||
selectedIndexHistory.length == 1 &&
|
||||
selectedIndexHistory.last == 1) {
|
||||
return true;
|
||||
}
|
||||
setIsReversing(
|
||||
selectedIndexHistory.length >= 2
|
||||
? selectedIndexHistory.reversed.toList()[1]
|
||||
: 0,
|
||||
);
|
||||
if (selectedIndexHistory.isNotEmpty) {
|
||||
setState(() {
|
||||
selectedIndexHistory.removeLast();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return !(pages[0].widget.key as GlobalKey<AppsPageState>).currentState!
|
||||
.clearSelected();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_linkSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
969
lib/pages/import_export.dart
Normal file
969
lib/pages/import_export.dart
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/app_sources/fdroidrepo.dart';
|
||||
import 'package:obtainium/components/custom_app_bar.dart';
|
||||
import 'package:obtainium/components/generated_form.dart';
|
||||
import 'package:obtainium/components/generated_form_modal.dart';
|
||||
import 'package:obtainium/custom_errors.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ImportExportPage extends StatefulWidget {
|
||||
const ImportExportPage({super.key});
|
||||
|
||||
@override
|
||||
State<ImportExportPage> createState() => _ImportExportPageState();
|
||||
}
|
||||
|
||||
class _ImportExportPageState extends State<ImportExportPage> {
|
||||
bool importInProgress = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
|
||||
var outlineButtonStyle = ButtonStyle(
|
||||
shape: WidgetStateProperty.all(
|
||||
StadiumBorder(
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
urlListImport({String? initValue, bool overrideInitValid = false}) {
|
||||
showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
initValid: overrideInitValid,
|
||||
title: tr('importFromURLList'),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'appURLList',
|
||||
defaultValue: initValue ?? '',
|
||||
label: tr('appURLList'),
|
||||
max: 7,
|
||||
additionalValidators: [
|
||||
(dynamic value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
var lines = value.trim().split('\n');
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
try {
|
||||
sourceProvider.getSource(lines[i]);
|
||||
} catch (e) {
|
||||
return '${tr('line')} ${i + 1}: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((values) {
|
||||
if (values != null) {
|
||||
var urls = (values['appURLList'] as String).split('\n');
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
appsProvider
|
||||
.addAppsByURL(urls)
|
||||
.then((errors) {
|
||||
if (errors.isEmpty) {
|
||||
showMessage(
|
||||
tr(
|
||||
'importedX',
|
||||
args: [plural('apps', urls.length).toLowerCase()],
|
||||
),
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: urls.length,
|
||||
errors: errors,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
})
|
||||
.whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runObtainiumExport({bool pickOnly = false}) async {
|
||||
HapticFeedback.selectionClick();
|
||||
appsProvider
|
||||
.export(
|
||||
pickOnly:
|
||||
pickOnly || (await settingsProvider.getExportDir()) == null,
|
||||
sp: settingsProvider,
|
||||
)
|
||||
.then((String? result) {
|
||||
if (result != null) {
|
||||
showMessage(tr('exportedTo', args: [result]), context);
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
});
|
||||
}
|
||||
|
||||
runObtainiumImport() {
|
||||
HapticFeedback.selectionClick();
|
||||
FilePicker.platform
|
||||
.pickFiles()
|
||||
.then((result) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
if (result != null) {
|
||||
String data = File(result.files.single.path!).readAsStringSync();
|
||||
try {
|
||||
jsonDecode(data);
|
||||
} catch (e) {
|
||||
throw ObtainiumError(tr('invalidInput'));
|
||||
}
|
||||
appsProvider.import(data).then((value) {
|
||||
var cats = settingsProvider.categories;
|
||||
appsProvider.apps.forEach((key, value) {
|
||||
for (var c in value.app.categories) {
|
||||
if (!cats.containsKey(c)) {
|
||||
cats[c] = generateRandomLightColor().value;
|
||||
}
|
||||
}
|
||||
});
|
||||
appsProvider.addMissingCategories(settingsProvider);
|
||||
showMessage(
|
||||
'${tr('importedX', args: [plural('apps', value.key.length).toLowerCase()])}${value.value ? ' + ${tr('settings').toLowerCase()}' : ''}',
|
||||
context,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// User canceled the picker
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
})
|
||||
.whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runUrlImport() {
|
||||
FilePicker.platform.pickFiles().then((result) {
|
||||
if (result != null) {
|
||||
urlListImport(
|
||||
overrideInitValid: true,
|
||||
initValue: RegExp('https?://[^"]+')
|
||||
.allMatches(File(result.files.single.path!).readAsStringSync())
|
||||
.map((e) => e.input.substring(e.start, e.end))
|
||||
.toSet()
|
||||
.toList()
|
||||
.where((url) {
|
||||
try {
|
||||
sourceProvider.getSource(url);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runSourceSearch(AppSource source) {
|
||||
() async {
|
||||
var values = await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('searchX', args: [source.name]),
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'searchQuery',
|
||||
label: tr('searchQuery'),
|
||||
required: source.name != FDroidRepo().name,
|
||||
),
|
||||
],
|
||||
...source.searchQuerySettingFormItems.map((e) => [e]),
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'url',
|
||||
label: source.hosts.isNotEmpty
|
||||
? tr('overrideSource')
|
||||
: plural('url', 1).substring(2),
|
||||
defaultValue: source.hosts.isNotEmpty
|
||||
? source.hosts[0]
|
||||
: '',
|
||||
required: true,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
if (source.hosts.isEmpty || values['url'] != source.hosts[0]) {
|
||||
source = sourceProvider.getSource(
|
||||
values['url'],
|
||||
overrideSource: source.runtimeType.toString(),
|
||||
);
|
||||
}
|
||||
var urlsWithDescriptions = await source.search(
|
||||
values['searchQuery'] as String,
|
||||
querySettings: values,
|
||||
);
|
||||
if (urlsWithDescriptions.isNotEmpty) {
|
||||
var selectedUrls =
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return SelectionModal(
|
||||
entries: urlsWithDescriptions,
|
||||
selectedByDefault: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (selectedUrls != null && selectedUrls.isNotEmpty) {
|
||||
var errors = await appsProvider.addAppsByURL(
|
||||
selectedUrls,
|
||||
sourceOverride: source,
|
||||
);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showMessage(
|
||||
tr(
|
||||
'importedX',
|
||||
args: [
|
||||
plural('apps', selectedUrls.length).toLowerCase(),
|
||||
],
|
||||
),
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
// ignore: use_build_context_synchronously
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: selectedUrls.length,
|
||||
errors: errors,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw ObtainiumError(tr('noResults'));
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
})
|
||||
.whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
runMassSourceImport(MassAppUrlSource source) {
|
||||
() async {
|
||||
var values = await showDialog<Map<String, dynamic>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return GeneratedFormModal(
|
||||
title: tr('importX', args: [source.name]),
|
||||
items: source.requiredArgs
|
||||
.map((e) => [GeneratedFormTextField(e, label: e)])
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (values != null) {
|
||||
setState(() {
|
||||
importInProgress = true;
|
||||
});
|
||||
var urlsWithDescriptions = await source.getUrlsWithDescriptions(
|
||||
values.values.map((e) => e.toString()).toList(),
|
||||
);
|
||||
var selectedUrls =
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return SelectionModal(entries: urlsWithDescriptions);
|
||||
},
|
||||
);
|
||||
if (selectedUrls != null) {
|
||||
var errors = await appsProvider.addAppsByURL(selectedUrls);
|
||||
if (errors.isEmpty) {
|
||||
// ignore: use_build_context_synchronously
|
||||
showMessage(
|
||||
tr(
|
||||
'importedX',
|
||||
args: [plural('apps', selectedUrls.length).toLowerCase()],
|
||||
),
|
||||
context,
|
||||
);
|
||||
} else {
|
||||
// ignore: use_build_context_synchronously
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ImportErrorDialog(
|
||||
urlsLength: selectedUrls.length,
|
||||
errors: errors,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
.catchError((e) {
|
||||
showError(e, context);
|
||||
})
|
||||
.whenComplete(() {
|
||||
setState(() {
|
||||
importInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var sourceStrings = <String, List<String>>{};
|
||||
sourceProvider.sources.where((e) => e.canSearch).forEach((s) {
|
||||
sourceStrings[s.name] = [s.name];
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
CustomAppBar(title: tr('importExport')),
|
||||
SliverFillRemaining(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: settingsProvider.getExportDir(),
|
||||
builder: (context, snapshot) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: outlineButtonStyle,
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
runObtainiumExport(pickOnly: true);
|
||||
},
|
||||
child: Text(
|
||||
tr('pickExportDir'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: outlineButtonStyle,
|
||||
onPressed:
|
||||
importInProgress || snapshot.data == null
|
||||
? null
|
||||
: runObtainiumExport,
|
||||
child: Text(
|
||||
tr('obtainiumExport'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: outlineButtonStyle,
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: runObtainiumImport,
|
||||
child: Text(
|
||||
tr('obtainiumImport'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (snapshot.data != null)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormSwitch(
|
||||
'autoExportOnChanges',
|
||||
label: tr('autoExportOnChanges'),
|
||||
defaultValue: settingsProvider
|
||||
.autoExportOnChanges,
|
||||
),
|
||||
],
|
||||
[
|
||||
GeneratedFormDropdown(
|
||||
'exportSettings',
|
||||
[
|
||||
MapEntry('0', tr('none')),
|
||||
MapEntry('1', tr('excludeSecrets')),
|
||||
MapEntry('2', tr('all')),
|
||||
],
|
||||
label: tr('includeSettings'),
|
||||
defaultValue: settingsProvider
|
||||
.exportSettings
|
||||
.toString(),
|
||||
),
|
||||
],
|
||||
],
|
||||
onValueChanges: (value, valid, isBuilding) {
|
||||
if (valid && !isBuilding) {
|
||||
if (value['autoExportOnChanges'] !=
|
||||
null) {
|
||||
settingsProvider.autoExportOnChanges =
|
||||
value['autoExportOnChanges'] ==
|
||||
true;
|
||||
}
|
||||
if (value['exportSettings'] != null) {
|
||||
settingsProvider.exportSettings =
|
||||
int.parse(value['exportSettings']);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (importInProgress)
|
||||
const Column(
|
||||
children: [
|
||||
SizedBox(height: 14),
|
||||
LinearProgressIndicator(),
|
||||
SizedBox(height: 14),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 32),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () async {
|
||||
var searchSourceName =
|
||||
await showDialog<List<String>?>(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return SelectionModal(
|
||||
title: tr(
|
||||
'selectX',
|
||||
args: [
|
||||
tr(
|
||||
'source',
|
||||
).toLowerCase(),
|
||||
],
|
||||
),
|
||||
entries: sourceStrings,
|
||||
selectedByDefault: false,
|
||||
onlyOneSelectionAllowed: true,
|
||||
titlesAreLinks: false,
|
||||
);
|
||||
},
|
||||
) ??
|
||||
[];
|
||||
var searchSource = sourceProvider
|
||||
.sources
|
||||
.where(
|
||||
(e) => searchSourceName.contains(
|
||||
e.name,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (searchSource.isNotEmpty) {
|
||||
runSourceSearch(searchSource[0]);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
tr(
|
||||
'searchX',
|
||||
args: [lowerCaseIfEnglish(tr('source'))],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress ? null : urlListImport,
|
||||
child: Text(tr('importFromURLList')),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress ? null : runUrlImport,
|
||||
child: Text(tr('importFromURLsInFile')),
|
||||
),
|
||||
],
|
||||
),
|
||||
...sourceProvider.massUrlSources.map(
|
||||
(source) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: importInProgress
|
||||
? null
|
||||
: () {
|
||||
runMassSourceImport(source);
|
||||
},
|
||||
child: Text(tr('importX', args: [source.name])),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
tr('importedAppsIdDisclaimer'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImportErrorDialog extends StatefulWidget {
|
||||
const ImportErrorDialog({
|
||||
super.key,
|
||||
required this.urlsLength,
|
||||
required this.errors,
|
||||
});
|
||||
|
||||
final int urlsLength;
|
||||
final List<List<String>> errors;
|
||||
|
||||
@override
|
||||
State<ImportErrorDialog> createState() => _ImportErrorDialogState();
|
||||
}
|
||||
|
||||
class _ImportErrorDialogState extends State<ImportErrorDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(tr('importErrors')),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'importedXOfYApps',
|
||||
args: [
|
||||
(widget.urlsLength - widget.errors.length).toString(),
|
||||
widget.urlsLength.toString(),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('followingURLsHadErrors'),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
...widget.errors.map((e) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Text(e[0]),
|
||||
Text(e[1], style: const TextStyle(fontStyle: FontStyle.italic)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: Text(tr('ok')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class SelectionModal extends StatefulWidget {
|
||||
SelectionModal({
|
||||
super.key,
|
||||
required this.entries,
|
||||
this.selectedByDefault = true,
|
||||
this.onlyOneSelectionAllowed = false,
|
||||
this.titlesAreLinks = true,
|
||||
this.title,
|
||||
this.deselectThese = const [],
|
||||
});
|
||||
|
||||
String? title;
|
||||
Map<String, List<String>> entries;
|
||||
bool selectedByDefault;
|
||||
List<String> deselectThese;
|
||||
bool onlyOneSelectionAllowed;
|
||||
bool titlesAreLinks;
|
||||
|
||||
@override
|
||||
State<SelectionModal> createState() => _SelectionModalState();
|
||||
}
|
||||
|
||||
class _SelectionModalState extends State<SelectionModal> {
|
||||
Map<MapEntry<String, List<String>>, bool> entrySelections = {};
|
||||
String filterRegex = '';
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (var entry in widget.entries.entries) {
|
||||
entrySelections.putIfAbsent(
|
||||
entry,
|
||||
() =>
|
||||
widget.selectedByDefault &&
|
||||
!widget.onlyOneSelectionAllowed &&
|
||||
!widget.deselectThese.contains(entry.key),
|
||||
);
|
||||
}
|
||||
if (widget.selectedByDefault && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(widget.entries.entries.first.key);
|
||||
}
|
||||
}
|
||||
|
||||
void selectOnlyOne(String url) {
|
||||
for (var e in entrySelections.keys) {
|
||||
entrySelections[e] = e.key == url;
|
||||
}
|
||||
}
|
||||
|
||||
void selectAll({bool deselect = false}) {
|
||||
for (var e in entrySelections.keys) {
|
||||
entrySelections[e] = !deselect;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Map<MapEntry<String, List<String>>, bool> filteredEntrySelections = {};
|
||||
entrySelections.forEach((key, value) {
|
||||
var searchableText = key.value.isEmpty ? key.key : key.value[0];
|
||||
if (filterRegex.isEmpty || RegExp(filterRegex).hasMatch(searchableText)) {
|
||||
filteredEntrySelections.putIfAbsent(key, () => value);
|
||||
}
|
||||
});
|
||||
if (filterRegex.isNotEmpty && filteredEntrySelections.isEmpty) {
|
||||
entrySelections.forEach((key, value) {
|
||||
var searchableText = key.value.isEmpty ? key.key : key.value[0];
|
||||
if (filterRegex.isEmpty ||
|
||||
RegExp(
|
||||
filterRegex,
|
||||
caseSensitive: false,
|
||||
).hasMatch(searchableText)) {
|
||||
filteredEntrySelections.putIfAbsent(key, () => value);
|
||||
}
|
||||
});
|
||||
}
|
||||
getSelectAllButton() {
|
||||
if (widget.onlyOneSelectionAllowed) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
var noneSelected = entrySelections.values.where((v) => v == true).isEmpty;
|
||||
return noneSelected
|
||||
? TextButton(
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selectAll();
|
||||
});
|
||||
},
|
||||
child: Text(tr('selectAll')),
|
||||
)
|
||||
: TextButton(
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selectAll(deselect: true);
|
||||
});
|
||||
},
|
||||
child: Text(tr('deselectX', args: [''])),
|
||||
);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: Text(widget.title ?? tr('pick')),
|
||||
content: Column(
|
||||
children: [
|
||||
GeneratedForm(
|
||||
items: [
|
||||
[
|
||||
GeneratedFormTextField(
|
||||
'filter',
|
||||
label: tr('filter'),
|
||||
required: false,
|
||||
additionalValidators: [
|
||||
(value) {
|
||||
return regExValidator(value);
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
onValueChanges: (value, valid, isBuilding) {
|
||||
if (valid && !isBuilding) {
|
||||
if (value['filter'] != null) {
|
||||
setState(() {
|
||||
filterRegex = value['filter'];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
...filteredEntrySelections.keys.map((entry) {
|
||||
selectThis(bool? value) {
|
||||
setState(() {
|
||||
value ??= false;
|
||||
if (value! && widget.onlyOneSelectionAllowed) {
|
||||
selectOnlyOne(entry.key);
|
||||
} else {
|
||||
entrySelections[entry] = value!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var urlLink = GestureDetector(
|
||||
onTap: !widget.titlesAreLinks
|
||||
? null
|
||||
: () {
|
||||
launchUrlString(
|
||||
entry.key,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
entry.value.isEmpty ? entry.key : entry.value[0],
|
||||
style: TextStyle(
|
||||
decoration: widget.titlesAreLinks
|
||||
? TextDecoration.underline
|
||||
: null,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
if (widget.titlesAreLinks)
|
||||
Text(
|
||||
Uri.parse(entry.key).host,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
var descriptionText = entry.value.length <= 1
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
entry.value[1].length > 128
|
||||
? '${entry.value[1].substring(0, 128)}...'
|
||||
: entry.value[1],
|
||||
style: const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12,
|
||||
),
|
||||
);
|
||||
|
||||
var selectedEntries = entrySelections.entries
|
||||
.where((e) => e.value)
|
||||
.toList();
|
||||
|
||||
var singleSelectTile = ListTile(
|
||||
title: GestureDetector(
|
||||
onTap: widget.titlesAreLinks
|
||||
? null
|
||||
: () {
|
||||
selectThis(!(entrySelections[entry] ?? false));
|
||||
},
|
||||
child: urlLink,
|
||||
),
|
||||
subtitle: entry.value.length <= 1
|
||||
? null
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
selectOnlyOne(entry.key);
|
||||
});
|
||||
},
|
||||
child: descriptionText,
|
||||
),
|
||||
leading: Radio<String>(
|
||||
value: entry.key,
|
||||
groupValue: selectedEntries.isEmpty
|
||||
? null
|
||||
: selectedEntries.first.key.key,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectOnlyOne(entry.key);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
var multiSelectTile = Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: entrySelections[entry],
|
||||
onChanged: (value) {
|
||||
selectThis(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: widget.titlesAreLinks
|
||||
? null
|
||||
: () {
|
||||
selectThis(!(entrySelections[entry] ?? false));
|
||||
},
|
||||
child: urlLink,
|
||||
),
|
||||
entry.value.length <= 1
|
||||
? const SizedBox.shrink()
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
selectThis(!(entrySelections[entry] ?? false));
|
||||
},
|
||||
child: descriptionText,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return widget.onlyOneSelectionAllowed
|
||||
? singleSelectTile
|
||||
: multiSelectTile;
|
||||
}),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
getSelectAllButton(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(tr('cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: entrySelections.values.where((b) => b).isEmpty
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pop(
|
||||
entrySelections.entries
|
||||
.where((entry) => entry.value)
|
||||
.map((e) => e.key.key)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
widget.onlyOneSelectionAllowed
|
||||
? tr('pick')
|
||||
: tr(
|
||||
'selectX',
|
||||
args: [
|
||||
entrySelections.values.where((b) => b).length.toString(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1173
lib/pages/settings.dart
Normal file
1173
lib/pages/settings.dart
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue