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

935 lines
33 KiB
Dart

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:
appsProvider.apps.isEmpty ||
importInProgress
? null
: () {
runObtainiumExport(pickOnly: true);
},
child: Text(
tr('pickExportDir'),
textAlign: TextAlign.center,
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextButton(
style: outlineButtonStyle,
onPressed:
appsProvider.apps.isEmpty ||
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,
),
],
[
GeneratedFormSwitch(
'exportSettings',
label: tr('includeSettings'),
defaultValue:
settingsProvider.exportSettings,
),
],
],
onValueChanges: (value, valid, isBuilding) {
if (valid && !isBuilding) {
if (value['autoExportOnChanges'] !=
null) {
settingsProvider.autoExportOnChanges =
value['autoExportOnChanges'] ==
true;
}
if (value['exportSettings'] != null) {
settingsProvider.exportSettings =
value['exportSettings'] == true;
}
}
},
),
],
),
],
);
},
),
if (importInProgress)
const Column(
children: [
SizedBox(height: 14),
LinearProgressIndicator(),
SizedBox(height: 14),
],
)
else
Column(
children: [
const Divider(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: [tr('source').toLowerCase()],
),
),
),
),
],
),
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;
}
}
@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);
}
});
}
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: [
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(),
],
),
),
),
],
);
}
}