improve find in page widget

This commit is contained in:
Fabian Freund
2026-02-01 10:22:05 +01:00
parent b19b9b2232
commit 6d2f9ff837
3 changed files with 200 additions and 98 deletions
@@ -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_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_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/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/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
import 'package:weblibre/features/user/data/models/general_settings.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 // Get pixel ratio for converting logical pixels to physical pixels
final pixelRatio = MediaQuery.of(context).devicePixelRatio; 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 // Set up GeckoView dynamic toolbar height
// This tells GeckoView the maximum toolbar space so it can adjust viewport // This tells GeckoView the maximum toolbar space so it can adjust viewport
final bottomToolbarHeightPx = bottomToolbarVisible final baseToolbarHeight = bottomToolbarVisible
? (bottomAppBarTotalHeight * pixelRatio).round() ? bottomAppBarTotalHeight
: 0; : 0.0;
final totalToolbarHeight = findInPageVisible
? baseToolbarHeight + findInPageHeight
: baseToolbarHeight;
final toolbarHeightPx = (totalToolbarHeight * pixelRatio).round();
useEffect(() { useEffect(() {
final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull; final lastKeyboardEvent = viewportService.keyboardEvents.valueOrNull;
if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) { if (lastKeyboardEvent == null || !lastKeyboardEvent.isVisible) {
unawaited( unawaited(viewportService.setDynamicToolbarMaxHeight(toolbarHeightPx));
viewportService.setDynamicToolbarMaxHeight(bottomToolbarHeightPx),
);
} }
return null; return null;
}, [bottomToolbarHeightPx]); }, [toolbarHeightPx]);
// Listen to keyboard visibility changes from native // Listen to keyboard visibility changes from native
useOnStreamChange( useOnStreamChange(
@@ -391,7 +410,15 @@ class BrowserScreen extends HookConsumerWidget {
if (event.isVisible && event.heightPx > 0) { if (event.isVisible && event.heightPx > 0) {
// When keyboard is visible, notify GeckoView to adjust viewport // When keyboard is visible, notify GeckoView to adjust viewport
// This uses the native API to handle keyboard without Flutter resize // 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, bottom: false,
left: !isFullscreen, left: !isFullscreen,
child: Stack( child: Stack(
children: [ children: [BrowserView(pointerMoveEventSink: pointerMoveEventSink)],
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);
},
),
),
],
), ),
); );
} }
@@ -22,11 +22,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/presentation/hooks/debouncer.dart';
class FindInPageWidget extends HookConsumerWidget { class FindInPageWidget extends HookConsumerWidget {
final String tabId; final String tabId;
final EdgeInsetsGeometry padding; 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}); const FindInPageWidget({required this.tabId, this.padding = EdgeInsets.zero});
@override @override
@@ -39,76 +43,113 @@ class FindInPageWidget extends HookConsumerWidget {
final focusNode = useFocusNode(); final focusNode = useFocusNode();
final textController = useTextEditingController( final textController = useTextEditingController(
text: searchResult?.lastSearchText ?? findInPageState.lastSearchText, 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<void> 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( return Visibility(
visible: findInPageState.visible || searchResult?.hasMatches == true, visible: findInPageState.visible || searchResult?.hasMatches == true,
child: Padding( child: Padding(
padding: padding, padding: padding,
child: Material( child: Material(
child: Row( child: SizedBox(
children: [ height: findInPageHeight,
const SizedBox(width: 8), child: Row(
Expanded( children: [
child: TextField( const SizedBox(width: 8),
focusNode: focusNode, Expanded(
controller: textController, child: TextField(
autofocus: true, focusNode: focusNode,
autocorrect: false, controller: textController,
decoration: const InputDecoration.collapsed( autofocus: true,
hintText: 'Find in page', 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 { Text(
if (value == '') { (searchResult != null && searchResult.hasMatches)
await ref ? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}'
.read(findInPageControllerProvider(tabId).notifier) : 'Not found',
.clearMatches(); ),
} else { IconButton(
await ref icon: const Icon(Icons.arrow_upward),
.read(findInPageControllerProvider(tabId).notifier) onPressed: () async {
.findAll(text: value); await ref
} .read(findInPageControllerProvider(tabId).notifier)
.findNext(
forward: false,
fallbackText: textController.text,
);
}, },
), ),
), IconButton(
Text( icon: const Icon(Icons.arrow_downward),
(searchResult != null && searchResult.hasMatches) onPressed: () async {
? '${searchResult.activeMatchOrdinal + 1} of ${searchResult.numberOfMatches}' await ref
: 'Not found', .read(findInPageControllerProvider(tabId).notifier)
), .findNext(fallbackText: textController.text);
IconButton( },
icon: const Icon(Icons.arrow_upward), ),
onPressed: () async { IconButton(
await ref icon: const Icon(Icons.clear),
.read(findInPageControllerProvider(tabId).notifier) onPressed: () async {
.findNext( await ref
forward: false, .read(findInPageControllerProvider(tabId).notifier)
fallbackText: textController.text, .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(); textController.clear();
focusNode.requestFocus(); focusNode.requestFocus();
}, },
), ),
], ],
),
), ),
), ),
), ),
+41
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}