diff --git a/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.dart b/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.dart
new file mode 100644
index 00000000..fdcf4e51
--- /dev/null
+++ b/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.dart
@@ -0,0 +1,46 @@
+/*
+ * 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 .
+ */
+import 'dart:convert';
+
+import 'package:riverpod/experimental/persist.dart';
+import 'package:riverpod_annotation/experimental/persist.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+import 'package:weblibre/features/user/data/providers.dart';
+
+part 'extensions_expanded.g.dart';
+
+@Riverpod(keepAlive: true)
+class ExtensionsExpanded extends _$ExtensionsExpanded {
+ void toggle() {
+ state = !state;
+ }
+
+ @override
+ bool build() {
+ persist(
+ ref.watch(riverpodDatabaseStorageProvider),
+ key: 'ExtensionsExpanded',
+ encode: (state) => jsonEncode([state]),
+ decode: (encoded) => (jsonDecode(encoded) as List).first as bool,
+ );
+
+ return stateOrNull ?? false;
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.g.dart b/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.g.dart
new file mode 100644
index 00000000..2103c16d
--- /dev/null
+++ b/app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.g.dart
@@ -0,0 +1,63 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'extensions_expanded.dart';
+
+// **************************************************************************
+// RiverpodGenerator
+// **************************************************************************
+
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint, type=warning
+
+@ProviderFor(ExtensionsExpanded)
+final extensionsExpandedProvider = ExtensionsExpandedProvider._();
+
+final class ExtensionsExpandedProvider
+ extends $NotifierProvider {
+ ExtensionsExpandedProvider._()
+ : super(
+ from: null,
+ argument: null,
+ retry: null,
+ name: r'extensionsExpandedProvider',
+ isAutoDispose: false,
+ dependencies: null,
+ $allTransitiveDependencies: null,
+ );
+
+ @override
+ String debugGetCreateSourceHash() => _$extensionsExpandedHash();
+
+ @$internal
+ @override
+ ExtensionsExpanded create() => ExtensionsExpanded();
+
+ /// {@macro riverpod.override_with_value}
+ Override overrideWithValue(bool value) {
+ return $ProviderOverride(
+ origin: this,
+ providerOverride: $SyncValueProvider(value),
+ );
+ }
+}
+
+String _$extensionsExpandedHash() =>
+ r'c07d67f3d00a8c374b598664f7f54c1bcc6de5b7';
+
+abstract class _$ExtensionsExpanded extends $Notifier {
+ bool build();
+ @$mustCallSuper
+ @override
+ void runBuild() {
+ final ref = this.ref as $Ref;
+ final element =
+ ref.element
+ as $ClassProviderElement<
+ AnyNotifier,
+ bool,
+ Object?,
+ Object?
+ >;
+ element.handleCreate(ref, build);
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
index be831648..8fc2df7b 100644
--- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart
@@ -47,7 +47,6 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart';
-import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
@@ -292,11 +291,6 @@ class BrowserScreen extends HookConsumerWidget {
),
);
- final drawerGestureEnabled = ref.watch(
- generalSettingsWithDefaultsProvider.select(
- (value) => value.drawerGestureEnabled,
- ),
- );
ref.listen(overlayControllerProvider, (previous, next) {
if (next != null) {
@@ -567,8 +561,6 @@ class BrowserScreen extends HookConsumerWidget {
child: Scaffold(
// Minimal scaffold - only for Material overlay support (SnackBars)
resizeToAvoidBottomInset: false,
- endDrawer: const BrowserNavigationDrawer(),
- endDrawerEnableOpenDragGesture: drawerGestureEnabled,
body: Stack(
children: [
// Layer 0: Browser content
@@ -879,6 +871,13 @@ class _Browser extends HookConsumerWidget {
return false;
}
+ // Dismiss modal routes (e.g. showModalBottomSheet)
+ final rootNavigator = Navigator.of(context, rootNavigator: true);
+ if (rootNavigator.canPop()) {
+ rootNavigator.pop();
+ return true;
+ }
+
if (ref.read(bottomSheetControllerProvider) != null) {
ref
.read(bottomSheetControllerProvider.notifier)
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
new file mode 100644
index 00000000..08ebd1b2
--- /dev/null
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart
@@ -0,0 +1,1805 @@
+/*
+ * 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 .
+ */
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:ui' as ui;
+
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:nullability/nullability.dart';
+import 'package:share_plus/share_plus.dart';
+import 'package:skeletonizer/skeletonizer.dart';
+import 'package:weblibre/core/design/app_colors.dart';
+import 'package:weblibre/core/routing/routes.dart';
+import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
+import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
+import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
+import 'package:weblibre/features/geckoview/domain/providers.dart';
+import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
+import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
+import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
+import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
+import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
+import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/content_selection_dialog.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/providers/extensions_expanded.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
+import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
+import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
+import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.dart';
+import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
+import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
+import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
+import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
+import 'package:weblibre/features/sync/domain/repositories/sync.dart';
+import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
+import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
+import 'package:weblibre/features/user/domain/providers.dart';
+import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
+import 'package:weblibre/presentation/controllers/website_title.dart';
+import 'package:weblibre/presentation/hooks/cached_future.dart';
+import 'package:weblibre/presentation/icons/tor_icons.dart';
+import 'package:weblibre/utils/exit_app.dart';
+import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
+
+/// Shows the combined browser menu as a modal bottom sheet.
+Future showBrowserMenuSheet(BuildContext context) {
+ return showModalBottomSheet(
+ context: context,
+ isScrollControlled: true,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
+ ),
+ builder: (context) => const _BrowserMenuSheet(),
+ );
+}
+
+class _BrowserMenuSheet extends HookConsumerWidget {
+ const _BrowserMenuSheet();
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final selectedTabId = ref.watch(selectedTabProvider);
+ final settings = ref.watch(generalSettingsWithDefaultsProvider);
+
+ return DraggableScrollableSheet(
+ initialChildSize: 0.85,
+ minChildSize: 0.4,
+ maxChildSize: 0.95,
+ expand: false,
+ builder: (context, scrollController) {
+ return Column(
+ children: [
+ // Drag handle
+ Container(
+ margin: const EdgeInsets.only(top: 12, bottom: 8),
+ height: 4,
+ width: 40,
+ decoration: BoxDecoration(
+ color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+
+ // Scrollable content
+ Expanded(
+ child: ListView(
+ controller: scrollController,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ children: [
+ // Navigation row
+ if (selectedTabId != null)
+ _NavigationRow(selectedTabId: selectedTabId),
+ const SizedBox(height: 16),
+
+ // Quick toggles (Desktop Mode / Reader Mode)
+ if (selectedTabId != null) ...[
+ _QuickTogglesGrid(selectedTabId: selectedTabId),
+ const SizedBox(height: 16),
+ ],
+
+ // Page actions
+ if (selectedTabId != null) ...[
+ _PageActionsCard(selectedTabId: selectedTabId),
+ const SizedBox(height: 16),
+ ],
+
+ // Extensions
+ _ExtensionsCard(),
+ const SizedBox(height: 16),
+
+ // Tab actions
+ if (selectedTabId != null) ...[
+ _TabActionsCard(selectedTabId: selectedTabId),
+ const SizedBox(height: 16),
+ ],
+
+ // Quick links grid
+ _QuickLinksGrid(showContainerUi: settings.showContainerUi),
+ const SizedBox(height: 16),
+
+ // Profile
+ _ProfileCard(),
+ const SizedBox(height: 16),
+
+ // App
+ const _SettingsCard(),
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
+
+// ─── Helper Builders ───
+
+Widget _buildMenuCard(BuildContext context, {required List children}) {
+ return Material(
+ color: Theme.of(context).colorScheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(16),
+ clipBehavior: Clip.antiAlias,
+ child: Column(children: children),
+ );
+}
+
+Widget _buildDivider() {
+ return const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16);
+}
+
+Widget _buildSubTile(
+ String title, {
+ IconData? icon,
+ Color? iconColor,
+ required VoidCallback onTap,
+}) {
+ return ListTile(
+ contentPadding: const EdgeInsets.only(left: 56, right: 16),
+ leading: icon != null ? Icon(icon, color: iconColor, size: 20) : null,
+ title: Text(title, style: const TextStyle(fontSize: 14)),
+ dense: true,
+ onTap: onTap,
+ );
+}
+
+// ─── Navigation Row ───
+
+class _NavigationRow extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _NavigationRow({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final history = ref.watch(
+ tabStateProvider(selectedTabId).select((value) => value?.historyState),
+ );
+ final isLoading = ref.watch(
+ tabStateProvider(
+ selectedTabId,
+ ).select((state) => state?.isLoading ?? false),
+ );
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ _buildNavIcon(
+ icon: isLoading ? Icons.close : Icons.arrow_back,
+ label: isLoading ? 'Stop' : 'Back',
+ disabled: !isLoading && history?.canGoBack != true,
+ onTap: () async {
+ final controller = ref.read(
+ tabSessionProvider(tabId: selectedTabId).notifier,
+ );
+ if (isLoading) {
+ await controller.stopLoading();
+ } else {
+ await controller.goBack();
+ }
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+ _buildNavIcon(
+ icon: Icons.arrow_forward,
+ label: 'Forward',
+ disabled: history?.canGoForward != true,
+ onTap: () async {
+ await ref
+ .read(tabSessionProvider(tabId: selectedTabId).notifier)
+ .goForward();
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+ _buildNavIcon(
+ icon: MdiIcons.tabMinus,
+ label: 'Close Tab',
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId));
+ if (tabState != null && tabState.tabMode is IsolatedTabMode) {
+ final allStates = ref.read(tabStatesProvider);
+ final groupCount = allStates.values
+ .where(
+ (s) => s.isolationContextId == tabState.isolationContextId,
+ )
+ .length;
+ if (groupCount <= 1 && context.mounted) {
+ final confirmed = await ui_helper.confirmIsolatedTabClose(
+ context,
+ );
+ if (!confirmed) return;
+ }
+ }
+
+ await ref
+ .read(tabRepositoryProvider.notifier)
+ .closeTab(selectedTabId);
+
+ if (context.mounted) {
+ Navigator.pop(context);
+ ui_helper.showTabUndoClose(
+ context,
+ ref.read(tabRepositoryProvider.notifier).undoClose,
+ );
+ }
+ },
+ ),
+ _buildNavIcon(
+ icon: Icons.refresh,
+ label: 'Reload',
+ onTap: () async {
+ await ref
+ .read(tabSessionProvider(tabId: selectedTabId).notifier)
+ .reload();
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+ ],
+ );
+ }
+
+ Widget _buildNavIcon({
+ required IconData icon,
+ required String label,
+ bool disabled = false,
+ required VoidCallback onTap,
+ }) {
+ return Builder(
+ builder: (context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ return InkWell(
+ onTap: disabled ? null : onTap,
+ borderRadius: BorderRadius.circular(8),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 24.0,
+ vertical: 8.0,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ icon,
+ color: disabled
+ ? colorScheme.onSurface.withValues(alpha: 0.3)
+ : colorScheme.onSurface,
+ size: 28,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ label,
+ style: TextStyle(
+ color: disabled
+ ? colorScheme.onSurface.withValues(alpha: 0.3)
+ : colorScheme.onSurfaceVariant,
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+// ─── Page Actions Card ───
+
+class _PageActionsCard extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _PageActionsCard({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return _buildMenuCard(
+ context,
+ children: [
+ // Add Bookmark
+ ListTile(
+ leading: const Icon(MdiIcons.bookmarkPlus),
+ title: const Text('Add Bookmark'),
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ Navigator.pop(context);
+ await BookmarkEntryAddRoute(
+ bookmarkInfo: jsonEncode(
+ BookmarkInfo(
+ title: tabState.titleOrAuthority,
+ url: tabState.url.toString(),
+ ).encode(),
+ ),
+ ).push(context);
+ },
+ ),
+ _buildDivider(),
+
+ // Find in page
+ ListTile(
+ leading: const Icon(Icons.search),
+ title: const Text('Find in page'),
+ onTap: () {
+ ref.read(bottomSheetControllerProvider.notifier).requestDismiss();
+ ref
+ .read(findInPageControllerProvider(selectedTabId).notifier)
+ .show();
+ Navigator.pop(context);
+ },
+ ),
+
+ // Add to Home Screen (conditional)
+ _AddToHomeScreenTile(selectedTabId: selectedTabId),
+ ],
+ );
+ }
+}
+
+// ─── Quick Toggles Grid ───
+
+class _QuickTogglesGrid extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _QuickTogglesGrid({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final desktopEnabled = ref.watch(desktopModeProvider(selectedTabId));
+
+ final readerChanging = ref.watch(readerableScreenControllerProvider);
+ final readerabilityState = ref.watch(
+ selectedTabStateProvider.select(
+ (state) => state?.readerableState ?? ReaderableState.$default(),
+ ),
+ );
+ final isReaderActive = readerabilityState.active;
+ final isReaderLoading = readerChanging.isLoading;
+
+ final enableReadability = ref.watch(
+ generalSettingsWithDefaultsProvider.select(
+ (value) => value.enableReadability,
+ ),
+ );
+ final enforceReadability = ref.watch(
+ generalSettingsWithDefaultsProvider.select(
+ (value) => value.enforceReadability,
+ ),
+ );
+ final readerVisible =
+ (readerabilityState.readerable &&
+ (enableReadability || readerabilityState.active)) ||
+ (enforceReadability && enableReadability);
+
+ return Row(
+ children: [
+ Expanded(
+ child: _ToggleTile(
+ icon: MdiIcons.monitor,
+ label: 'Desktop Mode',
+ active: desktopEnabled,
+ onTap: () {
+ ref
+ .read(desktopModeProvider(selectedTabId).notifier)
+ .enabled(!desktopEnabled);
+ },
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: readerVisible
+ ? _ToggleTile(
+ icon: isReaderActive
+ ? MdiIcons.bookOpen
+ : MdiIcons.bookOpenOutline,
+ label: 'Reader Mode',
+ active: isReaderActive,
+ enabled: !isReaderLoading,
+ onTap: () async {
+ await ref
+ .read(readerableScreenControllerProvider.notifier)
+ .toggleReaderView(!isReaderActive);
+ },
+ )
+ : _ToggleTile(
+ icon: MdiIcons.bookOpenOutline,
+ label: 'Reader Mode',
+ active: false,
+ enabled: false,
+ onTap: () {},
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ToggleTile extends StatelessWidget {
+ final IconData icon;
+ final String label;
+ final bool active;
+ final bool enabled;
+ final VoidCallback onTap;
+
+ const _ToggleTile({
+ required this.icon,
+ required this.label,
+ required this.active,
+ this.enabled = true,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ final backgroundColor = active
+ ? colorScheme.primary
+ : colorScheme.surfaceContainerHigh;
+ final foregroundColor = active
+ ? colorScheme.onPrimary
+ : colorScheme.onSurface;
+ final effectiveAlpha = enabled ? 1.0 : 0.38;
+
+ return Material(
+ color: backgroundColor.withValues(alpha: enabled ? 1.0 : 0.5),
+ borderRadius: BorderRadius.circular(28),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: enabled ? onTap : null,
+ borderRadius: BorderRadius.circular(28),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
+ child: Row(
+ children: [
+ Icon(
+ icon,
+ color: foregroundColor.withValues(alpha: effectiveAlpha),
+ size: 22,
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ label,
+ style: TextStyle(
+ color: foregroundColor.withValues(alpha: effectiveAlpha),
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _AddToHomeScreenTile extends ConsumerWidget {
+ final String selectedTabId;
+
+ const _AddToHomeScreenTile({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final isInstallable = ref.watch(isCurrentTabInstallableProvider);
+
+ if (!isInstallable) return const SizedBox.shrink();
+
+ return Column(
+ children: [
+ _buildDivider(),
+ ListTile(
+ leading: const Icon(Icons.add_to_home_screen),
+ title: const Text('Add to Home Screen'),
+ onTap: () async {
+ await showPwaInstallDialog(context, ref);
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+ ],
+ );
+ }
+}
+
+class _FetchFeedsTile extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _FetchFeedsTile({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final showFeeds = useState(false);
+
+ if (!showFeeds.value) {
+ return Column(
+ children: [
+ _buildDivider(),
+ ListTile(
+ leading: const Icon(Icons.rss_feed),
+ title: const Text('Fetch Feeds'),
+ onTap: () {
+ showFeeds.value = true;
+ },
+ ),
+ ],
+ );
+ }
+
+ final feedsAsync = ref.watch(websiteFeedProviderProvider(selectedTabId));
+
+ return feedsAsync.when(
+ skipLoadingOnReload: true,
+ data: (feeds) {
+ if (feeds.value.isEmpty) {
+ return Column(
+ children: [
+ _buildDivider(),
+ const ListTile(
+ leading: Icon(Icons.rss_feed_outlined),
+ title: Text('No Web Feeds Found'),
+ enabled: false,
+ ),
+ ],
+ );
+ }
+
+ return Column(
+ children: [
+ _buildDivider(),
+ ListTile(
+ leading: const Icon(Icons.rss_feed),
+ title: const Text('Available Web Feeds'),
+ trailing: Badge(label: Text(feeds.value!.length.toString())),
+ onTap: () async {
+ Navigator.pop(context);
+ await SelectFeedDialogRoute(
+ feedsJson: jsonEncode(
+ feeds.value!.map((feed) => feed.toString()).toList(),
+ ),
+ ).push(context);
+ },
+ ),
+ ],
+ );
+ },
+ error: (_, _) => const SizedBox.shrink(),
+ loading: () => Column(
+ children: [
+ _buildDivider(),
+ const ListTile(
+ leading: Icon(Icons.rss_feed),
+ title: Text('Fetching Web Feeds...'),
+ trailing: SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+// ─── Tab Actions Card ───
+
+class _TabActionsCard extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _TabActionsCard({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final settings = ref.watch(generalSettingsWithDefaultsProvider);
+ final showMore = useState(false);
+
+ return _buildMenuCard(
+ context,
+ children: [
+ // Container (conditional)
+ if (settings.showContainerUi) ...[
+ _ContainerExpansion(selectedTabId: selectedTabId),
+ _buildDivider(),
+ ],
+
+ // Share
+ _ShareExpansion(selectedTabId: selectedTabId),
+ _buildDivider(),
+
+ // More / Less toggle
+ if (!showMore.value)
+ ListTile(
+ leading: const Icon(Icons.more_horiz),
+ title: const Text('More'),
+ subtitle: const Text('Clone Tab, Export, Fetch Feeds'),
+ trailing: const Icon(Icons.expand_more),
+ onTap: () => showMore.value = true,
+ )
+ else ...[
+ _CloneTabExpansion(selectedTabId: selectedTabId),
+ _buildDivider(),
+ _ExportExpansion(selectedTabId: selectedTabId),
+ _FetchFeedsTile(selectedTabId: selectedTabId),
+ ],
+ ],
+ );
+ }
+}
+
+class _CloneTabExpansion extends ConsumerWidget {
+ final String selectedTabId;
+
+ const _CloneTabExpansion({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final appColors = AppColors.of(context);
+ final settings = ref.watch(generalSettingsWithDefaultsProvider);
+
+ return Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ leading: const Icon(MdiIcons.tabPlus),
+ title: const Text('Clone Tab'),
+ children: [
+ _buildSubTile(
+ 'Regular',
+ icon: MdiIcons.tab,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ final containerData = await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .getTabContainerData(selectedTabId);
+
+ final tabId = (tabState.tabMode is! RegularTabMode)
+ ? await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(
+ tabMode: TabMode.regular,
+ url: tabState.url,
+ containerSelection: containerData == null
+ ? const TabContainerSelection.unassigned()
+ : TabContainerSelection.specific(containerData),
+ selectTab: false,
+ )
+ : await ref
+ .read(tabRepositoryProvider.notifier)
+ .duplicateTab(
+ selectTabId: selectedTabId,
+ containerData: containerData,
+ selectTab: false,
+ );
+
+ if (context.mounted) {
+ final repo = ref.read(tabRepositoryProvider.notifier);
+ Navigator.pop(context);
+ ui_helper.showTabSwitchMessage(
+ context,
+ onSwitch: () => repo.selectTab(tabId),
+ );
+ }
+ },
+ ),
+ _buildSubTile(
+ 'Private',
+ icon: MdiIcons.dominoMask,
+ iconColor: appColors.privateTabPurple,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ final containerData = await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .getTabContainerData(selectedTabId);
+
+ final tabId = (tabState.tabMode is! PrivateTabMode)
+ ? await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(
+ url: tabState.url,
+ tabMode: TabMode.private,
+ containerSelection: containerData == null
+ ? const TabContainerSelection.unassigned()
+ : TabContainerSelection.specific(containerData),
+ selectTab: false,
+ )
+ : await ref
+ .read(tabRepositoryProvider.notifier)
+ .duplicateTab(
+ selectTabId: selectedTabId,
+ containerData: containerData,
+ selectTab: false,
+ );
+
+ if (context.mounted) {
+ final repo = ref.read(tabRepositoryProvider.notifier);
+ Navigator.pop(context);
+ ui_helper.showTabSwitchMessage(
+ context,
+ onSwitch: () => repo.selectTab(tabId),
+ );
+ }
+ },
+ ),
+ if (settings.showIsolatedTabUi)
+ _buildSubTile(
+ 'Isolated',
+ icon: MdiIcons.snowflake,
+ iconColor: appColors.isolatedTabTeal,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ final containerData = await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .getTabContainerData(selectedTabId);
+
+ final tabId = await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(
+ url: tabState.url,
+ tabMode: TabMode.newIsolated(),
+ containerSelection: containerData == null
+ ? const TabContainerSelection.unassigned()
+ : TabContainerSelection.specific(containerData),
+ selectTab: false,
+ );
+
+ if (context.mounted) {
+ final repo = ref.read(tabRepositoryProvider.notifier);
+ Navigator.pop(context);
+ ui_helper.showTabSwitchMessage(
+ context,
+ onSwitch: () => repo.selectTab(tabId),
+ );
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ContainerExpansion extends ConsumerWidget {
+ final String selectedTabId;
+
+ const _ContainerExpansion({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ leading: const Icon(MdiIcons.folder),
+ title: const Text('Containers'),
+ children: [
+ _buildSubTile(
+ 'Manage Containers',
+ icon: MdiIcons.folder,
+ onTap: () async {
+ Navigator.pop(context);
+ await const ContainerListRoute().push(context);
+ },
+ ),
+
+ // Assign Container
+ _buildSubTile(
+ 'Assign Container',
+ icon: MdiIcons.folderArrowUpDownOutline,
+ onTap: () async {
+ final selection = await const ContainerSelectionRoute()
+ .push(context);
+
+ switch (selection) {
+ case ContainerSelectionSelected(:final containerId):
+ final containerData = await ref
+ .read(containerRepositoryProvider.notifier)
+ .getContainerData(containerId);
+
+ if (containerData != null) {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .assignContainer(tabState.id, containerData);
+ }
+ case ContainerSelectionUnassigned():
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .unassignContainer(tabState.id);
+ case null:
+ break;
+ }
+
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+
+ // URL relation (conditional)
+ Consumer(
+ builder: (context, ref, child) {
+ final isSiteAssigned = ref.watch(
+ watchIsCurrentSiteAssignedToContainerProvider,
+ );
+
+ if (!isSiteAssigned.hasValue || isSiteAssigned.requireValue) {
+ return const SizedBox.shrink();
+ }
+
+ return _buildSubTile(
+ 'Assign URL to Container',
+ icon: MdiIcons.webPlus,
+ onTap: () async {
+ final selection = await const ContainerSelectionRoute()
+ .push(context);
+
+ if (selection case ContainerSelectionSelected(
+ :final containerId,
+ )) {
+ final containerData = await ref
+ .read(containerRepositoryProvider.notifier)
+ .getContainerData(containerId);
+
+ if (containerData != null) {
+ final tabState = ref.read(
+ tabStateProvider(selectedTabId),
+ );
+ final origin = tabState?.url.origin.mapNotNull(Uri.parse);
+
+ if (origin != null) {
+ await ref
+ .read(containerRepositoryProvider.notifier)
+ .replaceContainer(
+ containerData.copyWith.metadata(
+ containerData.metadata.copyWith.assignedSites([
+ ...?containerData.metadata.assignedSites,
+ origin,
+ ]),
+ ),
+ );
+ }
+ }
+ }
+
+ if (context.mounted) Navigator.pop(context);
+ },
+ );
+ },
+ ),
+
+ // Unassign URL relation (conditional)
+ Consumer(
+ builder: (context, ref, child) {
+ final isSiteAssigned = ref.watch(
+ watchIsCurrentSiteAssignedToContainerProvider,
+ );
+
+ if (!isSiteAssigned.hasValue || !isSiteAssigned.requireValue) {
+ return const SizedBox.shrink();
+ }
+
+ return _buildSubTile(
+ 'Unassign URL from Container',
+ icon: MdiIcons.webMinus,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId));
+ final origin = tabState?.url.origin.mapNotNull(Uri.parse);
+
+ if (origin != null) {
+ final containerId = await ref
+ .read(containerRepositoryProvider.notifier)
+ .siteAssignedContainerId(origin);
+
+ if (containerId != null) {
+ final containerData = await ref
+ .read(containerRepositoryProvider.notifier)
+ .getContainerData(containerId);
+
+ if (containerData != null) {
+ final updatedSites = containerData
+ .metadata
+ .assignedSites
+ ?.where((site) => site != origin)
+ .toList();
+
+ await ref
+ .read(containerRepositoryProvider.notifier)
+ .replaceContainer(
+ containerData.copyWith.metadata(
+ containerData.metadata.copyWith.assignedSites(
+ updatedSites,
+ ),
+ ),
+ );
+ }
+ }
+ }
+
+ if (context.mounted) Navigator.pop(context);
+ },
+ );
+ },
+ ),
+
+ // Unassign Container (conditional)
+ Consumer(
+ builder: (context, ref, child) {
+ final containerId = ref.watch(
+ watchContainerTabIdProvider(
+ selectedTabId,
+ ).select((value) => value.value),
+ );
+
+ if (containerId == null) return const SizedBox.shrink();
+
+ return _buildSubTile(
+ 'Unassign Container',
+ icon: MdiIcons.folderCancelOutline,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .unassignContainer(tabState.id);
+ if (context.mounted) Navigator.pop(context);
+ },
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ShareExpansion extends HookConsumerWidget {
+ final String selectedTabId;
+
+ const _ShareExpansion({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ leading: const Icon(Icons.share),
+ title: const Text('Share'),
+ children: [
+ // Copy Address
+ _buildSubTile(
+ 'Copy Address',
+ icon: MdiIcons.contentCopy,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ await Clipboard.setData(
+ ClipboardData(text: tabState.url.toString()),
+ );
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+
+ // Open in App (conditional)
+ _OpenInAppSubTile(selectedTabId: selectedTabId),
+
+ // Share Screenshot
+ _buildSubTile(
+ 'Share Screenshot',
+ icon: Icons.mobile_screen_share,
+ onTap: () async {
+ final screenshot = await ref
+ .read(selectedTabSessionProvider)
+ .requestScreenshot();
+
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+
+ if (screenshot != null) {
+ ui.decodeImageFromList(screenshot, (result) async {
+ try {
+ final png = await result.toByteData(
+ format: ui.ImageByteFormat.png,
+ );
+
+ if (png != null) {
+ final file = XFile.fromData(
+ png.buffer.asUint8List(),
+ mimeType: 'image/png',
+ );
+
+ await SharePlus.instance.share(
+ ShareParams(
+ files: [file],
+ subject: tabState.titleOrAuthority,
+ ),
+ );
+ }
+ } finally {
+ result.dispose();
+ }
+ });
+ }
+
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+
+ // Share Link
+ _buildSubTile(
+ 'Share Link',
+ icon: Icons.share,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ await SharePlus.instance.share(ShareParams(uri: tabState.url));
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+
+ // Send To Device (conditional)
+ _SendToDeviceExpansion(selectedTabId: selectedTabId),
+
+ // Show QR Code
+ _buildSubTile(
+ 'Show QR Code',
+ icon: Icons.qr_code,
+ onTap: () async {
+ final tabState = ref.read(tabStateProvider(selectedTabId))!;
+ if (context.mounted) {
+ Navigator.pop(context);
+ await showQrCode(context, tabState.url.toString());
+ }
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _OpenInAppSubTile extends HookConsumerWidget {
+ final String selectedTabId;
+
+ static final _service = GeckoAppLinksService();
+
+ const _OpenInAppSubTile({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final tabState = ref.watch(tabStateProvider(selectedTabId));
+ final url = tabState?.url;
+ final hasExternalApp = useCachedFuture(
+ () => url != null ? _service.hasExternalApp(url) : Future.value(false),
+ [url],
+ );
+
+ if (hasExternalApp.data != true) return const SizedBox.shrink();
+
+ return _buildSubTile(
+ 'Open in App',
+ icon: Icons.open_in_new,
+ onTap: () async {
+ if (url == null) return;
+ final success = await _service.openAppLink(url);
+ if (success && context.mounted) Navigator.pop(context);
+ },
+ );
+ }
+}
+
+class _SendToDeviceExpansion extends ConsumerWidget {
+ final String selectedTabId;
+
+ const _SendToDeviceExpansion({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
+ final devices = ref.watch(syncDevicesProvider);
+
+ if (!isAuthenticated) return const SizedBox.shrink();
+
+ return Skeletonizer(
+ enabled: devices.isLoading && devices.value == null,
+ child: Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ tilePadding: const EdgeInsets.only(left: 56, right: 16),
+ leading: const Icon(Icons.send_outlined, size: 20),
+ title: const Text('Send To Device', style: TextStyle(fontSize: 14)),
+ children: devices.when(
+ data: (deviceList) {
+ final targets = deviceList
+ .where(
+ (device) => !device.isCurrentDevice && device.canSendTab,
+ )
+ .toList(growable: false);
+
+ if (targets.isEmpty) {
+ return [
+ const ListTile(
+ contentPadding: EdgeInsets.only(left: 72, right: 16),
+ title: Text(
+ 'No target devices',
+ style: TextStyle(fontSize: 13),
+ ),
+ ),
+ ];
+ }
+
+ return targets
+ .map(
+ (device) => ListTile(
+ contentPadding: const EdgeInsets.only(
+ left: 72,
+ right: 16,
+ ),
+ leading: const Icon(Icons.devices_other, size: 18),
+ title: Text(
+ device.displayName,
+ style: const TextStyle(fontSize: 13),
+ ),
+ dense: true,
+ onTap: () async {
+ final tabState = ref.read(
+ tabStateProvider(selectedTabId),
+ );
+ if (tabState == null) return;
+
+ final title = tabState.title.isNotEmpty
+ ? tabState.title
+ : tabState.url.toString();
+
+ final success = await ref
+ .read(syncRepositoryProvider.notifier)
+ .sendTabToDevice(
+ deviceId: device.deviceId,
+ title: title,
+ url: tabState.url.toString(),
+ );
+
+ if (context.mounted) {
+ Navigator.pop(context);
+ if (success) {
+ ui_helper.showInfoMessage(
+ context,
+ 'Sent tab to ${device.displayName}',
+ );
+ } else {
+ ui_helper.showErrorMessage(
+ context,
+ 'Failed to send tab',
+ );
+ }
+ }
+ },
+ ),
+ )
+ .toList(growable: false);
+ },
+ loading: () => const [
+ ListTile(
+ contentPadding: EdgeInsets.only(left: 72, right: 16),
+ leading: Icon(Icons.devices_other, size: 18),
+ title: Text(
+ 'Loading devices...',
+ style: TextStyle(fontSize: 13),
+ ),
+ ),
+ ],
+ error: (_, _) => const [
+ ListTile(
+ contentPadding: EdgeInsets.only(left: 72, right: 16),
+ title: Text(
+ 'Failed to load devices',
+ style: TextStyle(fontSize: 13),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ExportExpansion extends ConsumerWidget {
+ final String selectedTabId;
+
+ const _ExportExpansion({required this.selectedTabId});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ leading: const Icon(MdiIcons.fileExport),
+ title: const Text('Export'),
+ children: [
+ // Copy as Markdown
+ _buildSubTile(
+ 'Copy as Markdown',
+ // ignore: deprecated_member_use
+ icon: MdiIcons.languageMarkdownOutline,
+ onTap: () async {
+ await _handleMarkdownExport(context, ref, selectedTabId, (
+ content,
+ fileName,
+ ) async {
+ await Clipboard.setData(ClipboardData(text: content));
+ if (context.mounted) {
+ ui_helper.showInfoMessage(
+ context,
+ 'Markdown copied to clipboard',
+ );
+ }
+ }, const Text('Copy as Markdown'));
+ },
+ ),
+
+ // Export as Markdown
+ _buildSubTile(
+ 'Export as Markdown',
+ // ignore: deprecated_member_use
+ icon: MdiIcons.languageMarkdown,
+ onTap: () async {
+ await _handleMarkdownExport(context, ref, selectedTabId, (
+ content,
+ fileName,
+ ) async {
+ await FilePicker.platform.saveFile(
+ fileName: fileName,
+ type: FileType.custom,
+ allowedExtensions: ['md'],
+ bytes: utf8.encode(content),
+ );
+ }, const Text('Export as Markdown'));
+ },
+ ),
+
+ // Export as PDF
+ _buildSubTile(
+ 'Export as PDF',
+ icon: MdiIcons.filePdfBox,
+ onTap: () async {
+ await ref
+ .read(tabSessionProvider(tabId: selectedTabId).notifier)
+ .saveToPdf();
+ if (context.mounted) Navigator.pop(context);
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _handleMarkdownExport(
+ BuildContext context,
+ WidgetRef ref,
+ String tabId,
+ Future Function(String content, String? fileName) shareAction,
+ Widget title,
+ ) async {
+ final tabData = await ref
+ .read(tabDataRepositoryProvider.notifier)
+ .getTabDataById(tabId);
+
+ if (tabData == null || tabData.fullContentMarkdown.isEmpty) {
+ if (context.mounted) Navigator.pop(context);
+ return;
+ }
+
+ final shouldShowDialog =
+ tabData.isProbablyReaderable == true &&
+ tabData.extractedContentMarkdown.isNotEmpty;
+
+ if (shouldShowDialog && context.mounted) {
+ Navigator.pop(context);
+ await showContentSelectionDialog(
+ context,
+ title: title,
+ tabData: tabData,
+ shareMarkdownAction: shareAction,
+ );
+ } else {
+ await shareAction(
+ tabData.fullContentMarkdown!,
+ tabData.title ?? tabData.url?.authority,
+ );
+ if (context.mounted) Navigator.pop(context);
+ }
+ }
+}
+
+// ─── Extensions Card ───
+
+class _ExtensionsCard extends HookConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final addonService = ref.watch(addonServiceProvider);
+ final extensionsExpanded = ref.watch(extensionsExpandedProvider);
+ final pageExtensions = ref.watch(
+ webExtensionsStateProvider(
+ WebExtensionActionType.page,
+ ).select((value) => value.values.toList()),
+ );
+ final browserExtensions = ref.watch(
+ webExtensionsStateProvider(
+ WebExtensionActionType.browser,
+ ).select((value) => value.values.toList()),
+ );
+ Future openExtensionSettings(String extensionId) async {
+ Navigator.pop(context);
+ await addonService.startAddonSettingsActivity(extensionId);
+ }
+
+ return _buildMenuCard(
+ context,
+ children: [
+ Theme(
+ data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
+ child: ExpansionTile(
+ leading: const Icon(MdiIcons.puzzle),
+ title: const Text('Extensions'),
+ initiallyExpanded: extensionsExpanded,
+ onExpansionChanged: (_) =>
+ ref.read(extensionsExpandedProvider.notifier).toggle(),
+ children: [
+ // Page extensions
+ if (pageExtensions.isNotEmpty) ...[
+ ...pageExtensions.map(
+ (extension) => ListTile(
+ contentPadding: const EdgeInsets.only(left: 56, right: 16),
+ leading: ExtensionBadgeIcon(extension),
+ title: Text(
+ extension.title ?? 'Extension',
+ style: const TextStyle(fontSize: 14),
+ ),
+ dense: true,
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const VerticalDivider(indent: 4, endIndent: 4),
+ IconButton(
+ icon: const Icon(Icons.settings, size: 20),
+ tooltip: 'Extension settings',
+ onPressed: () async {
+ await openExtensionSettings(extension.extensionId);
+ },
+ ),
+ ],
+ ),
+ onTap: () async {
+ Navigator.pop(context);
+ await addonService.invokeAddonAction(
+ extension.extensionId,
+ WebExtensionActionType.page,
+ );
+ },
+ ),
+ ),
+ const Divider(indent: 56, endIndent: 16),
+ ],
+ // Browser extensions
+ if (browserExtensions.isNotEmpty) ...[
+ ...browserExtensions.map(
+ (extension) => ListTile(
+ contentPadding: const EdgeInsets.only(left: 56, right: 16),
+ leading: ExtensionBadgeIcon(extension),
+ title: Text(
+ extension.title ?? 'Extension',
+ style: const TextStyle(fontSize: 14),
+ ),
+ dense: true,
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const VerticalDivider(indent: 4, endIndent: 4),
+ IconButton(
+ icon: const Icon(Icons.settings, size: 20),
+ tooltip: 'Extension settings',
+ onPressed: () async {
+ await openExtensionSettings(extension.extensionId);
+ },
+ ),
+ ],
+ ),
+ onTap: () async {
+ Navigator.pop(context);
+ await addonService.invokeAddonAction(
+ extension.extensionId,
+ WebExtensionActionType.browser,
+ );
+ },
+ ),
+ ),
+ const Divider(indent: 56, endIndent: 16),
+ ],
+ // Management
+ _buildSubTile(
+ 'Manage Extensions',
+ icon: MdiIcons.puzzleEdit,
+ onTap: () async {
+ Navigator.pop(context);
+ await addonService.startAddonManagerActivity();
+ },
+ ),
+ _buildSubTile(
+ 'Get Extensions',
+ icon: MdiIcons.puzzlePlus,
+ onTap: () async {
+ Navigator.pop(context);
+ final tabMode = TabMode.fromTabType(
+ ref
+ .read(generalSettingsWithDefaultsProvider)
+ .effectiveDefaultCreateTabType,
+ );
+ await ref
+ .read(tabRepositoryProvider.notifier)
+ .addTab(
+ url: Uri.parse('https://addons.mozilla.org'),
+ tabMode: tabMode,
+ containerSelection:
+ const TabContainerSelection.unassigned(),
+ selectTab: true,
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+// ─── Quick Links Grid ───
+
+class _QuickLinksGrid extends ConsumerWidget {
+ final bool showContainerUi;
+
+ const _QuickLinksGrid({required this.showContainerUi});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final torConnected = ref.watch(
+ torProxyServiceProvider.select((value) => value.value?.isRunning == true),
+ );
+
+ return Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: _buildGridItem(
+ context,
+ Icons.history,
+ 'History',
+ () async {
+ Navigator.pop(context);
+ await const HistoryRoute().push(context);
+ },
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _buildGridItem(
+ context,
+ MdiIcons.bookmarkMultiple,
+ 'Bookmarks',
+ () async {
+ Navigator.pop(context);
+ await BookmarkListRoute(
+ entryGuid: BookmarkRoot.root.id,
+ ).push(context);
+ },
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _buildGridItem(
+ context,
+ MdiIcons.fileDownload,
+ 'Downloads',
+ () async {
+ Navigator.pop(context);
+ await const HistoryDownloadsRoute().push(context);
+ },
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _buildGridItem(
+ context,
+ MdiIcons.exclamationThick,
+ 'Bangs',
+ () async {
+ Navigator.pop(context);
+ await const BangMenuRoute().push(context);
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Row(
+ children: [
+ Expanded(
+ child: _buildGridItem(
+ context,
+ TorIcons.onionAlt,
+ 'Tor\u2122 Proxy',
+ () async {
+ Navigator.pop(context);
+ await const TorProxyRoute().push(context);
+ },
+ badge: torConnected,
+ badgeColor: AppColors.of(context).torActiveGreen,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: _buildGridItem(context, Icons.rss_feed, 'Feeds', () async {
+ Navigator.pop(context);
+ await context.push(FeedListRoute().location);
+ }),
+ ),
+ const SizedBox(width: 8),
+ const Expanded(child: SizedBox.shrink()),
+ const SizedBox(width: 8),
+ const Expanded(child: SizedBox.shrink()),
+ ],
+ ),
+ ],
+ );
+ }
+
+ Widget _buildGridItem(
+ BuildContext context,
+ IconData icon,
+ String label,
+ VoidCallback onTap, {
+ bool badge = false,
+ Color? badgeColor,
+ }) {
+ final iconWidget = Icon(
+ icon,
+ color: Theme.of(context).colorScheme.onSurface,
+ );
+
+ return Material(
+ color: Theme.of(context).colorScheme.surfaceContainerHigh,
+ borderRadius: BorderRadius.circular(12),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16.0),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (badge)
+ Badge(backgroundColor: badgeColor, child: iconWidget)
+ else
+ iconWidget,
+ const SizedBox(height: 8),
+ Text(
+ label,
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onSurface,
+ fontSize: 12,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+// ─── Profile Card ───
+
+class _ProfileCard extends HookConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final theme = Theme.of(context);
+ final profile = ref.watch(selectedProfileProvider);
+ final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
+
+ return _buildMenuCard(
+ context,
+ children: [
+ ListTile(
+ leading: CircleAvatar(
+ backgroundColor: theme.colorScheme.primaryContainer,
+ child: Icon(
+ Icons.person,
+ color: theme.colorScheme.onPrimaryContainer,
+ ),
+ ),
+ title: Text(profile.value?.name ?? 'User'),
+ subtitle: Text(
+ 'Tap to switch profile',
+ style: TextStyle(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontSize: 12,
+ ),
+ ),
+ onTap: () async {
+ Navigator.pop(context);
+ await const SelectProfileRoute().push(context);
+ },
+ ),
+
+ // Sync Now (conditional)
+ if (isAuthenticated) ...[_buildDivider(), _SyncTile()],
+
+ _buildDivider(),
+ ListTile(
+ leading: const Icon(Icons.settings),
+ title: const Text('Settings'),
+ onTap: () async {
+ Navigator.pop(context);
+ await SettingsRoute().push(context);
+ },
+ ),
+
+ _buildDivider(),
+ ListTile(
+ leading: Icon(MdiIcons.power, color: theme.colorScheme.error),
+ title: Text(
+ 'Quit Browser',
+ style: TextStyle(color: theme.colorScheme.error),
+ ),
+ onTap: () async {
+ Navigator.pop(context);
+ final result = await showQuitBrowserDialog(context);
+
+ if (result == true && context.mounted) {
+ await exitApp(ProviderScope.containerOf(context));
+ }
+ },
+ ),
+ ],
+ );
+ }
+}
+
+class _SyncTile extends HookConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final syncInfo = ref.watch(
+ syncRepositoryProvider.select((value) => value.value?.account),
+ );
+
+ final syncStarted = ref.watch(
+ syncEventProvider.select(
+ (value) => value.isLoading || value.value?.$1 == SyncEvent.started,
+ ),
+ );
+ final isSyncing = syncStarted || syncInfo?.syncing == true;
+
+ final controller = useAnimationController(
+ duration: const Duration(seconds: 2),
+ );
+
+ useEffect(() {
+ if (isSyncing) {
+ unawaited(controller.repeat());
+ } else {
+ controller.stop();
+ controller.reset();
+ }
+ return null;
+ }, [isSyncing]);
+
+ return ListTile(
+ leading: RotationTransition(
+ turns: Tween(begin: 0, end: -1).animate(controller),
+ child: const Icon(Icons.sync),
+ ),
+ title: const Text('Sync Now'),
+ onTap: () async {
+ await ref.read(syncRepositoryProvider.notifier).syncNow();
+
+ final openedTabs = await ref
+ .read(syncRepositoryProvider.notifier)
+ .pollIncomingTabsAndOpen();
+
+ if (context.mounted) {
+ if (openedTabs > 0) {
+ ui_helper.showOpenedTabsFromAnotherDeviceMessage(
+ context,
+ openedTabs,
+ );
+ } else {
+ ui_helper.showInfoMessage(
+ context,
+ 'Synchronization complete',
+ duration: const Duration(seconds: 2),
+ );
+ }
+ }
+
+ if (context.mounted) Navigator.pop(context);
+ },
+ );
+ }
+}
+
+// ─── Settings & App Card ───
+
+class _SettingsCard extends StatelessWidget {
+ const _SettingsCard();
+
+ @override
+ Widget build(BuildContext context) {
+ return _buildMenuCard(
+ context,
+ children: [
+ ListTile(
+ leading: const Icon(Icons.info),
+ title: const Text('About'),
+ onTap: () async {
+ Navigator.pop(context);
+ await AboutRoute().push(context);
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
index a2907414..1f0ad67a 100644
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
+++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart
@@ -38,6 +38,7 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.dart';
+import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
@@ -55,7 +56,6 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
-import 'package:weblibre/presentation/icons/weblibre_icons.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -80,7 +80,6 @@ class BrowserTopAppBar extends HookConsumerWidget {
showQuickTabSwitcherBar: false,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
- showMainToolbarTabActionButton: !showContextualToolbar,
);
}
@@ -117,7 +116,6 @@ class BrowserBottomAppBar extends HookConsumerWidget {
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
- showMainToolbarTabActionButton: !showContextualToolbar,
);
}
@@ -147,7 +145,6 @@ class BrowserTabBar extends HookConsumerWidget {
final bool showMainToolbarTabsCount;
final bool showMainToolbarNavigationButton;
- final bool showMainToolbarTabActionButton;
const BrowserTabBar({
super.key,
@@ -157,7 +154,6 @@ class BrowserTabBar extends HookConsumerWidget {
required this.showQuickTabSwitcherBar,
required this.showMainToolbarTabsCount,
required this.showMainToolbarNavigationButton,
- required this.showMainToolbarTabActionButton,
});
static const contextualToolabarHeight = 54.0;
@@ -191,7 +187,6 @@ class BrowserTabBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extensionMenuController = useMenuController();
- final trippleDotMenuController = useMenuController();
final selectedTabId = ref.watch(selectedTabProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
@@ -360,23 +355,6 @@ class BrowserTabBar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
- if (selectedTabId != null && showMainToolbarTabActionButton)
- TabMenu(
- controller: trippleDotMenuController,
- selectedTabId: selectedTabId,
- builder: (context, controller, child) {
- return ToolbarButton(
- onTap: () {
- if (controller.isOpen) {
- controller.close();
- } else {
- controller.open();
- }
- },
- child: const Icon(WebLibreIcons.tabOptions),
- );
- },
- ),
],
),
),
@@ -444,24 +422,6 @@ class ContextualToolbar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
- if (selectedTabId != null)
- TabMenu(
- controller: useMenuController(),
- selectedTabId: selectedTabId!,
- enableNavigationButtons: false,
- builder: (context, controller, child) {
- return ToolbarButton(
- onTap: () {
- if (controller.isOpen) {
- controller.close();
- } else {
- controller.open();
- }
- },
- child: const Icon(WebLibreIcons.tabOptions),
- );
- },
- ),
],
);
}
@@ -679,8 +639,8 @@ class NavigationMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ToolbarButton(
- onTap: () {
- Scaffold.of(context).openEndDrawer();
+ onTap: () async {
+ await showBrowserMenuSheet(context);
},
child: const Icon(Icons.menu),
);
diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart
deleted file mode 100644
index 58ecf498..00000000
--- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart
+++ /dev/null
@@ -1,422 +0,0 @@
-/*
- * 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 .
- */
-
-import 'dart:async';
-
-import 'package:flutter/material.dart';
-import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
-import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
-import 'package:go_router/go_router.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:weblibre/core/design/app_colors.dart';
-import 'package:weblibre/core/routing/routes.dart';
-import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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/domain/repositories/tab.dart';
-import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
-import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
-import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
-import 'package:weblibre/features/sync/domain/repositories/sync.dart';
-import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
-import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
-import 'package:weblibre/features/user/domain/providers.dart';
-import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
-import 'package:weblibre/presentation/icons/tor_icons.dart';
-import 'package:weblibre/utils/exit_app.dart';
-import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
-
-/// Navigation drawer for the browser screen.
-/// Contains all navigation destinations and settings.
-class BrowserNavigationDrawer extends HookConsumerWidget {
- const BrowserNavigationDrawer({super.key});
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final colorScheme = Theme.of(context).colorScheme;
- final showContainerUi = ref.watch(
- generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
- );
-
- return NavigationDrawer(
- backgroundColor: colorScheme.surface,
- header: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [_ProfileHeader(), _SyncTile(), const Divider()],
- ),
- children: [
- // Section 1: Tools & Configuration
- _ExtensionsSection(),
-
- ListTile(
- leading: const Icon(Icons.settings),
- title: const Text('Settings'),
- onTap: () async {
- Navigator.of(context).pop();
- await SettingsRoute().push(context);
- },
- ),
-
- Consumer(
- builder: (context, ref, child) {
- final torConnected = ref.watch(
- torProxyServiceProvider.select(
- (value) => value.value?.isRunning == true,
- ),
- );
-
- return ListTile(
- leading: Badge(
- isLabelVisible: torConnected,
- backgroundColor: AppColors.of(context).torActiveGreen,
- child: const Icon(TorIcons.onionAlt),
- ),
- title: const Text('Tor™ Proxy'),
- onTap: () async {
- Navigator.of(context).pop();
- await const TorProxyRoute().push(context);
- },
- );
- },
- ),
-
- const Divider(),
-
- // Section 2: Data & Content
- ListTile(
- leading: const Icon(Icons.history),
- title: const Text('History'),
- onTap: () async {
- Navigator.of(context).pop();
- await const HistoryRoute().push(context);
- },
- ),
-
- ListTile(
- leading: const Icon(MdiIcons.fileDownload),
- title: const Text('Downloads'),
- onTap: () async {
- Navigator.of(context).pop();
- await const HistoryDownloadsRoute().push(context);
- },
- ),
-
- ListTile(
- leading: const Icon(MdiIcons.bookmarkMultiple),
- title: const Text('Bookmarks'),
- onTap: () async {
- Navigator.of(context).pop();
- await BookmarkListRoute(
- entryGuid: BookmarkRoot.root.id,
- ).push(context);
- },
- ),
-
- ListTile(
- leading: const Icon(MdiIcons.exclamationThick),
- title: const Text('Bangs'),
- onTap: () async {
- Navigator.of(context).pop();
- await const BangMenuRoute().push(context);
- },
- ),
-
- if (showContainerUi)
- ListTile(
- leading: const Icon(MdiIcons.folder),
- title: const Text('Containers'),
- onTap: () async {
- Navigator.of(context).pop();
- await const ContainerListRoute().push(context);
- },
- ),
-
- ListTile(
- leading: const Icon(Icons.rss_feed),
- title: const Text('Feeds'),
- onTap: () async {
- Navigator.of(context).pop();
- await context.push(FeedListRoute().location);
- },
- ),
-
- const Divider(),
-
- // Section 3: App
- ListTile(
- leading: const Icon(Icons.info),
- title: const Text('About'),
- onTap: () async {
- Navigator.of(context).pop();
- await AboutRoute().push(context);
- },
- ),
-
- // Quit Browser - styled as destructive action
- ListTile(
- leading: Icon(MdiIcons.power, color: colorScheme.error),
- title: Text(
- 'Quit Browser',
- style: TextStyle(color: colorScheme.error),
- ),
- onTap: () async {
- final result = await showQuitBrowserDialog(context);
-
- if (result == true && context.mounted) {
- await exitApp(ProviderScope.containerOf(context));
- }
- },
- ),
- ],
- );
- }
-}
-
-/// Profile header widget showing current profile information.
-class _ProfileHeader extends ConsumerWidget {
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final theme = Theme.of(context);
- final profile = ref.watch(selectedProfileProvider);
-
- return InkWell(
- onTap: () async {
- Navigator.of(context).pop(); // Close drawer
- await const SelectProfileRoute().push(context);
- },
- child: Container(
- padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- CircleAvatar(
- radius: 28,
- backgroundColor: theme.colorScheme.primaryContainer,
- child: Icon(
- Icons.person,
- size: 28,
- color: theme.colorScheme.onPrimaryContainer,
- ),
- ),
- const SizedBox(height: 12),
- Text(
- profile.value?.name ?? 'User',
- style: theme.textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.bold,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- 'Tap to switch profile',
- style: theme.textTheme.bodySmall?.copyWith(
- color: theme.colorScheme.onSurfaceVariant,
- ),
- ),
- ],
- ),
- ),
- );
- }
-}
-
-/// Extensions section with expandable list of installed extensions.
-class _ExtensionsSection extends HookConsumerWidget {
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final addonService = ref.watch(addonServiceProvider);
- final pageExtensions = ref.watch(
- webExtensionsStateProvider(
- WebExtensionActionType.page,
- ).select((value) => value.values.toList()),
- );
- final browserExtensions = ref.watch(
- webExtensionsStateProvider(
- WebExtensionActionType.browser,
- ).select((value) => value.values.toList()),
- );
-
- final isExpanded = useState(false);
-
- return Column(
- children: [
- ListTile(
- leading: const Icon(MdiIcons.puzzle),
- title: const Text('Extensions'),
- trailing: Icon(
- isExpanded.value ? Icons.expand_less : Icons.expand_more,
- ),
- onTap: () => isExpanded.value = !isExpanded.value,
- ),
- if (isExpanded.value)
- Padding(
- padding: const EdgeInsets.only(left: 16),
- child: Column(
- children: [
- // Page extensions
- if (pageExtensions.isNotEmpty) ...[
- ...pageExtensions.map(
- (extension) => ListTile(
- leading: ExtensionBadgeIcon(extension),
- title: Text(extension.title ?? 'Extension'),
- dense: true,
- onTap: () async {
- Navigator.of(context).pop(); // Close drawer
- await addonService.invokeAddonAction(
- extension.extensionId,
- WebExtensionActionType.page,
- );
- },
- ),
- ),
- const Divider(indent: 16, endIndent: 16),
- ],
- // Browser extensions
- if (browserExtensions.isNotEmpty) ...[
- ...browserExtensions.map(
- (extension) => ListTile(
- leading: ExtensionBadgeIcon(extension),
- title: Text(extension.title ?? 'Extension'),
- dense: true,
- onTap: () async {
- Navigator.of(context).pop(); // Close drawer
- await addonService.invokeAddonAction(
- extension.extensionId,
- WebExtensionActionType.browser,
- );
- },
- ),
- ),
- const Divider(indent: 16, endIndent: 16),
- ],
- // Management options
- ListTile(
- leading: const Icon(MdiIcons.puzzleEdit),
- title: const Text('Manage Extensions'),
- dense: true,
- onTap: () async {
- Navigator.of(context).pop(); // Close drawer
- await addonService.startAddonManagerActivity();
- },
- ),
- ListTile(
- leading: const Icon(MdiIcons.puzzlePlus),
- title: const Text('Get Extensions'),
- dense: true,
- onTap: () async {
- Navigator.of(context).pop(); // Close drawer
- final tabMode = TabMode.fromTabType(
- ref
- .read(generalSettingsWithDefaultsProvider)
- .effectiveDefaultCreateTabType,
- );
-
- await ref
- .read(tabRepositoryProvider.notifier)
- .addTab(
- url: Uri.parse('https://addons.mozilla.org'),
- tabMode: tabMode,
- containerSelection:
- const TabContainerSelection.unassigned(),
- selectTab: true,
- );
- },
- ),
- ],
- ),
- ),
- ],
- );
- }
-}
-
-/// Sync tile widget shown in navigation drawer when sync is active.
-/// Displays sync status and allows manual sync trigger.
-class _SyncTile extends HookConsumerWidget {
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
-
- // Only show if sync is active
- if (!isAuthenticated) {
- return const SizedBox.shrink();
- }
-
- final syncInfo = ref.watch(
- syncRepositoryProvider.select((value) => value.value?.account),
- );
-
- final syncStarted = ref.watch(
- syncEventProvider.select(
- (value) => value.isLoading || value.value?.$1 == SyncEvent.started,
- ),
- );
- final isSyncing = syncStarted || syncInfo?.syncing == true;
-
- final controller = useAnimationController(
- duration: const Duration(seconds: 2),
- );
-
- useEffect(() {
- if (isSyncing) {
- unawaited(controller.repeat());
- } else {
- controller.stop();
- controller.reset();
- }
- return null;
- }, [isSyncing]);
-
- return ListTile(
- leading: RotationTransition(
- turns: Tween(begin: 0, end: -1).animate(controller),
- child: const Icon(Icons.sync),
- ),
- title: const Text('Sync Now'),
- onTap: () async {
- await ref.read(syncRepositoryProvider.notifier).syncNow();
-
- final openedTabs = await ref
- .read(syncRepositoryProvider.notifier)
- .pollIncomingTabsAndOpen();
-
- if (context.mounted) {
- if (openedTabs > 0) {
- ui_helper.showOpenedTabsFromAnotherDeviceMessage(
- context,
- openedTabs,
- );
- } else {
- ui_helper.showInfoMessage(
- context,
- 'Synchronization complete',
- duration: const Duration(seconds: 2),
- );
- }
- }
-
- if (context.mounted) {
- Navigator.of(context).pop();
- }
- },
- );
- }
-}
diff --git a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart
index 437d50e9..9932e04a 100644
--- a/app/lib/features/settings/presentation/screens/appearance_display_settings.dart
+++ b/app/lib/features/settings/presentation/screens/appearance_display_settings.dart
@@ -172,7 +172,6 @@ class _GesturesSection extends StatelessWidget {
children: [
SettingSection(name: 'Gestures'),
_PullToRefreshTile(),
- _DrawerGestureTile(),
_DoubleBackCloseTabTile(),
],
);
@@ -509,31 +508,6 @@ class _PullToRefreshTile extends HookConsumerWidget {
}
}
-class _DrawerGestureTile extends HookConsumerWidget {
- const _DrawerGestureTile();
-
- @override
- Widget build(BuildContext context, WidgetRef ref) {
- final drawerGestureEnabled = ref.watch(
- generalSettingsWithDefaultsProvider.select((s) => s.drawerGestureEnabled),
- );
-
- return SwitchListTile.adaptive(
- title: const Text('Drawer Swipe Gesture'),
- subtitle: const Text('Swipe from screen edge to open navigation drawer'),
- secondary: const Icon(MdiIcons.gestureSwipe),
- value: drawerGestureEnabled,
- onChanged: (value) async {
- await ref
- .read(saveGeneralSettingsControllerProvider.notifier)
- .save(
- (currentSettings) =>
- currentSettings.copyWith.drawerGestureEnabled(value),
- );
- },
- );
- }
-}
class _DoubleBackCloseTabTile extends HookConsumerWidget {
const _DoubleBackCloseTabTile();
diff --git a/app/lib/features/settings/presentation/screens/settings.dart b/app/lib/features/settings/presentation/screens/settings.dart
index d5563a45..0ec72840 100644
--- a/app/lib/features/settings/presentation/screens/settings.dart
+++ b/app/lib/features/settings/presentation/screens/settings.dart
@@ -58,7 +58,6 @@ class _AppearanceDisplayTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Appearance & Display'),
@@ -83,7 +82,6 @@ class _PrivacySecurityTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Privacy & Security'),
@@ -108,7 +106,6 @@ class _SearchContentTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Search & Content'),
@@ -133,7 +130,6 @@ class _TabsBehaviorTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Tabs & Behavior'),
@@ -158,7 +154,6 @@ class _FingerprintingTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Fingerprinting'),
@@ -183,7 +178,6 @@ class _SyncTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Firefox Sync'),
@@ -208,7 +202,6 @@ class _AdvancedTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
- color: Theme.of(context).highlightColor,
clipBehavior: Clip.antiAlias,
child: ListTile(
title: const Text('Advanced'),
diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart
index 4a8cdee7..0e789b2a 100644
--- a/app/lib/features/user/data/models/general_settings.dart
+++ b/app/lib/features/user/data/models/general_settings.dart
@@ -91,7 +91,6 @@ class GeneralSettings with FastEquatable {
final bool tabListShowFavicons;
final bool quickTabSwitcherShowTitles;
final bool quickTabSwitcherShowHistorySuggestions;
- final bool drawerGestureEnabled;
final String syncServerOverride;
final String syncTokenServerOverride;
final bool urlCleanerEnabled;
@@ -137,7 +136,6 @@ class GeneralSettings with FastEquatable {
required this.tabListShowFavicons,
required this.quickTabSwitcherShowTitles,
required this.quickTabSwitcherShowHistorySuggestions,
- required this.drawerGestureEnabled,
required this.syncServerOverride,
required this.syncTokenServerOverride,
required this.urlCleanerEnabled,
@@ -184,7 +182,6 @@ class GeneralSettings with FastEquatable {
bool? tabListShowFavicons,
bool? quickTabSwitcherShowTitles,
bool? quickTabSwitcherShowHistorySuggestions,
- bool? drawerGestureEnabled,
String? syncServerOverride,
String? syncTokenServerOverride,
bool? urlCleanerEnabled,
@@ -234,7 +231,6 @@ class GeneralSettings with FastEquatable {
quickTabSwitcherShowTitles = quickTabSwitcherShowTitles ?? true,
quickTabSwitcherShowHistorySuggestions =
quickTabSwitcherShowHistorySuggestions ?? true,
- drawerGestureEnabled = drawerGestureEnabled ?? false,
syncServerOverride = syncServerOverride ?? '',
syncTokenServerOverride = syncTokenServerOverride ?? '',
urlCleanerEnabled = urlCleanerEnabled ?? true,
@@ -305,7 +301,6 @@ class GeneralSettings with FastEquatable {
tabListShowFavicons,
quickTabSwitcherShowTitles,
quickTabSwitcherShowHistorySuggestions,
- drawerGestureEnabled,
syncServerOverride,
syncTokenServerOverride,
urlCleanerEnabled,
diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart
index 73b52137..82730812 100644
--- a/app/lib/features/user/data/models/general_settings.g.dart
+++ b/app/lib/features/user/data/models/general_settings.g.dart
@@ -85,8 +85,6 @@ abstract class _$GeneralSettingsCWProxy {
bool quickTabSwitcherShowHistorySuggestions,
);
- GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled);
-
GeneralSettings syncServerOverride(String syncServerOverride);
GeneralSettings syncTokenServerOverride(String syncTokenServerOverride);
@@ -152,7 +150,6 @@ abstract class _$GeneralSettingsCWProxy {
bool tabListShowFavicons,
bool quickTabSwitcherShowTitles,
bool quickTabSwitcherShowHistorySuggestions,
- bool drawerGestureEnabled,
String syncServerOverride,
String syncTokenServerOverride,
bool urlCleanerEnabled,
@@ -309,10 +306,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
quickTabSwitcherShowHistorySuggestions,
);
- @override
- GeneralSettings drawerGestureEnabled(bool drawerGestureEnabled) =>
- call(drawerGestureEnabled: drawerGestureEnabled);
-
@override
GeneralSettings syncServerOverride(String syncServerOverride) =>
call(syncServerOverride: syncServerOverride);
@@ -404,7 +397,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
Object? quickTabSwitcherShowTitles = const $CopyWithPlaceholder(),
Object? quickTabSwitcherShowHistorySuggestions =
const $CopyWithPlaceholder(),
- Object? drawerGestureEnabled = const $CopyWithPlaceholder(),
Object? syncServerOverride = const $CopyWithPlaceholder(),
Object? syncTokenServerOverride = const $CopyWithPlaceholder(),
Object? urlCleanerEnabled = const $CopyWithPlaceholder(),
@@ -602,12 +594,6 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy {
? _value.quickTabSwitcherShowHistorySuggestions
// ignore: cast_nullable_to_non_nullable
: quickTabSwitcherShowHistorySuggestions as bool,
- drawerGestureEnabled:
- drawerGestureEnabled == const $CopyWithPlaceholder() ||
- drawerGestureEnabled == null
- ? _value.drawerGestureEnabled
- // ignore: cast_nullable_to_non_nullable
- : drawerGestureEnabled as bool,
syncServerOverride:
syncServerOverride == const $CopyWithPlaceholder() ||
syncServerOverride == null
@@ -761,7 +747,6 @@ GeneralSettings _$GeneralSettingsFromJson(
quickTabSwitcherShowTitles: json['quickTabSwitcherShowTitles'] as bool?,
quickTabSwitcherShowHistorySuggestions:
json['quickTabSwitcherShowHistorySuggestions'] as bool?,
- drawerGestureEnabled: json['drawerGestureEnabled'] as bool?,
syncServerOverride: json['syncServerOverride'] as String?,
syncTokenServerOverride: json['syncTokenServerOverride'] as String?,
urlCleanerEnabled: json['urlCleanerEnabled'] as bool?,
@@ -823,7 +808,6 @@ Map _$GeneralSettingsToJson(
'quickTabSwitcherShowTitles': instance.quickTabSwitcherShowTitles,
'quickTabSwitcherShowHistorySuggestions':
instance.quickTabSwitcherShowHistorySuggestions,
- 'drawerGestureEnabled': instance.drawerGestureEnabled,
'syncServerOverride': instance.syncServerOverride,
'syncTokenServerOverride': instance.syncTokenServerOverride,
'urlCleanerEnabled': instance.urlCleanerEnabled,
diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart
index e26540b1..98ee033a 100644
--- a/app/lib/features/user/domain/repositories/general_settings.dart
+++ b/app/lib/features/user/domain/repositories/general_settings.dart
@@ -164,10 +164,6 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository {
DriftSqlType.bool,
db.typeMapping,
),
- 'drawerGestureEnabled': settings['drawerGestureEnabled']?.readAs(
- DriftSqlType.bool,
- db.typeMapping,
- ),
'syncServerOverride': settings['syncServerOverride']?.readAs(
DriftSqlType.string,
db.typeMapping,
diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart
index 217543b2..2a18e878 100644
--- a/app/lib/features/user/domain/repositories/general_settings.g.dart
+++ b/app/lib/features/user/domain/repositories/general_settings.g.dart
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
- r'51cfcf20b24705f5c6f51d4e204fff5a8e4eee89';
+ r'4c2ee264cafd243916e59a78b0d5723997eab823';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier {
diff --git a/app/lib/main.dart b/app/lib/main.dart
index 4e0633a6..9a53901d 100644
--- a/app/lib/main.dart
+++ b/app/lib/main.dart
@@ -32,6 +32,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logger/logger.dart';
+import 'package:material_color_utilities/material_color_utilities.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/error_observer.dart';
@@ -48,6 +49,38 @@ import 'package:weblibre/features/web_feed/utils/fetch_entrypoint.dart';
import 'package:weblibre/presentation/hooks/on_initialization.dart';
import 'package:weblibre/presentation/main_app.dart';
+ColorScheme _fixSurfaceContainerColors(
+ ColorScheme scheme,
+ TonalPalette neutralPalette,
+ Brightness brightness,
+) {
+ if (brightness == Brightness.light) {
+ return scheme.copyWith(
+ surfaceContainerLowest: Color(neutralPalette.get(100)),
+ surfaceContainerLow: Color(neutralPalette.get(96)),
+ surfaceContainer: Color(neutralPalette.get(94)),
+ surfaceContainerHigh: Color(neutralPalette.get(92)),
+ surfaceContainerHighest: Color(neutralPalette.get(90)),
+ );
+ } else {
+ return scheme.copyWith(
+ surfaceContainerLowest: Color(neutralPalette.get(4)),
+ surfaceContainerLow: Color(neutralPalette.get(10)),
+ surfaceContainer: Color(neutralPalette.get(12)),
+ surfaceContainerHigh: Color(neutralPalette.get(17)),
+ surfaceContainerHighest: Color(neutralPalette.get(22)),
+ );
+ }
+}
+
+bool _hasBrokenSurfaceContainerColors(ColorScheme scheme) {
+ return scheme.surfaceContainerLowest == scheme.surface &&
+ scheme.surfaceContainerLow == scheme.surface &&
+ scheme.surfaceContainer == scheme.surface &&
+ scheme.surfaceContainerHigh == scheme.surface &&
+ scheme.surfaceContainerHighest == scheme.surface;
+}
+
class _MainWidget extends HookConsumerWidget {
const _MainWidget();
@@ -159,18 +192,45 @@ class _MainWidget extends HookConsumerWidget {
}
});
+ final corePaletteSnapshot = useFuture(
+ useMemoized(() => DynamicColorPlugin.getCorePalette()),
+ );
+
return DynamicColorBuilder(
builder: (lightDynamic, darkDynamic) {
ColorScheme lightColorScheme;
ColorScheme darkColorScheme;
if (lightDynamic != null && darkDynamic != null) {
+ final corePalette = corePaletteSnapshot.data;
+
// On Android S+ devices, use the provided dynamic color scheme.
// (Recommended) Harmonize the dynamic color scheme' built-in semantic colors.
- lightColorScheme = lightDynamic.harmonized();
+ final harmonizedLight = lightDynamic.harmonized();
+ final harmonizedDark = darkDynamic.harmonized();
- // Repeat for the dark color scheme.
- darkColorScheme = darkDynamic.harmonized();
+ // Workaround for https://github.com/material-foundation/flutter-packages/issues/649
+ // dynamic_color package returns broken surfaceContainer* colors.
+ // Fix them using the neutral tonal palette from CorePalette.
+ if (corePalette != null) {
+ lightColorScheme = _hasBrokenSurfaceContainerColors(harmonizedLight)
+ ? _fixSurfaceContainerColors(
+ harmonizedLight,
+ corePalette.neutral,
+ Brightness.light,
+ )
+ : harmonizedLight;
+ darkColorScheme = _hasBrokenSurfaceContainerColors(harmonizedDark)
+ ? _fixSurfaceContainerColors(
+ harmonizedDark,
+ corePalette.neutral,
+ Brightness.dark,
+ )
+ : harmonizedDark;
+ } else {
+ lightColorScheme = harmonizedLight;
+ darkColorScheme = harmonizedDark;
+ }
} else {
// Otherwise, use fallback schemes.
lightColorScheme = ColorScheme.fromSeed(
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index 9a6a6521..a65cba47 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -56,6 +56,7 @@ dependencies:
path: ../packages/locale_resolver
logger: ^2.6.2
markdown: ^7.3.0
+ material_color_utilities: ^0.13.0
mime: ^2.0.0
nullability: ^1.0.0
package_info_plus: ^9.0.0
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
index cb302749..1a096fa6 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoAddonsApiImpl.kt
@@ -9,15 +9,21 @@ package eu.weblibre.flutter_mozilla_components.api
import android.content.Context
import android.content.Intent
import eu.weblibre.flutter_mozilla_components.GlobalComponents
+import eu.weblibre.flutter_mozilla_components.addons.AddonInternalSettingsActivity
import eu.weblibre.flutter_mozilla_components.addons.AddonsActivity
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonsApi
import eu.weblibre.flutter_mozilla_components.pigeons.WebExtensionActionType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import mozilla.components.concept.engine.webextension.InstallationMethod
class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
+ private val scope = CoroutineScope(Dispatchers.IO)
override fun startAddonManagerActivity() {
val intent = Intent(context, AddonsActivity::class.java)
@@ -25,6 +31,55 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
context.startActivity(intent)
}
+ override fun startAddonSettingsActivity(extensionId: String) {
+ scope.launch {
+ val addon = runCatching {
+ components.core.addonManager.getAddons()
+ .find { it.id == extensionId }
+ }.getOrNull()
+
+ if (addon == null) {
+ withContext(Dispatchers.Main) {
+ startAddonManagerActivity()
+ }
+ return@launch
+ }
+
+ val optionsPageUrl = addon.installedState?.optionsPageUrl
+ if (optionsPageUrl.isNullOrEmpty()) {
+ withContext(Dispatchers.Main) {
+ startAddonManagerActivity()
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ if (addon.installedState?.openOptionsPageInTab == true) {
+ components.useCases.tabsUseCases.selectOrAddTab(
+ url = optionsPageUrl,
+ ignoreFragment = true,
+ )
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ launchIntent?.addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
+ Intent.FLAG_ACTIVITY_SINGLE_TOP,
+ )
+ if (launchIntent != null) {
+ context.startActivity(launchIntent)
+ } else {
+ startAddonManagerActivity()
+ }
+ } else {
+ val intent = Intent(context, AddonInternalSettingsActivity::class.java)
+ intent.putExtra("add_on", addon)
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ context.startActivity(intent)
+ }
+ }
+ }
+ }
+
override fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType) {
when(actionType) {
WebExtensionActionType.BROWSER -> components.features.webExtensionToolbarFeature.invokeAddonBrowserAction(extensionId)
@@ -50,4 +105,4 @@ class GeckoAddonsApiImpl(private val context: Context) : GeckoAddonsApi {
}
)
}
-}
\ No newline at end of file
+}
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
index b11effdb..48e8142d 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
@@ -6815,6 +6815,7 @@ class GeckoSelectionActionEvents(private val binaryMessenger: BinaryMessenger, p
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface GeckoAddonsApi {
fun startAddonManagerActivity()
+ fun startAddonSettingsActivity(extensionId: String)
fun invokeAddonAction(extensionId: String, actionType: WebExtensionActionType)
fun installAddon(url: String, callback: (Result) -> Unit)
@@ -6843,6 +6844,24 @@ interface GeckoAddonsApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.startAddonSettingsActivity$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val extensionIdArg = args[0] as String
+ val wrapped: List = try {
+ api.startAddonSettingsActivity(extensionIdArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ GeckoPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
run {
val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.invokeAddonAction$separatedMessageChannelSuffix", codec)
if (api != null) {
diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_addon.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_addon.dart
index 4112cb67..845dcae5 100644
--- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_addon.dart
+++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_addon.dart
@@ -38,6 +38,10 @@ class GeckoAddonService extends GeckoAddonEvents {
return _api.startAddonManagerActivity();
}
+ Future startAddonSettingsActivity(String extensionId) {
+ return _api.startAddonSettingsActivity(extensionId);
+ }
+
Future invokeAddonAction(
String extensionId,
WebExtensionActionType actionType,
diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
index c9576e53..e4177fb2 100644
--- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
+++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
@@ -8550,6 +8550,31 @@ class GeckoAddonsApi {
}
}
+ Future startAddonSettingsActivity(String extensionId) async {
+ final pigeonVar_channelName =
+ 'dev.flutter.pigeon.flutter_mozilla_components.GeckoAddonsApi.startAddonSettingsActivity$pigeonVar_messageChannelSuffix';
+ final pigeonVar_channel = BasicMessageChannel