move improve ui; added url cleaner everywhere

This commit is contained in:
Fabian Freund
2026-03-03 15:16:58 +01:00
parent 8110019249
commit 7770a99bf6
9 changed files with 889 additions and 214 deletions
@@ -61,7 +61,7 @@ final class ToolbarVisibilityControllerProvider
} }
String _$toolbarVisibilityControllerHash() => String _$toolbarVisibilityControllerHash() =>
r'242449893afd6780d977544659e83d4641c8a0ad'; r'e947508c351d4cbbe8c63289171a6d0a34f1a61a';
final class ToolbarVisibilityControllerFamily extends $Family final class ToolbarVisibilityControllerFamily extends $Family
with with
@@ -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/dialogs/qr_code.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.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/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/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pwa_install_button.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/readerview/presentation/controllers/readerable.dart';
@@ -120,11 +123,6 @@ class _BrowserMenuSheet extends HookConsumerWidget {
vertical: 8, vertical: 8,
), ),
children: [ children: [
// Navigation row
if (selectedTabId != null)
_NavigationRow(selectedTabId: selectedTabId),
const SizedBox(height: 16),
// Quick toggles (Desktop Mode / Reader Mode) // Quick toggles (Desktop Mode / Reader Mode)
if (selectedTabId != null) ...[ if (selectedTabId != null) ...[
_QuickTogglesGrid(selectedTabId: selectedTabId), _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, { String title, {
IconData? icon, IconData? icon,
Color? iconColor, Color? iconColor,
Widget? trailing,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16), contentPadding: const EdgeInsets.only(left: 56, right: 16),
leading: icon != null ? Icon(icon, color: iconColor, size: 20) : null, leading: icon != null ? Icon(icon, color: iconColor, size: 20) : null,
title: Text(title, style: const TextStyle(fontSize: 14)), title: Text(title, style: const TextStyle(fontSize: 14)),
trailing: trailing,
dense: true, dense: true,
onTap: onTap, onTap: onTap,
); );
@@ -1013,20 +1025,74 @@ class _ShareExpansion extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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<Uri?>(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( return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent), data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile( child: ExpansionTile(
leading: const Icon(Icons.share), leading: const Icon(Icons.share),
title: const Text('Share'), title: const Text('Share'),
children: [ children: [
if (showCleanerTile)
UrlCleanerTile(
result: cleaner.result!,
currentUrl: effectiveUrl?.toString() ?? '',
allowReferralMarketing: settings.urlCleanerAllowReferralMarketing,
onClean: applyCleanUrl,
onApplySelectedRemovals: applySelectedTrackingRemovals,
applied: cleaner.applied,
),
// Copy Address // Copy Address
_buildSubTile( _buildSubTile(
'Copy Address', 'Copy Address',
icon: MdiIcons.contentCopy, icon: MdiIcons.contentCopy,
trailing: cleanedTrailing,
onTap: () async { onTap: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
await Clipboard.setData( await Clipboard.setData(
ClipboardData(text: tabState.url.toString()), ClipboardData(text: effectiveUrl.toString()),
); );
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
@@ -1044,7 +1110,7 @@ class _ShareExpansion extends HookConsumerWidget {
.read(selectedTabSessionProvider) .read(selectedTabSessionProvider)
.requestScreenshot(); .requestScreenshot();
final tabState = ref.read(tabStateProvider(selectedTabId))!; final ts = ref.read(tabStateProvider(selectedTabId))!;
if (screenshot != null) { if (screenshot != null) {
ui.decodeImageFromList(screenshot, (result) async { ui.decodeImageFromList(screenshot, (result) async {
@@ -1062,7 +1128,7 @@ class _ShareExpansion extends HookConsumerWidget {
await SharePlus.instance.share( await SharePlus.instance.share(
ShareParams( ShareParams(
files: [file], files: [file],
subject: tabState.titleOrAuthority, subject: ts.titleOrAuthority,
), ),
); );
} }
@@ -1080,9 +1146,9 @@ class _ShareExpansion extends HookConsumerWidget {
_buildSubTile( _buildSubTile(
'Share Link', 'Share Link',
icon: Icons.share, icon: Icons.share,
trailing: cleanedTrailing,
onTap: () async { onTap: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!; await SharePlus.instance.share(ShareParams(uri: effectiveUrl));
await SharePlus.instance.share(ShareParams(uri: tabState.url));
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
}, },
), ),
@@ -1094,11 +1160,11 @@ class _ShareExpansion extends HookConsumerWidget {
_buildSubTile( _buildSubTile(
'Show QR Code', 'Show QR Code',
icon: Icons.qr_code, icon: Icons.qr_code,
trailing: cleanedTrailing,
onTap: () async { onTap: () async {
final tabState = ref.read(tabStateProvider(selectedTabId))!;
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); 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); Navigator.pop(context);
final result = await showQuitBrowserDialog(context); final result = await showQuitBrowserDialog(context);
if (result == true && context.mounted) { if (result == true) {
await exitApp(ProviderScope.containerOf(context)); await exitApp(ref.container);
} }
}, },
), ),
@@ -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_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/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/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/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_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_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart';
@@ -289,9 +289,6 @@ class BrowserTabBar extends HookConsumerWidget {
displayedSheet is! ViewTabsSheet) displayedSheet is! ViewTabsSheet)
? ContainerColors.forAppBar(containerColor) ? ContainerColors.forAppBar(containerColor)
: null, : null,
leading: showMainToolbarNavigationButton
? NavigationMenuButton(selectedTabId: selectedTabId)
: null,
title: title:
(selectedTabId != null && displayedSheet is! ViewTabsSheet) (selectedTabId != null && displayedSheet is! ViewTabsSheet)
? const AppBarTitle() ? const AppBarTitle()
@@ -356,6 +353,8 @@ class BrowserTabBar extends HookConsumerWidget {
displayedSheet: displayedSheet, displayedSheet: displayedSheet,
showLongPressMenu: true, showLongPressMenu: true,
), ),
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
], ],
), ),
), ),
@@ -421,7 +420,6 @@ class ContextualToolbar extends HookConsumerWidget {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
NavigationMenuButton(selectedTabId: selectedTabId),
if (tabState?.historyState.canGoBack == true || if (tabState?.historyState.canGoBack == true ||
tabState?.isLoading == true) tabState?.isLoading == true)
NavigateBackButton( NavigateBackButton(
@@ -447,6 +445,7 @@ class ContextualToolbar extends HookConsumerWidget {
displayedSheet: displayedSheet, displayedSheet: displayedSheet,
showLongPressMenu: false, showLongPressMenu: false,
), ),
NavigationMenuButton(selectedTabId: selectedTabId),
], ],
); );
} }
@@ -642,38 +641,22 @@ class QuickTabSwitcher extends HookConsumerWidget {
} }
} }
class ShareMenuButton extends HookConsumerWidget { class ShareMenuButton extends StatelessWidget {
final String? selectedTabId; final String? selectedTabId;
const ShareMenuButton({super.key, required this.selectedTabId}); const ShareMenuButton({super.key, required this.selectedTabId});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
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( return IconButton(
onPressed: () { onPressed: () async {
if (controller.isOpen) { final tabId = selectedTabId;
controller.close(); if (tabId != null) {
} else { await showShareBottomSheet(context, selectedTabId: tabId);
controller.open();
} }
}, },
icon: const Icon(Icons.share), icon: const Icon(Icons.share),
); );
},
);
} }
} }
@@ -688,7 +671,7 @@ class NavigationMenuButton extends StatelessWidget {
onTap: () async { onTap: () async {
await showBrowserMenuSheet(context); await showBrowserMenuSheet(context);
}, },
child: const Icon(Icons.menu), child: const Icon(Icons.more_vert),
); );
} }
} }
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<void> 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<Uri?>(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<String>? 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'),
),
],
),
),
),
);
}
}
@@ -128,4 +128,13 @@ extension HitResultX on HitResult {
hasLink && hasLink &&
tryGetLink()?.scheme == 'mailto'; tryGetLink()?.scheme == 'mailto';
} }
HitResult withCleanedLink(String cleanedUrl) {
return switch (this) {
UnknownHitResult() => UnknownHitResult(src: cleanedUrl),
ImageSrcHitResult(:final src) =>
ImageSrcHitResult(src: src, uri: cleanedUrl),
_ => this,
};
}
} }
@@ -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_email.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/share_image.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/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/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/utils/ui_helper.dart';
class ContextMenuDialog extends HookConsumerWidget { class ContextMenuDialog extends HookConsumerWidget {
final HitResult hitResult; final HitResult hitResult;
@@ -47,13 +51,46 @@ class ContextMenuDialog extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final showContainerUi = ref.watch( final settings = ref.watch(generalSettingsWithDefaultsProvider);
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi), 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( return SimpleDialog(
title: AutoSizeText( title: AutoSizeText(
hitResult.getTitle(), effective.getTitle(),
minFontSize: 18, minFontSize: 18,
maxFontSize: DefaultTextStyle.of(context).style.fontSize, maxFontSize: DefaultTextStyle.of(context).style.fontSize,
maxLines: 10, maxLines: 10,
@@ -61,28 +98,37 @@ class ContextMenuDialog extends HookConsumerWidget {
softWrap: true, softWrap: true,
), ),
children: [ children: [
if (OpenInNewTab.isSupported(hitResult)) if (showCleanerTile)
OpenInNewTab(hitResult: hitResult), UrlCleanerTile(
if (showContainerUi && OpenInContainer.isSupported(hitResult)) result: cleaner.result!,
OpenInContainer(hitResult: hitResult), currentUrl: effective.tryGetLink()?.toString() ?? url ?? '',
if (CopyLink.isSupported(hitResult)) CopyLink(hitResult: hitResult), allowReferralMarketing: settings.urlCleanerAllowReferralMarketing,
if (SaveFile.isSupported(hitResult)) SaveFile(hitResult: hitResult), onClean: applyCleanUrl,
if (ShareLink.isSupported(hitResult)) ShareLink(hitResult: hitResult), onApplySelectedRemovals: applySelectedTrackingRemovals,
if (ShareImage.isSupported(hitResult)) ShareImage(hitResult: hitResult), applied: cleaner.applied,
if (OpenImageInNewTab.isSupported(hitResult)) ),
OpenImageInNewTab(hitResult: hitResult), if (OpenInNewTab.isSupported(effective))
if (CopyImage.isSupported(hitResult)) CopyImage(hitResult: hitResult), OpenInNewTab(hitResult: effective),
if (SaveImage.isSupported(hitResult)) SaveImage(hitResult: hitResult), if (showContainerUi && OpenInContainer.isSupported(effective))
if (CopyImageLocation.isSupported(hitResult)) OpenInContainer(hitResult: effective),
CopyImageLocation(hitResult: hitResult), if (CopyLink.isSupported(effective)) CopyLink(hitResult: effective),
if (ShareEmail.isSupported(hitResult)) ShareEmail(hitResult: hitResult), if (SaveFile.isSupported(effective)) SaveFile(hitResult: effective),
if (CopyEmail.isSupported(hitResult)) CopyEmail(hitResult: hitResult), 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( HookBuilder(
builder: (context) { builder: (context) {
final isSupported = useCachedFuture( final isSupported = useCachedFuture(
// ignore: discarded_futures useFuture // ignore: discarded_futures useFuture
() => LaunchExternal.isSupported(hitResult), () => LaunchExternal.isSupported(effective),
[hitResult], [effective],
); );
if (isSupported.data == false) { if (isSupported.data == false) {
@@ -91,7 +137,7 @@ class ContextMenuDialog extends HookConsumerWidget {
return Skeletonizer( return Skeletonizer(
enabled: isSupported.connectionState != ConnectionState.done, enabled: isSupported.connectionState != ConnectionState.done,
child: LaunchExternal(hitResult: hitResult), child: LaunchExternal(hitResult: effective),
); );
}, },
), ),
@@ -29,13 +29,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.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/entities/tab_container_selection.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.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_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/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/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/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/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
@@ -96,74 +95,33 @@ class OpenSharedContent extends HookConsumerWidget {
supportedShortenerHosts.value ?? const <String>{}, supportedShortenerHosts.value ?? const <String>{},
); );
// URL cleaner state
final cleanerResult = useState<UrlCleanerResult?>(null);
final cleanerApplied = useState(false);
final unshortenState = ref.watch( final unshortenState = ref.watch(
openSharedContentUnshortenControllerProvider, openSharedContentUnshortenControllerProvider,
); );
final cleaner = useUrlCleanerController(
void runCleaner({bool allowAutoApply = false}) { // Avoid expensive matching work on every keystroke.
final settings = ref.read(generalSettingsWithDefaultsProvider); sourceUrl: debouncedUrl.value,
rules: catalogAsync.value,
if (!settings.urlCleanerEnabled) return; cleanerEnabled: settings.urlCleanerEnabled,
allowReferralMarketing: settings.urlCleanerAllowReferralMarketing,
final rules = catalogAsync.value; autoApply: settings.urlCleanerAutoApply,
if (rules == null) return; getCurrentUrl: () => textController.text,
onApplyCleanedUrl: (cleanedUrl) {
final result = cleanUrl( textController.text = cleanedUrl;
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;
}, },
[
debouncedUrl.value,
catalogAsync.value,
settings.urlCleanerEnabled,
settings.urlCleanerAllowReferralMarketing,
settings.urlCleanerAutoApply,
],
); );
void applyCleanUrl() { void applyCleanUrl() {
final result = cleanerResult.value; if (cleaner.applyCleanUrl()) {
if (result != null && result.changed) {
textController.text = result.cleanedUrl;
cleanerApplied.value = true;
showInfoMessage(context, 'URL cleaned'); showInfoMessage(context, 'URL cleaned');
} }
} }
void applySelectedTrackingRemovals(String previewUrl) { void applySelectedTrackingRemovals(String previewUrl) {
if (previewUrl == textController.text) return; if (cleaner.applyPreviewUrl(previewUrl)) {
textController.text = previewUrl;
cleanerApplied.value = true;
showInfoMessage(context, 'URL preview applied'); showInfoMessage(context, 'URL preview applied');
} }
}
Future<void> openTab(TabMode tabMode) async { Future<void> openTab(TabMode tabMode) async {
if (formKey.currentState?.validate() == true) { if (formKey.currentState?.validate() == true) {
@@ -234,7 +192,12 @@ class OpenSharedContent extends HookConsumerWidget {
child: Form( child: Form(
key: formKey, key: formKey,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: EdgeInsets.fromLTRB(
16,
16,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -263,9 +226,8 @@ class OpenSharedContent extends HookConsumerWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// URL Cleaner tile // URL Cleaner tile
if (settings.urlCleanerEnabled && if (settings.urlCleanerEnabled && cleaner.result != null) ...[
cleanerResult.value != null) ...[ if (cleaner.result!.blocked)
if (cleanerResult.value!.blocked)
ListTile( ListTile(
leading: Icon( leading: Icon(
MdiIcons.alertCircle, MdiIcons.alertCircle,
@@ -274,20 +236,18 @@ class OpenSharedContent extends HookConsumerWidget {
title: const Text('URL blocked by ClearURLs'), title: const Text('URL blocked by ClearURLs'),
dense: true, dense: true,
) )
else if (cleanerResult.value!.removedParams.isNotEmpty) else if (cleaner.result!.removedParams.isNotEmpty)
_UrlCleanerTile( UrlCleanerTile(
result: cleanerResult.value!, result: cleaner.result!,
currentUrl: currentUrl, currentUrl: currentUrl,
allowReferralMarketing: allowReferralMarketing:
settings.urlCleanerAllowReferralMarketing, settings.urlCleanerAllowReferralMarketing,
onClean: cleanerResult.value!.changed onClean: cleaner.result!.changed ? applyCleanUrl : null,
? applyCleanUrl
: null,
onApplySelectedRemovals: applySelectedTrackingRemovals, onApplySelectedRemovals: applySelectedTrackingRemovals,
) )
else if (cleanerApplied.value) else if (cleaner.applied)
_UrlCleanerTile( UrlCleanerTile(
result: cleanerResult.value!, result: cleaner.result!,
currentUrl: currentUrl, currentUrl: currentUrl,
allowReferralMarketing: allowReferralMarketing:
settings.urlCleanerAllowReferralMarketing, settings.urlCleanerAllowReferralMarketing,
@@ -525,80 +485,6 @@ Future<void> _showUnshortenerInfoDialog(BuildContext context) {
); );
} }
class _UrlCleanerTile extends StatelessWidget {
final UrlCleanerResult result;
final String currentUrl;
final bool allowReferralMarketing;
final VoidCallback? onClean;
final ValueChanged<String>? 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 { class _OpenActionTile extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<UrlCleanerRule>? rules,
required bool cleanerEnabled,
required bool allowReferralMarketing,
required bool autoApply,
required String? Function() getCurrentUrl,
required void Function(String cleanedUrl) onApplyCleanedUrl,
}) {
final cleanerResult = useState<UrlCleanerResult?>(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,
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<String>? 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,
);
}
}