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 812479e0..9eb7dbe0 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -52,6 +52,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.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/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; @@ -367,22 +368,40 @@ class BrowserScreen extends HookConsumerWidget { // Get pixel ratio for converting logical pixels to physical pixels final pixelRatio = MediaQuery.of(context).devicePixelRatio; + // Watch find-in-page visibility for the selected tab + final selectedTabId = ref.watch(selectedTabProvider); + final findInPageVisible = + selectedTabId != null && + ref.watch( + findInPageControllerProvider( + selectedTabId, + ).select((state) => state.visible), + ); + + // Find in page widget height from the widget constant + final findInPageHeight = findInPageVisible + ? FindInPageWidget.findInPageHeight + : 0.0; + // Set up GeckoView dynamic toolbar height // This tells GeckoView the maximum toolbar space so it can adjust viewport - final bottomToolbarHeightPx = bottomToolbarVisible - ? (bottomAppBarTotalHeight * pixelRatio).round() - : 0; + final baseToolbarHeight = bottomToolbarVisible + ? bottomAppBarTotalHeight + : 0.0; + final totalToolbarHeight = findInPageVisible + ? baseToolbarHeight + findInPageHeight + : baseToolbarHeight; + final toolbarHeightPx = (totalToolbarHeight * pixelRatio).round(); + useEffect(() { final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull; if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) { - unawaited( - viewportService.setDynamicToolbarMaxHeight(bottomToolbarHeightPx), - ); + unawaited(viewportService.setDynamicToolbarMaxHeight(toolbarHeightPx)); } return null; - }, [bottomToolbarHeightPx]); + }, [toolbarHeightPx]); // Listen to keyboard visibility changes from native useOnStreamChange( @@ -391,7 +410,15 @@ class BrowserScreen extends HookConsumerWidget { if (event.isVisible && event.heightPx > 0) { // When keyboard is visible, notify GeckoView to adjust viewport // This uses the native API to handle keyboard without Flutter resize - unawaited(viewportService.setDynamicToolbarMaxHeight(event.heightPx)); + // If find-in-page is also visible, add its height to the keyboard height + final findInPageHeightPx = findInPageVisible + ? (FindInPageWidget.findInPageHeight * pixelRatio).round() + : 0; + unawaited( + viewportService.setDynamicToolbarMaxHeight( + event.heightPx + findInPageHeightPx, + ), + ); } }, ); @@ -552,6 +579,29 @@ class BrowserScreen extends HookConsumerWidget { }, ), ), + + // Layer 6: Find in Page widget (above toolbar or keyboard, whichever is higher) + AnimatedPositioned( + duration: _AnimatedToolbar._kAnimationDuration, + curve: Curves.easeInOutQuart, + left: 0, + right: 0, + bottom: math.max( + bottomToolbarVisible + ? bottomAppBarTotalHeight + : bottomSafeArea, + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Consumer( + builder: (context, ref, child) { + final tabId = ref.watch(selectedTabProvider); + if (tabId == null) { + return const SizedBox.shrink(); + } + return FindInPageWidget(tabId: tabId); + }, + ), + ), ], ), ), @@ -830,37 +880,7 @@ class _BrowserView extends StatelessWidget { bottom: false, left: !isFullscreen, child: Stack( - children: [ - BrowserView(pointerMoveEventSink: pointerMoveEventSink), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Consumer( - builder: (context, ref, child) { - final value = ref.watch( - selectedTabStateProvider.select( - (state) => EdgeInsets.only( - bottom: - (state?.isLoading == true && - state?.progress != null && - state!.progress < 100) - ? 4.0 - : 0.0, - ), - ), - ); - - final tabId = ref.watch(selectedTabProvider); - if (tabId == null) { - return const SizedBox.shrink(); - } - - return FindInPageWidget(tabId: tabId, padding: value); - }, - ), - ), - ], + children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)], ), ); } diff --git a/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart b/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart index f05f1713..2dc59fe6 100644 --- a/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart +++ b/app/lib/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart @@ -22,11 +22,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; +import 'package:weblibre/presentation/hooks/debouncer.dart'; class FindInPageWidget extends HookConsumerWidget { final String tabId; final EdgeInsetsGeometry padding; + /// The height of the find-in-page widget. + static const findInPageHeight = 56.0; + const FindInPageWidget({required this.tabId, this.padding = EdgeInsets.zero}); @override @@ -39,76 +43,113 @@ class FindInPageWidget extends HookConsumerWidget { final focusNode = useFocusNode(); final textController = useTextEditingController( text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, - keys: [searchResult?.lastSearchText, findInPageState.lastSearchText], ); + // Update controller text when search result changes from external sources + // (e.g., when navigating between matches) + useEffect(() { + final currentText = textController.text; + final newText = + searchResult?.lastSearchText ?? findInPageState.lastSearchText ?? ''; + + if (currentText != newText) { + textController.text = newText; + } + return null; + }, [searchResult?.lastSearchText, findInPageState.lastSearchText]); + + // Create debouncer with automatic disposal + final debouncer = useDebouncer(const Duration(milliseconds: 300)); + + // Search function that handles debouncing + Future onSearchTextChanged(String value) async { + if (value.isEmpty) { + debouncer.dispose(); + await ref + .read(findInPageControllerProvider(tabId).notifier) + .clearMatches(); + } else { + debouncer.eventOccured(() async { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .findAll(text: value); + }); + } + } + return Visibility( visible: findInPageState.visible || searchResult?.hasMatches == true, child: Padding( padding: padding, child: Material( - child: Row( - children: [ - const SizedBox(width: 8), - Expanded( - child: TextField( - focusNode: focusNode, - controller: textController, - autofocus: true, - autocorrect: false, - decoration: const InputDecoration.collapsed( - hintText: 'Find in page', + child: SizedBox( + height: findInPageHeight, + child: Row( + children: [ + const SizedBox(width: 8), + Expanded( + child: TextField( + focusNode: focusNode, + controller: textController, + autofocus: true, + autocorrect: false, + decoration: const InputDecoration.collapsed( + hintText: 'Find in page', + ), + keyboardType: TextInputType.text, + onChanged: onSearchTextChanged, + onSubmitted: (value) async { + // Cancel pending debounce and execute immediately + debouncer.dispose(); + if (value.isEmpty) { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .clearMatches(); + } else { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .findAll(text: value); + } + }, ), - keyboardType: TextInputType.text, - onSubmitted: (value) async { - if (value == '') { - await ref - .read(findInPageControllerProvider(tabId).notifier) - .clearMatches(); - } else { - await ref - .read(findInPageControllerProvider(tabId).notifier) - .findAll(text: value); - } + ), + Text( + (searchResult != null && searchResult.hasMatches) + ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' + : 'Not found', + ), + IconButton( + icon: const Icon(Icons.arrow_upward), + onPressed: () async { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .findNext( + forward: false, + fallbackText: textController.text, + ); }, ), - ), - Text( - (searchResult != null && searchResult.hasMatches) - ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' - : 'Not found', - ), - IconButton( - icon: const Icon(Icons.arrow_upward), - onPressed: () async { - await ref - .read(findInPageControllerProvider(tabId).notifier) - .findNext( - forward: false, - fallbackText: textController.text, - ); - }, - ), - IconButton( - icon: const Icon(Icons.arrow_downward), - onPressed: () async { - await ref - .read(findInPageControllerProvider(tabId).notifier) - .findNext(fallbackText: textController.text); - }, - ), - IconButton( - icon: const Icon(Icons.clear), - onPressed: () async { - await ref - .read(findInPageControllerProvider(tabId).notifier) - .hide(); + IconButton( + icon: const Icon(Icons.arrow_downward), + onPressed: () async { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .findNext(fallbackText: textController.text); + }, + ), + IconButton( + icon: const Icon(Icons.clear), + onPressed: () async { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .hide(); - textController.clear(); - focusNode.requestFocus(); - }, - ), - ], + textController.clear(); + focusNode.requestFocus(); + }, + ), + ], + ), ), ), ), diff --git a/app/lib/presentation/hooks/debouncer.dart b/app/lib/presentation/hooks/debouncer.dart new file mode 100644 index 00000000..77821f09 --- /dev/null +++ b/app/lib/presentation/hooks/debouncer.dart @@ -0,0 +1,41 @@ +/* + * 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_hooks/flutter_hooks.dart'; +import 'package:weblibre/utils/debouncer.dart'; + +/// A hook that creates a [Debouncer] with automatic disposal. +/// +/// The debouncer will be created once and automatically disposed when the +/// widget is unmounted. +/// +/// Example: +/// ```dart +/// final debouncer = useDebouncer(const Duration(milliseconds: 300)); +/// +/// // Use the debouncer +/// debouncer.eventOccured(() { +/// // Your debounced action +/// }); +/// ``` +Debouncer useDebouncer(Duration duration) { + final debouncer = useMemoized(() => Debouncer(duration)); + useEffect(() => debouncer.dispose, [debouncer]); + return debouncer; +}