From b7edd950937421abc0e17300b3c234ef310d6db1 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sat, 28 Feb 2026 18:37:35 +0100 Subject: [PATCH] merge navigation and tab menu into bottom sheet --- .../providers/extensions_expanded.dart | 46 + .../providers/extensions_expanded.g.dart | 63 + .../browser/presentation/screens/browser.dart | 15 +- .../widgets/browser_menu_sheet.dart | 1805 +++++++++++++++++ .../browser_modules/bottom_app_bar.dart | 46 +- .../browser_modules/navigation_drawer.dart | 422 ---- .../screens/appearance_display_settings.dart | 26 - .../presentation/screens/settings.dart | 7 - .../user/data/models/general_settings.dart | 5 - .../user/data/models/general_settings.g.dart | 16 - .../domain/repositories/general_settings.dart | 4 - .../repositories/general_settings.g.dart | 2 +- app/lib/main.dart | 66 +- app/pubspec.yaml | 1 + .../api/GeckoAddonsApiImpl.kt | 57 +- .../pigeons/Gecko.g.kt | 19 + .../lib/src/domain/services/gecko_addon.dart | 4 + .../lib/src/pigeons/gecko.g.dart | 25 + .../pigeons/gecko.dart | 2 + 19 files changed, 2095 insertions(+), 536 deletions(-) create mode 100644 app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/providers/extensions_expanded.g.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart delete mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart 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( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [extensionId], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + Future invokeAddonAction( String extensionId, WebExtensionActionType actionType, diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 883f2bee..c2963cd7 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -1602,6 +1602,8 @@ abstract class GeckoSelectionActionEvents { abstract class GeckoAddonsApi { void startAddonManagerActivity(); + void startAddonSettingsActivity(String extensionId); + void invokeAddonAction(String extensionId, WebExtensionActionType actionType); @async