Files
Obtainium/lib/pages/app.dart
2025-07-02 20:25:21 -04:00

683 lines
25 KiB
Dart

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});
final String appId;
@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>();
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: [tr('releaseAsset').toLowerCase()]),
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 && settingsProvider.showAppWebpage)
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: settingsProvider.showAppWebpage ? AppBar() : appScreenAppBar(),
backgroundColor: Theme.of(context).colorScheme.surface,
body: RefreshIndicator(
child: settingsProvider.showAppWebpage
? getAppWebView()
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Column(children: [getFullInfoColumn()]),
),
],
),
onRefresh: () async {
if (app != null) {
getUpdate(app.app.id);
}
},
),
bottomSheet: getBottomSheetMenu(),
);
}
}