new extension management
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user