From 37f11f1bd5429e59a4e5ee95129533b3ee6d2390 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Fri, 1 Aug 2025 20:51:53 +0200 Subject: [PATCH] refactor tab menu --- app/lib/core/routing/routes.browser.dart | 15 - app/lib/core/routing/routes.dart | 2 - app/lib/core/routing/routes.g.dart | 34 -- .../presentation/widgets/site_search.dart | 155 ++++--- .../browser/domain/entities/sheet.dart | 9 +- .../presentation/dialogs/web_page_dialog.dart | 350 ---------------- .../browser/presentation/screens/browser.dart | 47 +-- .../browser_modules/bottom_app_bar.dart | 26 +- .../presentation/widgets/edit_url_dialog.dart | 125 ------ .../presentation/widgets/sheets/view_tab.dart | 378 ++++++++++++++++++ .../search/presentation/screens/search.dart | 4 +- .../presentation/widgets/search_field.dart | 20 +- .../fixed_search_suggestions.dart | 100 +++++ ...ions.dart => full_search_suggestions.dart} | 88 ++-- app/lib/utils/debouncer.dart | 7 +- 15 files changed, 660 insertions(+), 700 deletions(-) delete mode 100644 app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart delete mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart create mode 100644 app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart rename app/lib/features/geckoview/features/search/presentation/widgets/search_modules/{search_suggestions.dart => full_search_suggestions.dart} (72%) diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index dcc194ab..6a14cb27 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -23,7 +23,6 @@ part of 'routes.dart'; name: BrowserRoute.name, path: '/', routes: [ - TypedGoRoute(name: 'WebPageRoute', path: 'page/:url'), TypedGoRoute( name: 'SearchRoute', path: 'search/:tabType/:searchText', @@ -66,20 +65,6 @@ class BrowserRoute extends GoRouteData with _$BrowserRoute { } } -class WebPageRoute extends GoRouteData with _$WebPageRoute { - final String url; - final WebPageInfo? $extra; - - const WebPageRoute({required this.url, required this.$extra}); - - @override - Page buildPage(BuildContext context, GoRouterState state) { - return DialogPage( - builder: (_) => WebPageDialog(url: Uri.parse(url), precachedInfo: $extra), - ); - } -} - enum TabType { regular, private, child } class SearchRoute extends GoRouteData with _$SearchRoute { diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 6d449b4c..b652c384 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -22,13 +22,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:weblibre/core/routing/widgets/dialog_page.dart'; -import 'package:weblibre/data/models/web_page_info.dart'; import 'package:weblibre/features/about/presentation/screens/about.dart'; import 'package:weblibre/features/bangs/presentation/screens/categories.dart'; import 'package:weblibre/features/bangs/presentation/screens/list.dart'; import 'package:weblibre/features/bangs/presentation/screens/search.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/screens/browser.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 4b687c09..d845a288 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -283,12 +283,6 @@ RouteBase get $browserRoute => GoRouteData.$route( factory: _$BrowserRoute._fromState, routes: [ - GoRouteData.$route( - path: 'page/:url', - name: 'WebPageRoute', - - factory: _$WebPageRoute._fromState, - ), GoRouteData.$route( path: 'search/:tabType/:searchText', name: 'SearchRoute', @@ -362,34 +356,6 @@ mixin _$BrowserRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } -mixin _$WebPageRoute on GoRouteData { - static WebPageRoute _fromState(GoRouterState state) => WebPageRoute( - url: state.pathParameters['url']!, - $extra: state.extra as WebPageInfo?, - ); - - WebPageRoute get _self => this as WebPageRoute; - - @override - String get location => - GoRouteData.$location('/page/${Uri.encodeComponent(_self.url)}'); - - @override - void go(BuildContext context) => context.go(location, extra: _self.$extra); - - @override - Future push(BuildContext context) => - context.push(location, extra: _self.$extra); - - @override - void pushReplacement(BuildContext context) => - context.pushReplacement(location, extra: _self.$extra); - - @override - void replace(BuildContext context) => - context.replace(location, extra: _self.$extra); -} - mixin _$SearchRoute on GoRouteData { static SearchRoute _fromState(GoRouterState state) => SearchRoute( tabType: _$TabTypeEnumMap._$fromName(state.pathParameters['tabType']!)!, diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index 8c28402c..69af54ec 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -17,41 +17,45 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/extensions/uri.dart'; import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/search.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; -import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; class SiteSearch extends HookConsumerWidget { final String domain; final List availableBangs; + final String? initialText; final bool searchInNewTab; const SiteSearch({ required this.domain, required this.availableBangs, super.key, - this.searchInNewTab = true, + this.initialText, + this.searchInNewTab = false, }); @override Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - - final searchTextController = useTextEditingController(); + final searchTextController = useTextEditingController(text: initialText); + final searchFocusNode = useFocusNode(); useListenableCallback(searchTextController, () { ref @@ -60,44 +64,55 @@ class SiteSearch extends HookConsumerWidget { }); final selectedBang = ref.watch(selectedBangDataProvider(domain: domain)); + final defaultSearchBang = ref.watch( + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); - final activeBang = selectedBang ?? availableBangs.firstOrNull; + Future submitSearch(String value) async { + if (value.isNotEmpty) { + final bang = selectedBang ?? defaultSearchBang; - Future submitSearch(String query) async { - if (activeBang != null && (formKey.currentState?.validate() == true)) { - final isPrivate = - ref.read(selectedTabStateProvider)?.isPrivate ?? false; + final newUrl = uri_parser.tryParseUrl(value, eagerParsing: true); - final searchUri = activeBang.getTemplateUrl(query); - - if (!isPrivate) { - await ref - .read(bangSearchProvider.notifier) - .triggerBangSearch(activeBang, query); - } - - if (searchInNewTab) { - await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: searchUri, private: isPrivate); - } else { + if (newUrl != null) { await ref .read(tabSessionProvider(tabId: null).notifier) - .loadUrl(url: searchUri); - } + .loadUrl(url: newUrl); - if (context.mounted) { - context.pop(); + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + } else if (bang != null) { + final isPrivate = + ref.read(selectedTabStateProvider)?.isPrivate ?? false; + + final searchUri = bang.getTemplateUrl(value); + + if (!isPrivate) { + await ref + .read(bangSearchProvider.notifier) + .triggerBangSearch(bang, value); + } + + if (searchInNewTab) { + await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: searchUri, private: isPrivate); + } else { + await ref + .read(tabSessionProvider(tabId: null).notifier) + .loadUrl(url: searchUri); + } + + ref.read(bottomSheetControllerProvider.notifier).dismiss(); } } } - return Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Visibility( + visible: availableBangs.isNotEmpty, + child: SizedBox( height: 48, width: double.maxFinite, child: SelectableChips( @@ -109,6 +124,18 @@ class SiteSearch extends HookConsumerWidget { availableItems: availableBangs, selectedItem: selectedBang, onSelected: (bang) { + if (selectedBang == null) { + final hasSupportedScheme = + uri_parser + .tryParseUrl(searchTextController.text) + .mapNotNull((uri) => uri.hasSupportedScheme) ?? + false; + + if (hasSupportedScheme) { + searchTextController.clear(); + } + } + ref .read(selectedBangTriggerProvider(domain: domain).notifier) .setTrigger(bang.trigger); @@ -125,37 +152,33 @@ class SiteSearch extends HookConsumerWidget { }, ), ), - SearchField( - textEditingController: searchTextController, - activeBang: activeBang, - onSubmitted: (_) async { - await submitSearch(searchTextController.text); - }, - showSuggestions: false, - ), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 150), - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return CustomScrollView( - shrinkWrap: true, - controller: controller, - slivers: [ - SearchTermSuggestions( - searchTextController: searchTextController, - activeBang: activeBang, - submitSearch: submitSearch, - showHistory: false, - showChips: false, - ), - ], - ); - }, - ), - ), - ], - ), + ), + SearchField( + textEditingController: searchTextController, + focusNode: searchFocusNode, + maxLines: null, + activeBang: selectedBang, + showSuggestions: true, + onTap: () { + if (!searchFocusNode.hasFocus) { + // Select all text when the field is tapped + searchTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: searchTextController.text.length, + ); + } + }, + unfocusOnTapOutside: false, + onSubmitted: (value) async { + await submitSearch(value); + }, + ), + FixedSearchTermSuggestions( + searchTextController: searchTextController, + activeBang: selectedBang, + submitSearch: submitSearch, + ), + ], ); } } diff --git a/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart b/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart index d5ece03a..546ab8f0 100644 --- a/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart +++ b/app/lib/features/geckoview/features/browser/domain/entities/sheet.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'package:fast_equatable/fast_equatable.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; sealed class Sheet with FastEquatable {} @@ -26,11 +27,11 @@ final class ViewTabsSheet extends Sheet { List get hashParameters => [null]; } -final class TabQaChatSheet extends Sheet { - final String chatId; +final class EditUrlSheet extends Sheet { + final TabState tabState; - TabQaChatSheet({required this.chatId}); + EditUrlSheet({required this.tabState}); @override - List get hashParameters => [chatId]; + List get hashParameters => [tabState]; } diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart deleted file mode 100644 index 88373a37..00000000 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/web_page_dialog.dart +++ /dev/null @@ -1,350 +0,0 @@ -/* - * Copyright (c) 2024-2025 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:ui' as ui; - -import 'package:fading_scroll/fading_scroll.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/data/models/web_page_info.dart'; -import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; -import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart'; -import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; -import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; -import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; -import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/address_with_suggestions_field.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; -import 'package:weblibre/features/user/domain/providers.dart'; -import 'package:weblibre/presentation/widgets/failure_widget.dart'; -import 'package:weblibre/presentation/widgets/share_tile.dart'; -import 'package:weblibre/presentation/widgets/website_feed_tile.dart'; -import 'package:weblibre/presentation/widgets/website_title_tile.dart'; -import 'package:weblibre/utils/ui_helper.dart' as ui_helper; - -class WebPageDialog extends HookConsumerWidget { - final Uri url; - final WebPageInfo? precachedInfo; - - const WebPageDialog({required this.url, this.precachedInfo, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch(incognitoModeEnabledProvider); - - final availableBangsAsync = ref.watch( - bangListProvider(domain: url.host, orderMostFrequentFirst: true), - ); - - final availableBangCount = availableBangsAsync.valueOrNull?.length; - - return MediaQuery.removeViewInsets( - context: context, - removeBottom: true, - child: Dialog( - insetPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 64.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), - child: WebsiteTitleTile(url, precachedInfo: precachedInfo), - ), - CertificateTile(), - const Divider(), - SizedBox( - //We need this to stretch the dialog, then padding from dialog is applied - width: double.maxFinite, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: AddressWithSuggestionsField( - url: url, - incognitoEnabled: incognitoEnabled, - ), - ), - ), - const SizedBox(height: 8), - Flexible( - child: FadingScroll( - fadingSize: 25, - builder: (context, controller) { - return SingleChildScrollView( - controller: controller, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: availableBangsAsync.when( - skipLoadingOnReload: true, - data: (availableBangs) { - if (availableBangs.isEmpty) { - return const SizedBox.shrink(); - } - - return SiteSearch( - domain: url.host, - availableBangs: availableBangs, - searchInNewTab: false, - ); - }, - error: (error, stackTrace) => FailureWidget( - title: 'Could not load bangs', - exception: error, - ), - loading: () => SiteSearch( - domain: url.host, - availableBangs: const [ - // BangData( - // websiteName: 'websiteName', - // domain: 'domain', - // trigger: 'trigger', - // urlTemplate: 'urlTemplate', - // ), - ], - ), - ), - ), - if (availableBangsAsync.isLoading || - availableBangCount == null || - availableBangCount > 0) - const Divider(), - ListTile( - leading: const Icon(MdiIcons.contentCopy), - title: const Text('Copy address'), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: url.toString()), - ); - if (context.mounted) { - context.pop(); - } - }, - ), - ListTile( - onTap: () async { - await ui_helper.launchUrlFeedback(context, url); - }, - leading: const Icon(Icons.open_in_browser), - title: const Text('Launch External'), - ), - ListTile( - leading: const Icon(MdiIcons.tabPlus), - title: const Text('Clone tab'), - onTap: () async { - final tabId = await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: url); - - if (context.mounted) { - //save reference before pop `ref` gets disposed - final repo = ref.read( - tabRepositoryProvider.notifier, - ); - - ui_helper.showTabSwitchMessage( - context, - onSwitch: () async { - await repo.selectTab(tabId); - }, - ); - - context.pop(); - } - }, - ), - ListTile( - leading: const Icon(MdiIcons.tabUnselected), - title: const Text('Clone as private tab'), - onTap: () async { - final tabId = await ref - .read(tabRepositoryProvider.notifier) - .addTab(url: url, private: true); - - if (context.mounted) { - //save reference before pop `ref` gets disposed - final repo = ref.read( - tabRepositoryProvider.notifier, - ); - - ui_helper.showTabSwitchMessage( - context, - onSwitch: () async { - await repo.selectTab(tabId); - }, - ); - - context.pop(); - } - }, - ), - ListTile( - leading: const Icon( - MdiIcons.folderArrowUpDownOutline, - ), - title: const Text('Assign container'), - onTap: () async { - final selectedTabId = ref.read(selectedTabProvider); - if (selectedTabId != null) { - final targetContainerId = - await ContainerSelectionRoute().push( - context, - ); - - if (targetContainerId != null) { - final containerData = await ref - .read(containerRepositoryProvider.notifier) - .getContainerData(targetContainerId); - - if (containerData != null) { - await ref - .read(tabDataRepositoryProvider.notifier) - .assignContainer( - selectedTabId, - containerData, - ); - } - } - - if (context.mounted) { - context.pop(); - } - } - }, - ), - Consumer( - child: ListTile( - leading: const Icon(MdiIcons.folderCancelOutline), - title: const Text('Unassign container'), - onTap: () async { - final selectedTabId = ref.read( - selectedTabProvider, - ); - - if (selectedTabId != null) { - await ref - .read(tabDataRepositoryProvider.notifier) - .unassignContainer(selectedTabId); - } - - if (context.mounted) { - context.pop(); - } - }, - ), - builder: (context, ref, child) { - final containerId = ref.watch( - selectedTabContainerIdProvider.select( - (value) => value.valueOrNull, - ), - ); - - return Visibility( - visible: containerId != null, - child: child!, - ); - }, - ), - ShareTile( - onTap: () async { - await SharePlus.instance.share( - ShareParams(uri: url), - ); - - if (context.mounted) { - context.pop(); - } - }, - onTapQr: () async { - await showQrCode(context, url.toString()); - }, - ), - ListTile( - leading: const Icon(Icons.mobile_screen_share), - title: const Text('Share screenshot'), - onTap: () async { - final screenshot = await ref - .read(selectedTabSessionNotifierProvider) - .requestScreenshot(); - - if (screenshot != null) { - ui.decodeImageFromList(screenshot, ( - result, - ) async { - 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: precachedInfo?.title, - ), - ); - } - }); - } - - if (context.mounted) { - context.pop(); - } - }, - ), - WebsiteFeedTile(url, precachedInfo: precachedInfo), - ], - ), - ); - }, - ), - ), - Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.only(right: 16.0, bottom: 16.0), - child: TextButton( - onPressed: () { - context.pop(); - }, - child: const Text('Close'), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 6170e9f3..9d7ed746 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -30,6 +30,7 @@ import 'package:weblibre/data/models/drag_data.dart'; import 'package:weblibre/extensions/media_query.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/controllers/overlay.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; @@ -40,7 +41,7 @@ import 'package:weblibre/features/geckoview/features/browser/domain/entities/she import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; @@ -262,10 +263,10 @@ class BrowserScreen extends HookConsumerWidget { key: ValueKey(displayedSheet), maxChildSize: MediaQuery.of(context).relativeSafeArea(), ), - final TabQaChatSheet parameter => _QaSheet( + final EditUrlSheet parameter => _ViewUrlSheet( key: ValueKey(displayedSheet), + initialTabState: parameter.tabState, maxChildSize: MediaQuery.of(context).relativeSafeArea(), - chatId: parameter.chatId, ), }, ) @@ -353,11 +354,15 @@ class _BrowserView extends StatelessWidget { } } -class _QaSheet extends HookConsumerWidget { - final String chatId; +class _ViewUrlSheet extends HookConsumerWidget { final double maxChildSize; + final TabState initialTabState; - const _QaSheet({super.key, required this.chatId, this.maxChildSize = 1.0}); + const _ViewUrlSheet({ + super.key, + required this.initialTabState, + this.maxChildSize = 1.0, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -366,6 +371,7 @@ class _QaSheet extends HookConsumerWidget { return DraggableScrollableSheet( controller: draggableScrollableController, expand: false, + initialChildSize: 172.0 / MediaQuery.of(context).size.height, minChildSize: 0.1, maxChildSize: maxChildSize, builder: (context, scrollController) { @@ -374,28 +380,13 @@ class _QaSheet extends HookConsumerWidget { topLeft: Radius.circular(28), topRight: Radius.circular(28), ), - child: Column( - children: [ - DraggableScrollableHeader( - controller: draggableScrollableController, - child: Material( - child: SizedBox( - width: double.infinity, - child: Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.symmetric(vertical: 16.0), - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - ), - ), - ), - ], + child: ViewTabSheetWidget( + initialTabState: initialTabState, + sheetScrollController: scrollController, + draggableScrollableController: draggableScrollableController, + onClose: () { + ref.read(bottomSheetControllerProvider.notifier).dismiss(); + }, ), ); }, 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 7a27d3e9..95bacca3 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 @@ -35,7 +35,6 @@ import 'package:weblibre/features/geckoview/domain/providers/web_extensions_stat import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart'; @@ -77,30 +76,13 @@ class BrowserBottomAppBar extends HookConsumerWidget { height: AppBar().preferredSize.height, padding: EdgeInsets.zero, child: GestureDetector( - onTap: () async { + onTap: () { final tabState = ref.read(selectedTabStateProvider); if (tabState != null) { - await WebPageRoute( - url: tabState.url.toString(), - $extra: tabState, - ).push(context); - } - }, - onLongPress: () async { - final tabState = ref.read(selectedTabStateProvider); - - if (tabState != null) { - final newUrl = await showDialog( - context: context, - builder: (context) => EditUrlDialog(initialUrl: tabState.url), - ); - - if (newUrl != null) { - await ref - .read(tabSessionProvider(tabId: null).notifier) - .loadUrl(url: newUrl); - } + ref + .read(bottomSheetControllerProvider.notifier) + .show(EditUrlSheet(tabState: tabState)); } }, onHorizontalDragStart: (details) { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart deleted file mode 100644 index d10eeadc..00000000 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/edit_url_dialog.dart +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2024-2025 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/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nullability/nullability.dart'; -import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; -import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; -import 'package:weblibre/presentation/hooks/listenable_callback.dart'; -import 'package:weblibre/presentation/widgets/auto_suggest_text_field.dart'; -import 'package:weblibre/utils/form_validators.dart'; -import 'package:weblibre/utils/uri_parser.dart' as uri_parser; - -class EditUrlDialog extends HookConsumerWidget { - final Uri initialUrl; - - const EditUrlDialog({required this.initialUrl}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - final addressTextController = useTextEditingController( - text: initialUrl.toString(), - ); - - return AlertDialog( - title: const Text('Edit Address'), - content: HookBuilder( - builder: (context) { - final addressTextFocusNode = useFocusNode(); - - final suggestion = useState(null); - final lastText = useRef(addressTextController.text); - - useListenableCallback(addressTextController, () async { - if (addressTextController.text.isNotEmpty) { - if (suggestion.value.isNotEmpty && - lastText.value.length > 1 && - addressTextController.text == - lastText.value.substring(0, lastText.value.length - 1)) { - suggestion.value = null; - addressTextController.text = lastText.value; - } else if (addressTextController.text != lastText.value) { - final result = await ref - .read(engineSuggestionsProvider.notifier) - .getAutocompleteSuggestion(addressTextController.text); - - suggestion.value = result; - } - } - - lastText.value = addressTextController.text; - }); - - return Form( - key: formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AutoSuggestTextField( - controller: addressTextController, - focusNode: addressTextFocusNode, - suggestion: suggestion.value, - // enableIMEPersonalizedLearning: !incognitoEnabled, - maxLines: null, - keyboardType: TextInputType.url, - decoration: const InputDecoration(hintText: 'Enter URL'), - onTap: () { - if (!addressTextFocusNode.hasFocus) { - // Select all text when the field is tapped - addressTextController.selection = TextSelection( - baseOffset: 0, - extentOffset: addressTextController.text.length, - ); - } - }, - validator: (value) { - return validateUrl(value, requireAuthority: false); - }, - ), - ClipboardFillLink(controller: addressTextController), - ], - ), - ); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - Navigator.of(context).pop( - uri_parser.tryParseUrl( - addressTextController.text, - eagerParsing: true, - ), - ); - } - }, - child: const Text('Edit'), - ), - ], - ); - } -} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart new file mode 100644 index 00000000..2b80bb64 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2024-2025 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:ui' as ui; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; +import 'package:weblibre/features/bangs/presentation/widgets/site_search.dart'; +import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/presentation/widgets/share_tile.dart'; +import 'package:weblibre/presentation/widgets/website_feed_tile.dart'; +import 'package:weblibre/presentation/widgets/website_title_tile.dart'; +import 'package:weblibre/utils/debouncer.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class ClampingScrollPhysicsWithoutImplicit extends ClampingScrollPhysics { + const ClampingScrollPhysicsWithoutImplicit({super.parent}); + + @override + ClampingScrollPhysicsWithoutImplicit applyTo(ScrollPhysics? ancestor) { + return ClampingScrollPhysicsWithoutImplicit(parent: buildParent(ancestor)); + } + + @override + bool get allowImplicitScrolling => false; +} + +class ViewTabSheetWidget extends HookConsumerWidget { + final TabState initialTabState; + final ScrollController sheetScrollController; + final DraggableScrollableController draggableScrollableController; + final VoidCallback onClose; + + const ViewTabSheetWidget({ + required this.initialTabState, + required this.sheetScrollController, + required this.draggableScrollableController, + required this.onClose, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final headerKey = useMemoized(() => GlobalKey()); + final textFieldKey = useMemoized(() => GlobalKey()); + + final availableBangs = ref.watch( + bangListProvider( + domain: initialTabState.url.host, + orderMostFrequentFirst: true, + ).select((value) => value.valueOrNull ?? const []), + ); + + final initialScrollDebouncer = useMemoized( + () => Debouncer(const Duration(milliseconds: 150)), + ); + useEffect(() { + if (!initialScrollDebouncer.hasRan) { + initialScrollDebouncer.eventOccured(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final header = headerKey.currentContext?.findRenderObject(); + final text = textFieldKey.currentContext?.findRenderObject(); + + if (header case final RenderBox headerBox) { + if (text case final RenderBox textBox) { + final totalHeight = + headerBox.size.height + + textBox.size.height + + kToolbarHeight; + + final relative = + totalHeight / MediaQuery.of(context).size.height; + + draggableScrollableController.jumpTo(relative); + } + } + }); + }); + } + + return null; + }); + + // final changes = useRef(0.0); + // useEffect(() { + // final emitter = KeyboardHeightEmitter(); + + // emitter.onKeyboardHeightChanged((height) { + // final diff = + // height / MediaQuery.of(context).size.height - changes.value; + + // draggableScrollableController.jumpTo( + // draggableScrollableController.size + diff, + // ); + + // changes.value += diff; + // }); + + // return () => emitter.dispose(); + // }, []); + + final changes = useRef(0.0); + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final diff = + ((MediaQuery.of(context).viewInsets.bottom / 2) / + MediaQuery.of(context).size.height) - + changes.value; + + draggableScrollableController.jumpTo( + draggableScrollableController.size + diff, + ); + + changes.value += diff; + }); + + return null; + }, [MediaQuery.of(context).viewInsets.bottom]); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + DraggableScrollableHeader( + key: headerKey, + controller: draggableScrollableController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), + child: GestureDetector( + onTap: () { + draggableScrollableController.jumpTo(1.0); + }, + child: WebsiteTitleTile(initialTabState), + ), + ), + CertificateTile(), + const Divider(), + ], + ), + ), + Flexible( + child: FadingScroll( + fadingSize: 25, + controller: sheetScrollController, + builder: (context, controller) { + return ListView( + controller: controller, + physics: const ClampingScrollPhysicsWithoutImplicit(), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SiteSearch( + key: textFieldKey, + domain: initialTabState.url.host, + availableBangs: availableBangs, + initialText: initialTabState.url.toString(), + ), + ), + const Divider(), + ListTile( + leading: const Icon(MdiIcons.contentCopy), + title: const Text('Copy address'), + onTap: () async { + await Clipboard.setData( + ClipboardData(text: initialTabState.url.toString()), + ); + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + ), + ListTile( + onTap: () async { + await ui_helper.launchUrlFeedback( + context, + initialTabState.url, + ); + }, + leading: const Icon(Icons.open_in_browser), + title: const Text('Launch External'), + ), + ListTile( + leading: const Icon(MdiIcons.tabPlus), + title: const Text('Clone tab'), + onTap: () async { + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: initialTabState.url); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read(tabRepositoryProvider.notifier); + + ui_helper.showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + } + }, + ), + ListTile( + leading: const Icon(MdiIcons.tabUnselected), + title: const Text('Clone as private tab'), + onTap: () async { + final tabId = await ref + .read(tabRepositoryProvider.notifier) + .addTab(url: initialTabState.url, private: true); + + if (context.mounted) { + //save reference before pop `ref` gets disposed + final repo = ref.read(tabRepositoryProvider.notifier); + + ui_helper.showTabSwitchMessage( + context, + onSwitch: () async { + await repo.selectTab(tabId); + }, + ); + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + } + }, + ), + ListTile( + leading: const Icon(MdiIcons.folderArrowUpDownOutline), + title: const Text('Assign container'), + onTap: () async { + final targetContainerId = await ContainerSelectionRoute() + .push(context); + + if (targetContainerId != null) { + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(targetContainerId); + + if (containerData != null) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer( + initialTabState.id, + containerData, + ); + } + } + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + ), + Consumer( + child: ListTile( + leading: const Icon(MdiIcons.folderCancelOutline), + title: const Text('Unassign container'), + onTap: () async { + await ref + .read(tabDataRepositoryProvider.notifier) + .unassignContainer(initialTabState.id); + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + ), + builder: (context, ref, child) { + final containerId = ref.watch( + watchContainerTabIdProvider( + initialTabState.id, + ).select((value) => value.valueOrNull), + ); + + return Visibility( + visible: containerId != null, + child: child!, + ); + }, + ), + ShareTile( + onTap: () async { + await SharePlus.instance.share( + ShareParams(uri: initialTabState.url), + ); + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + onTapQr: () async { + await showQrCode(context, initialTabState.url.toString()); + }, + ), + ListTile( + leading: const Icon(Icons.mobile_screen_share), + title: const Text('Share screenshot'), + onTap: () async { + final screenshot = await ref + .read(selectedTabSessionNotifierProvider) + .requestScreenshot(); + + if (screenshot != null) { + ui.decodeImageFromList(screenshot, (result) async { + 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: initialTabState.title, + ), + ); + } + }); + } + + ref + .read(bottomSheetControllerProvider.notifier) + .dismiss(); + }, + ), + WebsiteFeedTile(initialTabState), + ], + ); + }, + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 67e818ed..3e0970e0 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -34,8 +34,8 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; -import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; @@ -234,7 +234,7 @@ class SearchScreen extends HookConsumerWidget { child: ClipboardFillLink(controller: searchTextController), ), const SliverToBoxAdapter(child: Divider()), - SearchTermSuggestions( + FullSearchTermSuggestions( searchTextController: searchTextController, activeBang: activeBang, submitSearch: submitSearch, diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart index 631214ad..b78c851b 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -36,6 +36,9 @@ class SearchField extends HookConsumerWidget { final bool autofocus; final void Function(String)? onSubmitted; final bool showSuggestions; + final int? maxLines; + final VoidCallback? onTap; + final bool unfocusOnTapOutside; final BangData? activeBang; final bool showBangIcon; @@ -48,6 +51,9 @@ class SearchField extends HookConsumerWidget { required this.showSuggestions, this.label, this.focusNode, + this.maxLines = 1, + this.onTap, + this.unfocusOnTapOutside = true, this.showBangIcon = true, this.autofocus = false, }); @@ -93,6 +99,11 @@ class SearchField extends HookConsumerWidget { suggestion: suggestion.value, enableIMEPersonalizedLearning: !incognitoEnabled, focusNode: safeFocusNode, + maxLines: maxLines, + //Submit isntead of newline + textInputAction: (maxLines == null || maxLines! > 1) + ? TextInputAction.done + : null, autofocus: autofocus, decoration: InputDecoration( border: InputBorder.none, @@ -120,10 +131,13 @@ class SearchField extends HookConsumerWidget { }, ), ), - onTapOutside: (event) { - safeFocusNode.unfocus(); - }, + onTapOutside: unfocusOnTapOutside + ? (event) { + safeFocusNode.unfocus(); + } + : null, onSubmitted: onSubmitted, + onTap: onTap, ); } } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart new file mode 100644 index 00000000..67d2a9af --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024-2025 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:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/bangs/data/models/bang_data.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/utils/uri_parser.dart' as uri_parser; + +class FixedSearchTermSuggestions extends HookConsumerWidget { + final TextEditingController searchTextController; + final Future Function(String query) submitSearch; + final BangData? activeBang; + final int count; + + const FixedSearchTermSuggestions({ + required this.searchTextController, + required this.submitSearch, + required this.activeBang, + this.count = 3, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isSuggestableText = useListenableSelector(searchTextController, () { + if (searchTextController.text.isNotEmpty) { + final hasSupportedScheme = + uri_parser + .tryParseUrl(searchTextController.text) + .mapNotNull((uri) => uri.hasSupportedScheme) ?? + false; + + return !hasSupportedScheme; + } + + return false; + }); + + final searchSuggestions = ref.watch(searchSuggestionsProvider()); + + useListenableCallback(searchTextController, () { + ref + .read(searchSuggestionsProvider().notifier) + .addQuery(searchTextController.text); + }); + + final prioritizedSuggestions = isSuggestableText + ? [ + searchTextController.text, + if (searchSuggestions.valueOrNull != null) + ...searchSuggestions.valueOrNull! + .whereNot( + (suggestion) => suggestion == searchTextController.text, + ) + .take(count), + ] + : []; + + return Column( + mainAxisSize: MainAxisSize.min, + children: prioritizedSuggestions + .map( + (suggestion) => ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.search), + title: Text(suggestion), + onLongPress: () { + searchTextController.text = suggestion; + }, + onTap: () async { + await submitSearch(suggestion); + }, + ), + ) + .toList(), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart similarity index 72% rename from app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart rename to app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart index 6afefcc7..cfa6e607 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart @@ -32,20 +32,15 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/sea import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chips.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart'; -class SearchTermSuggestions extends HookConsumerWidget { +class FullSearchTermSuggestions extends HookConsumerWidget { final TextEditingController searchTextController; final Future Function(String query) submitSearch; final BangData? activeBang; - final bool showHistory; - final bool showChips; - - const SearchTermSuggestions({ + const FullSearchTermSuggestions({ required this.searchTextController, required this.submitSearch, required this.activeBang, - this.showHistory = true, - this.showChips = true, super.key, }); @@ -68,9 +63,7 @@ class SearchTermSuggestions extends HookConsumerWidget { final Widget listSliver; - if (showHistory && - !searchTextIsNotEmpty && - (searchHistory.value.isNotEmpty)) { + if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) { final entries = searchHistory.value!; listSliver = SliverList.builder( @@ -128,51 +121,50 @@ class SearchTermSuggestions extends HookConsumerWidget { return MultiSliver( children: [ - if (showChips) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Search Provider', - style: Theme.of(context).textTheme.labelSmall, - ), - BangChips( - activeBang: activeBang, - onSelected: (bang) { - searchTextController.clear(); + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Provider', + style: Theme.of(context).textTheme.labelSmall, + ), + BangChips( + activeBang: activeBang, + onSelected: (bang) { + searchTextController.clear(); + ref + .read(selectedBangTriggerProvider().notifier) + .setTrigger(bang.trigger); + }, + onDeleted: (bang) async { + if (ref.read(selectedBangTriggerProvider()) == + bang.trigger) { ref .read(selectedBangTriggerProvider().notifier) - .setTrigger(bang.trigger); - }, - onDeleted: (bang) async { - if (ref.read(selectedBangTriggerProvider()) == - bang.trigger) { - ref - .read(selectedBangTriggerProvider().notifier) - .clearTrigger(); - } else { - final dialogResult = await BangChips.resetBangDialog( - context, - bang.trigger, - ); + .clearTrigger(); + } else { + final dialogResult = await BangChips.resetBangDialog( + context, + bang.trigger, + ); - if (dialogResult == true) { - await ref - .read(bangDataRepositoryProvider.notifier) - .resetFrequency(bang.trigger); - } + if (dialogResult == true) { + await ref + .read(bangDataRepositoryProvider.notifier) + .resetFrequency(bang.trigger); } - }, - searchTextController: searchTextController, - ), - ], - ), + } + }, + searchTextController: searchTextController, + ), + ], ), ), + ), SliverToBoxAdapter( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 150), diff --git a/app/lib/utils/debouncer.dart b/app/lib/utils/debouncer.dart index 326d02b0..eec970f6 100644 --- a/app/lib/utils/debouncer.dart +++ b/app/lib/utils/debouncer.dart @@ -23,14 +23,19 @@ class Debouncer { final Duration debounce; Timer? _timer; + bool _hasRan = false; Debouncer(this.debounce); bool get isDebouncing => _timer?.isActive ?? false; + bool get hasRan => _hasRan; void eventOccured(void Function() callback) { _timer?.cancel(); - _timer = Timer(debounce, callback); + _timer = Timer(debounce, () { + callback.call(); + _hasRan = true; + }); } void dispose() {