Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-20 13:08:39 +01:00
parent 2fd78f5dc9
commit 489cf0b2ea
148 changed files with 30898 additions and 2 deletions

771
lib/pages/add_app.dart Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

351
lib/pages/home.dart Normal file
View 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();
}
}

View 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

File diff suppressed because it is too large Load diff