Files
MrbWebLibre/apps/weblibre/lib/features/addons/presentation/screens/addon_details.dart
T

675 lines
23 KiB
Dart

/*
* Copyright (c) 2024-2026 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
import 'package:weblibre/features/addons/presentation/screens/addon_internal_settings.dart';
import 'package:weblibre/features/addons/presentation/widgets/addon_ui.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/services/browser_addon.dart';
import 'package:weblibre/utils/number_format.dart';
import 'package:weblibre/utils/ui_helper.dart';
class AddonDetailsScreen extends ConsumerWidget {
final String addonId;
const AddonDetailsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
if (addonAsync.isLoading && addon == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (addon == null) {
return Scaffold(
appBar: AppBar(title: const Text('Extension')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
addonAsync.error?.toString() ??
'This extension could not be found.',
textAlign: TextAlign.center,
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(addon.displayName),
actions: [
IconButton(
onPressed: addonAsync.isLoading
? null
: ref.read(addonDetailsProvider(addonId).notifier).refresh,
icon: const Icon(Icons.refresh),
),
],
),
body: RefreshIndicator(
onRefresh: ref.read(addonDetailsProvider(addonId).notifier).refresh,
child: _AddonDetailsBody(addonId: addonId),
),
);
}
}
class _AddonDetailsBody extends ConsumerWidget {
final String addonId;
const _AddonDetailsBody({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addon = ref.watch(
addonDetailsProvider(addonId).select((value) => value.value),
);
if (addon == null) return const SizedBox.shrink();
return ListView(
padding: const EdgeInsets.all(16),
children: [
_AddonHeader(addon: addon),
const SizedBox(height: 16),
if (addon.isInstalled) ...[
_ManagementSection(addonId: addonId),
const SizedBox(height: 16),
_UpdatesSection(addonId: addonId),
] else ...[
_InstallButton(addonId: addonId),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () =>
AddonPermissionsRoute(addonId: addon.id).push<void>(context),
icon: const Icon(Icons.privacy_tip_outlined),
label: const Text('View Permissions'),
),
],
const SizedBox(height: 16),
Text('Details', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_DetailsCard(addon: addon),
const SizedBox(height: 16),
Text('Description', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
_DescriptionCard(addon: addon),
],
);
}
}
class _InstallButton extends ConsumerWidget {
final String addonId;
const _InstallButton({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
return FilledButton.icon(
onPressed: (addonAsync.isLoading || addon == null)
? null
: () async {
final displayName = addon.displayName;
await ref.read(addonDetailsProvider(addonId).notifier).install();
if (!context.mounted) return;
showInfoMessage(context, '$displayName installed');
},
icon: const Icon(Icons.download),
label: const Text('Install Extension'),
);
}
}
class _AddonHeader extends StatelessWidget {
final AddonInfo addon;
const _AddonHeader({required this.addon});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AddonIconView(addon: addon, size: 56),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addon.displayName,
style: theme.textTheme.headlineSmall,
),
if ((addon.summary ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(addon.summary!),
],
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: [
Chip(
label: Text(
addon.isInstalled
? (addon.isEnabled ? 'Installed' : 'Disabled')
: 'Available',
),
),
if (addon.isAllowedInPrivateBrowsing)
const Chip(label: Text('Private Browsing')),
if (addon.ratingAverage != null)
Chip(
avatar: const Icon(Icons.star, size: 18),
label: Text(
'${addon.ratingAverage!.toStringAsFixed(1)}'
' (${formatCompactNumber(addon.ratingReviews ?? 0)})',
),
),
],
),
],
),
),
],
),
const SizedBox(height: 16),
AddonStatusBanner(addon: addon),
],
),
),
);
}
}
class _ManagementSection extends ConsumerWidget {
final String addonId;
const _ManagementSection({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
if (addon == null) return const SizedBox.shrink();
final globalAutoUpdate = ref.watch(addonAutoUpdateProvider);
final isLocalFileInstalled = addon.isLocalFileInstalled;
final isPinned = ref.watch(pinnedAddonIdsProvider).contains(addonId);
final (
globalAutoUpdateEnabled,
canChangePerAddonAutoUpdate,
) = globalAutoUpdate.when(
data: (enabled) =>
(enabled, !addonAsync.isLoading && enabled && !isLocalFileInstalled),
loading: () => (true, false),
error: (_, _) => (true, false),
);
final autoUpdateSubtitle = switch ((
isLocalFileInstalled,
addon.isAutoUpdateEnabled,
globalAutoUpdateEnabled,
)) {
(_, _, false) => 'Global automatic updates are disabled.',
(true, _, true) =>
'Run a manual update once and restart the app before automatic updates can be enabled.',
(false, true, true) =>
'Allow this extension to receive background updates.',
(false, false, true) =>
'Background updates are disabled for this extension.',
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Management', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
if (addon.isSupported)
SwitchListTile.adaptive(
title: const Text('Enabled'),
subtitle: Text(
addon.canUserToggleEnabled
? 'Allow this extension to run in WebLibre.'
: 'This extension cannot be safely enabled.',
),
value: addon.isEnabled,
onChanged: addonAsync.isLoading || !addon.canUserToggleEnabled
? null
: (enabled) => ref
.read(addonDetailsProvider(addonId).notifier)
.setEnabled(enabled: enabled),
),
SwitchListTile.adaptive(
title: const Text('Allow in Private Browsing'),
subtitle: const Text(
'Let this extension run in private browsing tabs.',
),
value: addon.isAllowedInPrivateBrowsing,
onChanged: addonAsync.isLoading
? null
: (allowed) => ref
.read(addonDetailsProvider(addonId).notifier)
.setAllowedInPrivateBrowsing(allowed: allowed),
),
SwitchListTile.adaptive(
title: const Text('Automatic updates'),
subtitle: Text(autoUpdateSubtitle),
value: addon.isAutoUpdateEnabled,
onChanged: canChangePerAddonAutoUpdate
? (enabled) => ref
.read(addonDetailsProvider(addonId).notifier)
.setAutoUpdateEnabled(enabled: enabled)
: null,
),
SwitchListTile.adaptive(
title: const Text('Pin to toolbar'),
subtitle: const Text(
'Show this extension as an icon in the main tab bar.',
),
value: isPinned,
onChanged: (pinned) {
ref
.read(pinnedAddonIdsProvider.notifier)
.setPinned(addonId, pinned: pinned);
},
),
if (addon.hasOptionsPage)
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Extension Settings'),
subtitle: Text(
addon.openOptionsPageInTab
? 'Open the extension options page in a browser tab'
: 'Open the extension options page',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => openAddonSettingsFlow(context, ref, addon),
),
if (addon.id == 'uBlock0@raymondhill.net')
ListTile(
leading: const Icon(Icons.filter_list),
title: const Text('Filter Lists & Hardenings'),
subtitle: const Text(
'Manage filter lists and apply WebLibre hardenings',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => UBlockFilterListsRoute().push<void>(context),
),
ListTile(
leading: const Icon(Icons.privacy_tip_outlined),
title: const Text('Permissions'),
trailing: const Icon(Icons.chevron_right),
onTap: () => AddonPermissionsRoute(
addonId: addon.id,
).push<void>(context),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Remove Extension'),
textColor: theme.colorScheme.error,
iconColor: theme.colorScheme.error,
onTap: addonAsync.isLoading
? null
: () async {
final confirmed = await _showConfirmUninstallDialog(
context,
addon,
);
if (confirmed != true || !context.mounted) return;
final displayName = addon.displayName;
await ref
.read(addonDetailsProvider(addonId).notifier)
.uninstall();
if (!context.mounted) return;
showInfoMessage(context, '$displayName removed');
Navigator.of(context).pop();
},
),
],
),
),
],
);
}
}
Future<bool?> _showConfirmUninstallDialog(
BuildContext context,
AddonInfo addon,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Remove extension?'),
content: Text('Remove ${addon.displayName} from WebLibre?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Remove'),
),
],
),
);
}
class _UpdatesSection extends ConsumerWidget {
final String addonId;
const _UpdatesSection({required this.addonId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final addon = ref.watch(addonDetailsProvider(addonId)).value;
if (addon == null) return const SizedBox.shrink();
final storeInfo = ref.watch(addonStoreInfoProvider(addonId)).value;
final updateAttempt = ref
.watch(lastAddonUpdateAttemptProvider(addonId))
.value;
final checking = ref.watch(addonUpdateCheckProvider(addonId)).isLoading;
final availableVersion = _displayAvailableVersion(addon, storeInfo);
final hasAvailableUpdate =
addon.installedVersion != null &&
availableVersion.isNotEmpty &&
addon.installedVersion != availableVersion;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Updates', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(formatUpdateAttemptStatus(updateAttempt)),
const SizedBox(height: 8),
if (hasAvailableUpdate) ...[
Text(
'Update available: ${addon.installedVersion} \u2192 $availableVersion',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
],
Text(
updateAttempt == null
? 'No recent update attempt information is available yet.'
: 'Last checked: ${formatUpdateAttemptDate(updateAttempt)}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: checking
? null
: () => _runUpdateCheck(context, ref, addonId),
icon: checking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
label: Text(
checking ? 'Checking for Updates' : 'Check for Updates',
),
),
],
),
),
),
],
);
}
}
String _displayAvailableVersion(AddonInfo addon, AddonStoreInfo? storeInfo) {
final latest = storeInfo?.latestVersion.trim();
return (latest != null && latest.isNotEmpty) ? latest : addon.version;
}
Future<void> _runUpdateCheck(
BuildContext context,
WidgetRef ref,
String addonId,
) async {
final outcome = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.resolveAvailableUpdate();
if (!context.mounted) return;
switch (outcome) {
case AddonUpdateOutcomeMissing():
return;
case AddonUpdateOutcomeUpToDate():
final result = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.triggerAndAwait();
if (!context.mounted) return;
_reportUpdateResult(context, result, fallback: 'No update available');
case AddonUpdateOutcomeAvailable(
addon: final fresh,
:final availableVersion,
):
final confirmed = await _confirmUpdateDialog(
context,
fresh,
availableVersion,
);
if (confirmed != true || !context.mounted) return;
final result = await ref
.read(addonUpdateCheckProvider(addonId).notifier)
.triggerAndAwait();
if (!context.mounted) return;
_reportUpdateResult(context, result);
}
}
Future<bool?> _confirmUpdateDialog(
BuildContext context,
AddonInfo addon,
String availableVersion,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Update available'),
content: Text(
'Update ${addon.displayName} from '
'${addon.installedVersion} to $availableVersion?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Not now'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Update'),
),
],
),
);
}
void _reportUpdateResult(
BuildContext context,
AddonUpdateRunResult result, {
String? fallback,
}) {
switch (result) {
case AddonUpdateRunDone(:final message):
final text = (message != null && message.isNotEmpty) ? message : fallback;
if (text != null) showInfoMessage(context, text);
case AddonUpdateRunNoRemoteSource():
showErrorMessage(
context,
'This locally installed extension has no remote update source.',
);
case AddonUpdateRunFailed():
showErrorMessage(context, 'Failed to start update check.');
}
}
class _DescriptionCard extends ConsumerWidget {
final AddonInfo addon;
const _DescriptionCard({required this.addon});
@override
Widget build(BuildContext context, WidgetRef ref) {
final markdownAsync = ref.watch(addonDescriptionMarkdownProvider(addon.id));
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.all(16),
child: markdownAsync.when(
skipLoadingOnReload: true,
data: (markdown) => markdown.isEmpty
? const Text('No description provided.')
: MarkdownBody(
data: markdown,
selectable: true,
onTapLink: (text, href, title) async {
if (href != null && href.isNotEmpty) {
await launchUrl(Uri.parse(href));
}
},
),
loading: () => Text(
addon.description.isNotEmpty
? addon.description
: 'Loading description…',
),
error: (_, _) => Text(
addon.description.isNotEmpty
? addon.description
: 'No description provided.',
),
),
),
);
}
}
class _DetailsCard extends StatelessWidget {
final AddonInfo addon;
const _DetailsCard({required this.addon});
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
children: [
if ((addon.authorName ?? '').isNotEmpty)
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Author'),
subtitle: Text(addon.authorName!),
onTap: (addon.authorUrl ?? '').isEmpty
? null
: () => launchUrl(Uri.parse(addon.authorUrl!)),
),
ListTile(
leading: const Icon(Icons.tag_outlined),
title: const Text('Version'),
subtitle: Text(addon.installedVersion ?? addon.version),
),
ListTile(
leading: const Icon(Icons.update_outlined),
title: const Text('Last Updated'),
subtitle: Text(formatAddonDate(addon.updatedAt)),
),
if (addon.homepageUrl.isNotEmpty)
ListTile(
leading: const Icon(Icons.public),
title: const Text('Homepage'),
subtitle: Text(addon.homepageUrl),
trailing: const Icon(Icons.open_in_new),
onTap: () => launchUrl(Uri.parse(addon.homepageUrl)),
),
if (addon.detailUrl.isNotEmpty)
ListTile(
leading: const Icon(Icons.storefront_outlined),
title: const Text('Addon Listing'),
subtitle: Text(addon.detailUrl),
trailing: const Icon(Icons.open_in_new),
onTap: () => launchUrl(Uri.parse(addon.detailUrl)),
),
],
),
);
}
}