diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart index 8ef1a732..3aa9db44 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/toolbar_visibility.g.dart @@ -61,7 +61,7 @@ final class ToolbarVisibilityControllerProvider } String _$toolbarVisibilityControllerHash() => - r'242449893afd6780d977544659e83d4641c8a0ad'; + r'e947508c351d4cbbe8c63289171a6d0a34f1a61a'; final class ToolbarVisibilityControllerFamily extends $Family with 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 index 98c5b1c6..8fc2d5d4 100644 --- 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 @@ -50,6 +50,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/dialog import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.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/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.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'; @@ -120,11 +123,6 @@ class _BrowserMenuSheet extends HookConsumerWidget { 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), @@ -161,6 +159,18 @@ class _BrowserMenuSheet extends HookConsumerWidget { ], ), ), + + // Persistent navigation row at the bottom + if (selectedTabId != null) ...[ + const Divider(height: 1), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + top: 8, + ), + child: _NavigationRow(selectedTabId: selectedTabId), + ), + ], ], ); }, @@ -187,12 +197,14 @@ Widget _buildSubTile( String title, { IconData? icon, Color? iconColor, + Widget? trailing, 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)), + trailing: trailing, dense: true, onTap: onTap, ); @@ -1013,20 +1025,74 @@ class _ShareExpansion extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); + final tabState = ref.watch(tabStateProvider(selectedTabId)); + final tabUrl = tabState?.url; + + final cleanedUrl = useState(null); + final cleaner = useUrlCleanerController( + sourceUrl: (cleanedUrl.value ?? tabUrl)?.toString(), + rules: catalogAsync.value, + cleanerEnabled: settings.urlCleanerEnabled, + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + autoApply: settings.urlCleanerAutoApply, + getCurrentUrl: () => (cleanedUrl.value ?? tabUrl)?.toString(), + onApplyCleanedUrl: (cleanedUrlValue) { + cleanedUrl.value = Uri.parse(cleanedUrlValue); + }, + ); + + void applyCleanUrl() { + if (cleaner.applyCleanUrl()) { + ui_helper.showInfoMessage(context, 'URL cleaned'); + } + } + + void applySelectedTrackingRemovals(String previewUrl) { + if (cleaner.applyPreviewUrl(previewUrl)) { + ui_helper.showInfoMessage(context, 'URL preview applied'); + } + } + + final effectiveUrl = cleanedUrl.value ?? tabUrl; + final cleaningHappened = cleanedUrl.value != null; + final hasActiveTracking = cleaner.result?.removedParams.isNotEmpty ?? false; + final cleanedTrailing = cleaningHappened + ? Icon( + hasActiveTracking + ? MdiIcons.shieldLinkVariantOutline + : MdiIcons.shieldLinkVariant, + size: 18, + color: Theme.of(context).colorScheme.primary, + ) + : null; + final showCleanerTile = tabUrl != null && cleaner.showTile; + return Theme( data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( leading: const Icon(Icons.share), title: const Text('Share'), children: [ + if (showCleanerTile) + UrlCleanerTile( + result: cleaner.result!, + currentUrl: effectiveUrl?.toString() ?? '', + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + onClean: applyCleanUrl, + onApplySelectedRemovals: applySelectedTrackingRemovals, + applied: cleaner.applied, + ), + // Copy Address _buildSubTile( 'Copy Address', icon: MdiIcons.contentCopy, + trailing: cleanedTrailing, onTap: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; await Clipboard.setData( - ClipboardData(text: tabState.url.toString()), + ClipboardData(text: effectiveUrl.toString()), ); if (context.mounted) Navigator.pop(context); }, @@ -1044,7 +1110,7 @@ class _ShareExpansion extends HookConsumerWidget { .read(selectedTabSessionProvider) .requestScreenshot(); - final tabState = ref.read(tabStateProvider(selectedTabId))!; + final ts = ref.read(tabStateProvider(selectedTabId))!; if (screenshot != null) { ui.decodeImageFromList(screenshot, (result) async { @@ -1062,7 +1128,7 @@ class _ShareExpansion extends HookConsumerWidget { await SharePlus.instance.share( ShareParams( files: [file], - subject: tabState.titleOrAuthority, + subject: ts.titleOrAuthority, ), ); } @@ -1080,9 +1146,9 @@ class _ShareExpansion extends HookConsumerWidget { _buildSubTile( 'Share Link', icon: Icons.share, + trailing: cleanedTrailing, onTap: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - await SharePlus.instance.share(ShareParams(uri: tabState.url)); + await SharePlus.instance.share(ShareParams(uri: effectiveUrl)); if (context.mounted) Navigator.pop(context); }, ), @@ -1094,11 +1160,11 @@ class _ShareExpansion extends HookConsumerWidget { _buildSubTile( 'Show QR Code', icon: Icons.qr_code, + trailing: cleanedTrailing, onTap: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; if (context.mounted) { Navigator.pop(context); - await showQrCode(context, tabState.url.toString()); + await showQrCode(context, effectiveUrl.toString()); } }, ), @@ -1716,8 +1782,8 @@ class _ProfileCard extends HookConsumerWidget { Navigator.pop(context); final result = await showQuitBrowserDialog(context); - if (result == true && context.mounted) { - await exitApp(ProviderScope.containerOf(context)); + if (result == true) { + await exitApp(ref.container); } }, ), 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 d48cdb17..62b1c875 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 @@ -42,8 +42,8 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/contro 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'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; @@ -289,9 +289,6 @@ class BrowserTabBar extends HookConsumerWidget { displayedSheet is! ViewTabsSheet) ? ContainerColors.forAppBar(containerColor) : null, - leading: showMainToolbarNavigationButton - ? NavigationMenuButton(selectedTabId: selectedTabId) - : null, title: (selectedTabId != null && displayedSheet is! ViewTabsSheet) ? const AppBarTitle() @@ -356,6 +353,8 @@ class BrowserTabBar extends HookConsumerWidget { displayedSheet: displayedSheet, showLongPressMenu: true, ), + if (showMainToolbarNavigationButton) + NavigationMenuButton(selectedTabId: selectedTabId), ], ), ), @@ -421,7 +420,6 @@ class ContextualToolbar extends HookConsumerWidget { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - NavigationMenuButton(selectedTabId: selectedTabId), if (tabState?.historyState.canGoBack == true || tabState?.isLoading == true) NavigateBackButton( @@ -447,6 +445,7 @@ class ContextualToolbar extends HookConsumerWidget { displayedSheet: displayedSheet, showLongPressMenu: false, ), + NavigationMenuButton(selectedTabId: selectedTabId), ], ); } @@ -642,37 +641,21 @@ class QuickTabSwitcher extends HookConsumerWidget { } } -class ShareMenuButton extends HookConsumerWidget { +class ShareMenuButton extends StatelessWidget { final String? selectedTabId; const ShareMenuButton({super.key, required this.selectedTabId}); @override - Widget build(BuildContext context, WidgetRef ref) { - final menuController = useMenuController(); - - return MenuAnchor( - controller: menuController, - menuChildren: [ - CopyAddressMenuItemButton(selectedTabId: selectedTabId), - OpenInAppMenuItemButton(selectedTabId: selectedTabId), - ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), - ShareMenuItemButton(selectedTabId: selectedTabId), - SendTabToDeviceMenuItemButton(selectedTabId: selectedTabId), - ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), - ], - builder: (context, controller, child) { - return IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - icon: const Icon(Icons.share), - ); + Widget build(BuildContext context) { + return IconButton( + onPressed: () async { + final tabId = selectedTabId; + if (tabId != null) { + await showShareBottomSheet(context, selectedTabId: tabId); + } }, + icon: const Icon(Icons.share), ); } } @@ -688,7 +671,7 @@ class NavigationMenuButton extends StatelessWidget { onTap: () async { await showBrowserMenuSheet(context); }, - child: const Icon(Icons.menu), + child: const Icon(Icons.more_vert), ); } } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart new file mode 100644 index 00000000..c10ff027 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/share_bottom_sheet.dart @@ -0,0 +1,468 @@ +/* + * 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:ui' as ui; + +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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:skeletonizer/skeletonizer.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/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; +import 'package:weblibre/features/sync/domain/repositories/sync.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +Future showShareBottomSheet( + BuildContext context, { + required String selectedTabId, +}) { + return showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => ShareBottomSheet(selectedTabId: selectedTabId), + ); +} + +class ShareBottomSheet extends HookConsumerWidget { + final String selectedTabId; + + const ShareBottomSheet({super.key, required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); + + final tabUrl = ref.watch( + tabStateProvider(selectedTabId).select((v) => v?.url), + ); + + final cleanedUrl = useState(null); + final cleaner = useUrlCleanerController( + sourceUrl: (cleanedUrl.value ?? tabUrl)?.toString(), + rules: catalogAsync.value, + cleanerEnabled: settings.urlCleanerEnabled, + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + autoApply: settings.urlCleanerAutoApply, + getCurrentUrl: () => (cleanedUrl.value ?? tabUrl)?.toString(), + onApplyCleanedUrl: (cleanedUrlValue) { + cleanedUrl.value = Uri.parse(cleanedUrlValue); + }, + ); + + void applyCleanUrl() { + if (cleaner.applyCleanUrl()) { + ui_helper.showInfoMessage(context, 'URL cleaned'); + } + } + + void applySelectedTrackingRemovals(String previewUrl) { + if (cleaner.applyPreviewUrl(previewUrl)) { + ui_helper.showInfoMessage(context, 'URL preview applied'); + } + } + + final effectiveUrl = cleanedUrl.value ?? tabUrl; + final cleaningHappened = cleanedUrl.value != null; + final hasActiveTracking = cleaner.result?.removedParams.isNotEmpty ?? false; + final urlWasCleaned = cleaningHappened && !hasActiveTracking; + final trackingStatusTrailing = cleaningHappened + ? Icon( + hasActiveTracking + ? MdiIcons.shieldLinkVariantOutline + : MdiIcons.shieldLinkVariant, + size: 18, + color: Theme.of(context).colorScheme.primary, + ) + : null; + final showCleanerTile = tabUrl != null && cleaner.showTile; + + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with URL and tracking status + _ShareHeader( + url: effectiveUrl?.toString() ?? '', + urlWasCleaned: urlWasCleaned, + hasTracking: showCleanerTile && !cleaner.applied, + cleanerResult: showCleanerTile ? cleaner.result : null, + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + onClean: showCleanerTile && !cleaner.applied + ? applyCleanUrl + : null, + onApplySelectedRemovals: applySelectedTrackingRemovals, + ), + + // Copy Address + ListTile( + leading: const Icon(MdiIcons.contentCopy), + title: const Text('Copy Address'), + trailing: trackingStatusTrailing, + onTap: () async { + await Clipboard.setData( + ClipboardData(text: effectiveUrl.toString()), + ); + if (context.mounted) Navigator.pop(context); + }, + ), + + // Open in App (conditional) + _OpenInAppTile(selectedTabId: selectedTabId), + + // Share Screenshot + ListTile( + leading: const Icon(Icons.mobile_screen_share), + title: const Text('Share Screenshot'), + onTap: () async { + final screenshot = await ref + .read(selectedTabSessionProvider) + .requestScreenshot(); + + final ts = 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: ts.titleOrAuthority, + ), + ); + } + } finally { + result.dispose(); + } + }); + } + + if (context.mounted) Navigator.pop(context); + }, + ), + + // Share Link + ListTile( + leading: const Icon(Icons.share), + title: const Text('Share Link'), + trailing: trackingStatusTrailing, + onTap: () async { + await SharePlus.instance.share(ShareParams(uri: effectiveUrl)); + if (context.mounted) Navigator.pop(context); + }, + ), + + // Send To Device (conditional) + _SendToDeviceTile(selectedTabId: selectedTabId), + + // Show QR Code + ListTile( + leading: const Icon(Icons.qr_code), + title: const Text('Show QR Code'), + trailing: trackingStatusTrailing, + onTap: () async { + if (context.mounted) { + Navigator.pop(context); + await showQrCode(context, effectiveUrl.toString()); + } + }, + ), + ], + ), + ), + ); + } +} + +class _ShareHeader extends StatelessWidget { + final String url; + final bool urlWasCleaned; + final bool hasTracking; + final UrlCleanerResult? cleanerResult; + final bool allowReferralMarketing; + final VoidCallback? onClean; + final ValueChanged? onApplySelectedRemovals; + + const _ShareHeader({ + required this.url, + required this.urlWasCleaned, + required this.hasTracking, + required this.allowReferralMarketing, + this.cleanerResult, + this.onClean, + this.onApplySelectedRemovals, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final paramCount = cleanerResult?.removedParams.length ?? 0; + final hasTappableDetails = paramCount > 0; + + return InkWell( + onTap: hasTappableDetails + ? () { + unawaited( + showDialog( + context: context, + builder: (context) => TrackingDetailsDialog( + currentUrl: url, + result: cleanerResult!, + allowReferralMarketing: allowReferralMarketing, + onApplySelectedRemovals: onApplySelectedRemovals, + ), + ), + ); + } + : null, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + url, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: urlWasCleaned + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + if (hasTracking) + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: 14, + color: colorScheme.error, + ), + const SizedBox(width: 4), + Text( + paramCount == 1 + ? '1 tracking parameter detected' + : '$paramCount tracking parameters detected', + style: TextStyle( + fontSize: 12, + color: colorScheme.error, + ), + ), + ], + ) + else if (urlWasCleaned) + Row( + children: [ + Icon( + MdiIcons.checkCircle, + size: 14, + color: colorScheme.primary, + ), + const SizedBox(width: 4), + Text( + 'Link is clean', + style: TextStyle( + fontSize: 12, + color: colorScheme.primary, + ), + ), + ], + ), + ], + ), + ), + if (hasTracking && onClean != null) + IconButton.filledTonal( + onPressed: onClean, + icon: const Icon(MdiIcons.linkVariantRemove), + tooltip: 'Remove tracking', + ), + ], + ), + ), + ); + } +} + +class _OpenInAppTile extends HookConsumerWidget { + final String selectedTabId; + + static final _service = GeckoAppLinksService(); + + const _OpenInAppTile({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 ListTile( + leading: const Icon(Icons.open_in_new), + title: const Text('Open in App'), + onTap: () async { + if (url == null) return; + final success = await _service.openAppLink(url); + if (success && context.mounted) Navigator.pop(context); + }, + ); + } +} + +class _SendToDeviceTile extends ConsumerWidget { + final String selectedTabId; + + const _SendToDeviceTile({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( + leading: const Icon(Icons.send_outlined), + title: const Text('Send To Device'), + 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'), + ), + ]; + } + + 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), + 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...'), + ), + ], + error: (_, _) => const [ + ListTile( + contentPadding: EdgeInsets.only(left: 72, right: 16), + title: Text('Failed to load devices'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart index 47e9701a..c938c7c3 100644 --- a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart +++ b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart @@ -128,4 +128,13 @@ extension HitResultX on HitResult { hasLink && tryGetLink()?.scheme == 'mailto'; } + + HitResult withCleanedLink(String cleanedUrl) { + return switch (this) { + UnknownHitResult() => UnknownHitResult(src: cleanedUrl), + ImageSrcHitResult(:final src) => + ImageSrcHitResult(src: src, uri: cleanedUrl), + _ => this, + }; + } } diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart index 3dac626a..5d229604 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart @@ -37,8 +37,12 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/ca import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_email.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_image.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_link.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; +import 'package:weblibre/utils/ui_helper.dart'; class ContextMenuDialog extends HookConsumerWidget { final HitResult hitResult; @@ -47,13 +51,46 @@ class ContextMenuDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final showContainerUi = ref.watch( - generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi), + final settings = ref.watch(generalSettingsWithDefaultsProvider); + final showContainerUi = settings.showContainerUi; + final catalogAsync = ref.watch(urlCleanerCatalogServiceProvider); + + final effectiveHitResult = useState(hitResult); + + final url = hitResult.tryGetLink()?.toString(); + final isCleanable = hitResult.isHttpLink(); + final cleaner = useUrlCleanerController( + sourceUrl: isCleanable + ? effectiveHitResult.value.tryGetLink()?.toString() + : null, + rules: catalogAsync.value, + cleanerEnabled: settings.urlCleanerEnabled, + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + autoApply: settings.urlCleanerAutoApply, + getCurrentUrl: () => effectiveHitResult.value.tryGetLink()?.toString(), + onApplyCleanedUrl: (cleanedUrl) { + effectiveHitResult.value = hitResult.withCleanedLink(cleanedUrl); + }, ); + void applyCleanUrl() { + if (cleaner.applyCleanUrl()) { + showInfoMessage(context, 'URL cleaned'); + } + } + + void applySelectedTrackingRemovals(String previewUrl) { + if (cleaner.applyPreviewUrl(previewUrl)) { + showInfoMessage(context, 'URL preview applied'); + } + } + + final effective = effectiveHitResult.value; + final showCleanerTile = isCleanable && cleaner.showTile; + return SimpleDialog( title: AutoSizeText( - hitResult.getTitle(), + effective.getTitle(), minFontSize: 18, maxFontSize: DefaultTextStyle.of(context).style.fontSize, maxLines: 10, @@ -61,28 +98,37 @@ class ContextMenuDialog extends HookConsumerWidget { softWrap: true, ), children: [ - if (OpenInNewTab.isSupported(hitResult)) - OpenInNewTab(hitResult: hitResult), - if (showContainerUi && OpenInContainer.isSupported(hitResult)) - OpenInContainer(hitResult: hitResult), - if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult), - if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult), - if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult), - if (ShareImage.isSupported(hitResult)) ShareImage(hitResult: hitResult), - if (OpenImageInNewTab.isSupported(hitResult)) - OpenImageInNewTab(hitResult: hitResult), - if (CopyImage.isSupported(hitResult)) CopyImage(hitResult: hitResult), - if (SaveImage.isSupported(hitResult)) SaveImage(hitResult: hitResult), - if (CopyImageLocation.isSupported(hitResult)) - CopyImageLocation(hitResult: hitResult), - if (ShareEmail.isSupported(hitResult)) ShareEmail(hitResult: hitResult), - if (CopyEmail.isSupported(hitResult)) CopyEmail(hitResult: hitResult), + if (showCleanerTile) + UrlCleanerTile( + result: cleaner.result!, + currentUrl: effective.tryGetLink()?.toString() ?? url ?? '', + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + onClean: applyCleanUrl, + onApplySelectedRemovals: applySelectedTrackingRemovals, + applied: cleaner.applied, + ), + if (OpenInNewTab.isSupported(effective)) + OpenInNewTab(hitResult: effective), + if (showContainerUi && OpenInContainer.isSupported(effective)) + OpenInContainer(hitResult: effective), + if (CopyLink.isSupported(effective)) CopyLink(hitResult: effective), + if (SaveFile.isSupported(effective)) SaveFile(hitResult: effective), + if (ShareLink.isSupported(effective)) ShareLink(hitResult: effective), + if (ShareImage.isSupported(effective)) ShareImage(hitResult: effective), + if (OpenImageInNewTab.isSupported(effective)) + OpenImageInNewTab(hitResult: effective), + if (CopyImage.isSupported(effective)) CopyImage(hitResult: effective), + if (SaveImage.isSupported(effective)) SaveImage(hitResult: effective), + if (CopyImageLocation.isSupported(effective)) + CopyImageLocation(hitResult: effective), + if (ShareEmail.isSupported(effective)) ShareEmail(hitResult: effective), + if (CopyEmail.isSupported(effective)) CopyEmail(hitResult: effective), HookBuilder( builder: (context) { final isSupported = useCachedFuture( // ignore: discarded_futures useFuture - () => LaunchExternal.isSupported(hitResult), - [hitResult], + () => LaunchExternal.isSupported(effective), + [effective], ); if (isSupported.data == false) { @@ -91,7 +137,7 @@ class ContextMenuDialog extends HookConsumerWidget { return Skeletonizer( enabled: isSupported.connectionState != ConnectionState.done, - child: LaunchExternal(hitResult: hitResult), + child: LaunchExternal(hitResult: effective), ); }, ), diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 2a8838ec..7a395f5b 100644 --- a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -29,13 +29,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; -import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_unshortener_service.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/controllers/open_shared_content_unshorten_controller.dart'; -import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/attribution_link.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.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/presentation/widgets/container_chips.dart'; @@ -96,73 +95,32 @@ class OpenSharedContent extends HookConsumerWidget { supportedShortenerHosts.value ?? const {}, ); - // URL cleaner state - final cleanerResult = useState(null); - final cleanerApplied = useState(false); - final unshortenState = ref.watch( openSharedContentUnshortenControllerProvider, ); - - void runCleaner({bool allowAutoApply = false}) { - final settings = ref.read(generalSettingsWithDefaultsProvider); - - if (!settings.urlCleanerEnabled) return; - - final rules = catalogAsync.value; - if (rules == null) return; - - final result = cleanUrl( - currentUrl, - rules, - allowReferral: settings.urlCleanerAllowReferralMarketing, - ); - cleanerResult.value = result; - - // Only reset applied state when new tracking params are found, - // preserving the "cleaned" indicator when the URL is already clean. - if (result.removedParams.isNotEmpty) { - cleanerApplied.value = false; - } - - if (allowAutoApply && result.changed) { - textController.text = result.cleanedUrl; - cleanerApplied.value = true; - } - } - - // Run URL cleaner on build when enabled and catalog is loaded. - // Uses debouncedUrl to avoid running regex matching on every keystroke. - useEffect( - () { - runCleaner(allowAutoApply: settings.urlCleanerAutoApply); - return null; + final cleaner = useUrlCleanerController( + // Avoid expensive matching work on every keystroke. + sourceUrl: debouncedUrl.value, + rules: catalogAsync.value, + cleanerEnabled: settings.urlCleanerEnabled, + allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, + autoApply: settings.urlCleanerAutoApply, + getCurrentUrl: () => textController.text, + onApplyCleanedUrl: (cleanedUrl) { + textController.text = cleanedUrl; }, - [ - debouncedUrl.value, - catalogAsync.value, - settings.urlCleanerEnabled, - settings.urlCleanerAllowReferralMarketing, - settings.urlCleanerAutoApply, - ], ); void applyCleanUrl() { - final result = cleanerResult.value; - if (result != null && result.changed) { - textController.text = result.cleanedUrl; - cleanerApplied.value = true; + if (cleaner.applyCleanUrl()) { showInfoMessage(context, 'URL cleaned'); } } void applySelectedTrackingRemovals(String previewUrl) { - if (previewUrl == textController.text) return; - - textController.text = previewUrl; - cleanerApplied.value = true; - - showInfoMessage(context, 'URL preview applied'); + if (cleaner.applyPreviewUrl(previewUrl)) { + showInfoMessage(context, 'URL preview applied'); + } } Future openTab(TabMode tabMode) async { @@ -234,7 +192,12 @@ class OpenSharedContent extends HookConsumerWidget { child: Form( key: formKey, child: Padding( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + MediaQuery.of(context).viewInsets.bottom + 16, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -263,9 +226,8 @@ class OpenSharedContent extends HookConsumerWidget { ), const SizedBox(height: 8), // URL Cleaner tile - if (settings.urlCleanerEnabled && - cleanerResult.value != null) ...[ - if (cleanerResult.value!.blocked) + if (settings.urlCleanerEnabled && cleaner.result != null) ...[ + if (cleaner.result!.blocked) ListTile( leading: Icon( MdiIcons.alertCircle, @@ -274,20 +236,18 @@ class OpenSharedContent extends HookConsumerWidget { title: const Text('URL blocked by ClearURLs'), dense: true, ) - else if (cleanerResult.value!.removedParams.isNotEmpty) - _UrlCleanerTile( - result: cleanerResult.value!, + else if (cleaner.result!.removedParams.isNotEmpty) + UrlCleanerTile( + result: cleaner.result!, currentUrl: currentUrl, allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, - onClean: cleanerResult.value!.changed - ? applyCleanUrl - : null, + onClean: cleaner.result!.changed ? applyCleanUrl : null, onApplySelectedRemovals: applySelectedTrackingRemovals, ) - else if (cleanerApplied.value) - _UrlCleanerTile( - result: cleanerResult.value!, + else if (cleaner.applied) + UrlCleanerTile( + result: cleaner.result!, currentUrl: currentUrl, allowReferralMarketing: settings.urlCleanerAllowReferralMarketing, @@ -525,80 +485,6 @@ Future _showUnshortenerInfoDialog(BuildContext context) { ); } -class _UrlCleanerTile extends StatelessWidget { - final UrlCleanerResult result; - final String currentUrl; - final bool allowReferralMarketing; - final VoidCallback? onClean; - final ValueChanged? onApplySelectedRemovals; - final bool applied; - - const _UrlCleanerTile({ - required this.result, - required this.currentUrl, - required this.allowReferralMarketing, - this.onClean, - this.onApplySelectedRemovals, - this.applied = false, - }); - - @override - Widget build(BuildContext context) { - final paramCount = result.removedParams.length; - final hasParams = paramCount > 0; - - final String subtitle; - if (!hasParams) { - subtitle = 'Tracking parameters removed'; - } else if (paramCount == 1) { - subtitle = '1 tracking parameter found'; - } else { - subtitle = '$paramCount tracking parameters found'; - } - - return ListTile( - leading: Icon( - applied ? MdiIcons.checkCircle : MdiIcons.broom, - color: applied - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, - ), - title: Text(applied ? 'URL cleaned' : 'Tracking detected'), - subtitle: Text(subtitle), - trailing: applied || !hasParams - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 24, child: VerticalDivider(width: 16)), - - IconButton( - icon: const Icon(MdiIcons.linkVariantRemove), - tooltip: 'Clean URL', - onPressed: onClean, - ), - ], - ), - dense: true, - onTap: hasParams - ? () { - unawaited( - showDialog( - context: context, - builder: (context) => TrackingDetailsDialog( - currentUrl: currentUrl, - result: result, - allowReferralMarketing: allowReferralMarketing, - onApplySelectedRemovals: onApplySelectedRemovals, - ), - ), - ); - } - : null, - ); - } -} - class _OpenActionTile extends StatelessWidget { final String title; final String? subtitle; diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart new file mode 100644 index 00000000..7d00ab3a --- /dev/null +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/hooks/url_cleaner_controller.dart @@ -0,0 +1,117 @@ +/* + * 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 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_rule.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_service.dart'; + +class UrlCleanerController { + final UrlCleanerResult? result; + final bool applied; + final bool Function() _applyCleanUrl; + final bool Function(String previewUrl) _applyPreviewUrl; + + const UrlCleanerController._({ + required this.result, + required this.applied, + required bool Function() applyCleanUrl, + required bool Function(String previewUrl) applyPreviewUrl, + }) : _applyCleanUrl = applyCleanUrl, + _applyPreviewUrl = applyPreviewUrl; + + bool get showTile => + result != null && (result!.removedParams.isNotEmpty || applied); + + bool applyCleanUrl() => _applyCleanUrl(); + + bool applyPreviewUrl(String previewUrl) => _applyPreviewUrl(previewUrl); +} + +UrlCleanerController useUrlCleanerController({ + required String? sourceUrl, + required List? rules, + required bool cleanerEnabled, + required bool allowReferralMarketing, + required bool autoApply, + required String? Function() getCurrentUrl, + required void Function(String cleanedUrl) onApplyCleanedUrl, +}) { + final cleanerResult = useState(null); + final cleanerApplied = useState(false); + + final getCurrentUrlRef = useRef(getCurrentUrl); + getCurrentUrlRef.value = getCurrentUrl; + + final onApplyCleanedUrlRef = useRef(onApplyCleanedUrl); + onApplyCleanedUrlRef.value = onApplyCleanedUrl; + + useEffect(() { + if (!cleanerEnabled || sourceUrl == null) { + cleanerResult.value = null; + return null; + } + + if (rules == null) return null; + + final result = cleanUrl( + sourceUrl, + rules, + allowReferral: allowReferralMarketing, + ); + cleanerResult.value = result; + + // Preserve the "already cleaned" state until a new removable parameter + // set is detected. + if (result.removedParams.isNotEmpty) { + cleanerApplied.value = false; + } + + if (autoApply && result.changed) { + onApplyCleanedUrlRef.value(result.cleanedUrl); + cleanerApplied.value = true; + } + + return null; + }, [sourceUrl, rules, cleanerEnabled, allowReferralMarketing, autoApply]); + + bool applyCleanUrl() { + final result = cleanerResult.value; + if (result == null || !result.changed) return false; + + onApplyCleanedUrlRef.value(result.cleanedUrl); + cleanerApplied.value = true; + return true; + } + + bool applyPreviewUrl(String previewUrl) { + if (previewUrl == getCurrentUrlRef.value()) return false; + + onApplyCleanedUrlRef.value(previewUrl); + cleanerApplied.value = true; + return true; + } + + return UrlCleanerController._( + result: cleanerResult.value, + applied: cleanerApplied.value, + applyCleanUrl: applyCleanUrl, + applyPreviewUrl: applyPreviewUrl, + ); +} diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart new file mode 100644 index 00000000..20236447 --- /dev/null +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/widgets/url_cleaner_tile.dart @@ -0,0 +1,100 @@ +/* + * 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_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; +import 'package:weblibre/features/geckoview/features/open_link_tools/presentation/dialogs/tracking_details_dialog.dart'; + +class UrlCleanerTile extends StatelessWidget { + final UrlCleanerResult result; + final String currentUrl; + final bool allowReferralMarketing; + final VoidCallback? onClean; + final ValueChanged? onApplySelectedRemovals; + final bool applied; + + const UrlCleanerTile({ + super.key, + required this.result, + required this.currentUrl, + required this.allowReferralMarketing, + this.onClean, + this.onApplySelectedRemovals, + this.applied = false, + }); + + @override + Widget build(BuildContext context) { + final paramCount = result.removedParams.length; + final hasParams = paramCount > 0; + + final String subtitle; + if (!hasParams) { + subtitle = 'Tracking parameters removed'; + } else if (paramCount == 1) { + subtitle = '1 tracking parameter found'; + } else { + subtitle = '$paramCount tracking parameters found'; + } + + return ListTile( + leading: Icon( + applied ? MdiIcons.checkCircle : MdiIcons.broom, + color: applied + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, + ), + title: Text(applied ? 'URL cleaned' : 'Tracking detected'), + subtitle: Text(subtitle), + trailing: applied || !hasParams + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24, child: VerticalDivider(width: 16)), + + IconButton( + icon: const Icon(MdiIcons.linkVariantRemove), + tooltip: 'Clean URL', + onPressed: onClean, + ), + ], + ), + dense: true, + onTap: hasParams + ? () { + unawaited( + showDialog( + context: context, + builder: (context) => TrackingDetailsDialog( + currentUrl: currentUrl, + result: result, + allowReferralMarketing: allowReferralMarketing, + onApplySelectedRemovals: onApplySelectedRemovals, + ), + ), + ); + } + : null, + ); + } +}