new extension management

This commit is contained in:
Fabian Freund
2026-04-17 19:09:27 +02:00
parent 8217472f59
commit f9bcd076f2
49 changed files with 7827 additions and 4533 deletions
@@ -0,0 +1,638 @@
/*
* 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_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/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(
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,
runSpacing: 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)}'
' (${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(
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),
),
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.start,
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 StatelessWidget {
final AddonInfo addon;
const _DescriptionCard({required this.addon});
@override
Widget build(BuildContext context) {
final description = addon.description;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
description.isNotEmpty ? description : 'No description provided.',
),
),
);
}
}
class _DetailsCard extends StatelessWidget {
final AddonInfo addon;
const _DetailsCard({required this.addon});
@override
Widget build(BuildContext context) {
return Card(
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)),
),
],
),
);
}
}
@@ -0,0 +1,161 @@
/*
* 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 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/geckoview/domain/providers.dart';
Future<void> openAddonSettingsFlow(
BuildContext context,
WidgetRef ref,
AddonInfo addon,
) async {
if (!addon.hasOptionsPage) {
await const AddonManagerRoute().push<void>(context);
return;
}
if (addon.openOptionsPageInTab) {
final optionsPageUrl = addon.optionsPageUrl;
if (optionsPageUrl == null || optionsPageUrl.isEmpty) {
return;
}
await GeckoTabService().selectOrAddTabByUrl(
url: Uri.parse(optionsPageUrl),
ignoreFragment: true,
);
if (context.mounted) {
const BrowserRoute().go(context);
}
return;
}
await AddonInternalSettingsRoute(addonId: addon.id).push<void>(context);
}
Future<void> openAddonSettingsFlowById(
BuildContext context,
WidgetRef ref,
String addonId,
) async {
final addon = await ref.read(addonServiceProvider).getAddonById(addonId);
if (addon == null) {
if (!context.mounted) return;
await const AddonManagerRoute().push<void>(context);
return;
}
if (!context.mounted) return;
await openAddonSettingsFlow(context, ref, addon);
}
class AddonInternalSettingsScreen extends ConsumerWidget {
final String addonId;
const AddonInternalSettingsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
final optionsPageUrl = addon?.optionsPageUrl;
return Scaffold(
appBar: AppBar(
title: Text(
addon == null
? 'Extension Settings'
: '${addon.displayName} Settings',
),
),
body: switch (addonAsync) {
AsyncLoading() when addon == null => const Center(
child: CircularProgressIndicator(),
),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Failed to load extension settings: $error',
textAlign: TextAlign.center,
),
),
),
_
when addon == null ||
optionsPageUrl == null ||
optionsPageUrl.isEmpty =>
const Center(
child: Text('This extension does not expose a settings page.'),
),
_ => _AddonSettingsPlatformView(optionsPageUrl: optionsPageUrl),
},
);
}
}
class _AddonSettingsPlatformView extends StatelessWidget {
final String optionsPageUrl;
const _AddonSettingsPlatformView({required this.optionsPageUrl});
@override
Widget build(BuildContext context) {
return PlatformViewLink(
viewType: 'eu.weblibre/addon_settings',
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
);
},
onCreatePlatformView: (params) {
final controller = PlatformViewsService.initExpensiveAndroidView(
id: params.id,
viewType: 'eu.weblibre/addon_settings',
layoutDirection: TextDirection.ltr,
creationParams: <String, Object?>{'optionsPageUrl': optionsPageUrl},
creationParamsCodec: const StandardMessageCodec(),
);
controller.addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
);
unawaited(controller.create());
return controller;
},
);
}
}
@@ -0,0 +1,328 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/widgets/addon_ui.dart';
import 'package:weblibre/utils/ui_helper.dart';
class AddonManagerScreen extends ConsumerWidget {
const AddonManagerScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonsAsync = ref.watch(addonListProvider);
Future<void> refresh() => ref.read(addonListProvider.notifier).refresh();
return Scaffold(
appBar: AppBar(
title: const Text('Extensions'),
actions: [
IconButton(
onPressed: addonsAsync.isLoading ? null : refresh,
icon: const Icon(Icons.refresh),
),
_TriggerAllUpdatesButton(
enabled: addonsAsync.maybeWhen(
data: (addons) =>
addons.any((a) => a.isInstalled && a.isSupported),
orElse: () => false,
),
),
],
),
body: addonsAsync.when(
skipLoadingOnReload: true,
skipError: true,
data: (addons) => RefreshIndicator(
onRefresh: refresh,
child: _AddonList(addons: addons),
),
error: (error, _) => _AddonLoadError(error: error, onRetry: refresh),
loading: () => const Center(child: CircularProgressIndicator()),
),
);
}
}
class _TriggerAllUpdatesButton extends ConsumerWidget {
final bool enabled;
const _TriggerAllUpdatesButton({required this.enabled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(
bulkAddonUpdateProvider.select((value) => value.isLoading),
);
return IconButton(
onPressed: enabled && !busy
? () async {
await ref.read(bulkAddonUpdateProvider.notifier).triggerAll();
if (!context.mounted) return;
showInfoMessage(
context,
'Background update checks started for installed extensions',
);
}
: null,
icon: busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
tooltip: 'Check all installed extensions for updates',
);
}
}
class _AddonList extends StatelessWidget {
final List<AddonInfo> addons;
const _AddonList({required this.addons});
@override
Widget build(BuildContext context) {
final enabled = addons
.where((a) => a.isInstalled && a.isSupported && a.isEnabled)
.toList();
final disabled = addons
.where((a) => a.isInstalled && a.isSupported && !a.isEnabled)
.toList();
final recommended = addons.where((a) => !a.isInstalled).toList();
final unsupported = addons
.where((a) => a.isInstalled && !a.isSupported)
.toList();
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Card(
child: ListTile(
leading: Icon(Icons.info_outline),
title: Text('Addon updates run in the background'),
subtitle: Text(
'Use each extension detail screen to view its last update result or trigger a manual check.',
),
),
),
if (enabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Enabled'),
for (final addon in enabled) _AddonCard(addon: addon),
],
if (disabled.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Disabled'),
for (final addon in disabled) _AddonCard(addon: addon),
],
if (recommended.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Available'),
for (final addon in recommended)
_AddonCard(
addon: addon,
action: _InstallAction(addon: addon),
),
],
if (unsupported.isNotEmpty) ...[
const SizedBox(height: 16),
const _Section(title: 'Unsupported'),
for (final addon in unsupported)
_AddonCard(
addon: addon,
action: _UninstallAction(addon: addon),
),
],
if (addons.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(child: Text('No extensions available right now.')),
),
],
);
}
}
class _InstallAction extends ConsumerWidget {
final AddonInfo addon;
const _InstallAction({required this.addon});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return FilledButton(
onPressed: busy
? null
: () async {
await ref.read(addonListProvider.notifier).install(addon);
if (!context.mounted) return;
showInfoMessage(context, '${addon.displayName} installed');
},
child: const Text('Install'),
);
}
}
class _UninstallAction extends ConsumerWidget {
final AddonInfo addon;
const _UninstallAction({required this.addon});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return IconButton(
tooltip: 'Remove extension',
onPressed: busy
? null
: () async {
await ref.read(addonListProvider.notifier).uninstall(addon);
if (!context.mounted) return;
showInfoMessage(context, '${addon.displayName} removed');
},
icon: const Icon(Icons.delete_outline),
);
}
}
class _Section extends StatelessWidget {
final String title;
const _Section({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
}
class _AddonCard extends ConsumerWidget {
final AddonInfo addon;
final Widget? action;
const _AddonCard({required this.addon, this.action});
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(addonBusyIdsProvider).contains(addon.id);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: busy
? null
: () => AddonDetailsRoute(addonId: addon.id).push<void>(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AddonIconView(addon: addon),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addon.displayName,
style: Theme.of(context).textTheme.titleMedium,
),
if ((addon.summary ?? '').isNotEmpty) ...[
const SizedBox(height: 4),
Text(addon.summary!),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (addon.isAllowedInPrivateBrowsing)
const Chip(label: Text('Private Browsing')),
if (addon.ratingAverage != null)
Chip(
avatar: const Icon(Icons.star, size: 16),
label: Text(
addon.ratingAverage!.toStringAsFixed(1),
),
),
],
),
],
),
),
const SizedBox(width: 8),
action ?? const Icon(Icons.chevron_right),
],
),
if (addon.statusBannerMessage != null) ...[
const SizedBox(height: 12),
AddonStatusBanner(addon: addon),
],
],
),
),
),
);
}
}
class _AddonLoadError extends StatelessWidget {
final Object? error;
final VoidCallback onRetry;
const _AddonLoadError({required this.error, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40),
const SizedBox(height: 12),
const Text('Failed to load extensions'),
const SizedBox(height: 8),
Text(error.toString(), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('Retry')),
],
),
),
);
}
}
@@ -0,0 +1,133 @@
/*
* 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
const _permissionsLearnMoreUrl =
'https://support.mozilla.org/kb/permission-request-messages-firefox-extensions';
class AddonPermissionsScreen extends ConsumerWidget {
final String addonId;
const AddonPermissionsScreen({required this.addonId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonAsync = ref.watch(addonDetailsProvider(addonId));
final addon = addonAsync.value;
final permissions = addon?.translatedPermissions.toList() ?? <String>[];
permissions.sort();
final dataCollection =
addon?.translatedRequiredDataCollectionPermissions.toList() ??
<String>[];
dataCollection.sort();
return Scaffold(
appBar: AppBar(
title: Text(
addon == null
? 'Extension Permissions'
: '${addon.displayName} Permissions',
),
),
body: switch (addonAsync) {
AsyncLoading() when addon == null => const Center(
child: CircularProgressIndicator(),
),
AsyncError(:final error) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Failed to load extension permissions: $error',
textAlign: TextAlign.center,
),
),
),
_ when addon == null => const Center(
child: Text('This extension could not be found.'),
),
_ => ListView(
padding: const EdgeInsets.all(16),
children: [
if (permissions.isEmpty && dataCollection.isEmpty)
const Card(
child: ListTile(
leading: Icon(Icons.verified_user_outlined),
title: Text('No special permissions listed'),
subtitle: Text(
'This extension does not currently expose any translated permission details.',
),
),
),
if (permissions.isNotEmpty) ...[
Text(
'Permissions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Column(
children: [
for (final permission in permissions)
ListTile(
leading: const Icon(Icons.check_circle_outline),
title: Text(permission),
),
],
),
),
const SizedBox(height: 16),
],
if (dataCollection.isNotEmpty) ...[
Text(
'Required Data Collection',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Column(
children: [
for (final permission in dataCollection)
ListTile(
leading: const Icon(Icons.data_usage_outlined),
title: Text(permission),
),
],
),
),
const SizedBox(height: 16),
],
FilledButton.icon(
onPressed: () async {
await launchUrl(Uri.parse(_permissionsLearnMoreUrl));
},
icon: const Icon(Icons.open_in_new),
label: const Text('Learn More'),
),
],
),
},
);
}
}
@@ -0,0 +1,149 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:intl/intl.dart';
import 'package:weblibre/features/addons/extensions/addon_info.dart';
class AddonIconView extends StatelessWidget {
final AddonInfo addon;
final double size;
const AddonIconView({required this.addon, this.size = 40, super.key});
@override
Widget build(BuildContext context) {
final bytes = addon.icon;
final borderRadius = BorderRadius.circular(12);
if (bytes != null && bytes.isNotEmpty) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.memory(
bytes,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _FallbackIcon(size: size),
),
);
}
return _FallbackIcon(size: size);
}
}
class _FallbackIcon extends StatelessWidget {
final double size;
const _FallbackIcon({required this.size});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.extension, color: theme.colorScheme.onSurfaceVariant),
);
}
}
class AddonStatusBanner extends StatelessWidget {
final AddonInfo addon;
const AddonStatusBanner({required this.addon, super.key});
@override
Widget build(BuildContext context) {
final message = addon.statusBannerMessage;
if (message == null) {
return const SizedBox.shrink();
}
final isWarning = addon.disabledReason == AddonDisabledReason.softBlocked;
final theme = Theme.of(context);
final background = isWarning
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.errorContainer;
final foreground = isWarning
? theme.colorScheme.onTertiaryContainer
: theme.colorScheme.onErrorContainer;
final icon = isWarning ? Icons.warning_amber_rounded : Icons.error_outline;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: foreground, size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(color: foreground),
),
),
],
),
);
}
}
String formatAddonDate(String raw) {
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
return raw.isEmpty ? 'Unknown' : raw;
}
return DateFormat.yMMMd().format(parsed.toLocal());
}
String formatUpdateAttemptDate(AddonUpdateAttemptInfo attempt) {
final date = DateTime.fromMillisecondsSinceEpoch(
attempt.dateMillisecondsSinceEpoch,
).toLocal();
return DateFormat.yMMMd().add_jm().format(date);
}
String formatUpdateAttemptStatus(AddonUpdateAttemptInfo? attempt) {
return switch (attempt?.status) {
AddonUpdateStatus.successfullyUpdated =>
attempt?.message?.isNotEmpty == true
? attempt!.message!
: 'Updated successfully',
AddonUpdateStatus.noUpdateAvailable => 'No update available',
AddonUpdateStatus.notInstalled => 'Extension not installed',
AddonUpdateStatus.error =>
attempt?.message?.isNotEmpty == true
? 'Update failed: ${attempt!.message}'
: 'Update failed',
null => 'No update checks recorded yet',
};
}
@@ -0,0 +1,81 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/addons/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
class PinnedAddonBar extends ConsumerWidget {
const PinnedAddonBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pinnedIds = ref.watch(pinnedAddonIdsProvider);
if (pinnedIds.isEmpty) return const SizedBox.shrink();
final extensions = ref.watch(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
final pinned = extensions
.where((e) => pinnedIds.contains(e.extensionId))
.toList();
if (pinned.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(right: 6.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final extension in pinned)
InkResponse(
radius: 22,
onTap: () async {
await ref
.read(addonServiceProvider)
.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
onLongPress: () async {
await AddonDetailsRoute(
addonId: extension.extensionId,
).push<void>(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 8.0,
),
child: ExtensionBadgeIcon(extension),
),
),
],
),
);
}
}