From ef4913a23e4a86bb2853a00cbf0a87aaee70a9b5 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 4 Jan 2026 10:57:38 +0100 Subject: [PATCH] add addres bar auto focus --- .../presentation/widgets/site_search.dart | 60 ++++++++-- .../hooks/listenable_callback.dart | 32 ------ .../hooks/on_listenable_change_selector.dart | 104 ++++++++++++++++++ 3 files changed, 153 insertions(+), 43 deletions(-) delete mode 100644 app/lib/presentation/hooks/listenable_callback.dart create mode 100644 app/lib/presentation/hooks/on_listenable_change_selector.dart diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index 3d5cc3e3..6511ac34 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -17,6 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -35,7 +37,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/providers/sea import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; -import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/hooks/on_listenable_change_selector.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; @@ -59,7 +61,52 @@ class SiteSearch extends HookConsumerWidget { final searchTextController = controller ?? useTextEditingController(); final searchFocusNode = useFocusNode(); - useListenableCallback(searchTextController, () { + useOnListenableChangeSelector( + searchFocusNode, + () => searchFocusNode.hasFocus, + () { + if (searchFocusNode.hasFocus) { + // Select all text when the field is focused + searchTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: searchTextController.text.length, + ); + } + }, + ); + + //Request initial focus in a way our useOnListenableChangeSelector is triggered + useEffect(() { + //Wait for first frame then request focus + unawaited( + Future.delayed(const Duration(milliseconds: 1000 ~/ 60)).whenComplete( + () { + WidgetsBinding.instance.addPostFrameCallback((_) { + searchFocusNode.requestFocus(); + }); + }, + ), + ); + + return null; + }, []); + + useOnAppLifecycleStateChange((previous, current) { + switch (current) { + case AppLifecycleState.detached: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + //Fixes issue with disappearing keyboard after resume (even we request focus) + searchFocusNode.unfocus(); + case AppLifecycleState.resumed: + WidgetsBinding.instance.addPostFrameCallback((_) { + searchFocusNode.requestFocus(); + }); + } + }); + + useOnListenableChange(searchTextController, () { if (ref.exists(searchSuggestionsProvider())) { ref .read(searchSuggestionsProvider().notifier) @@ -171,15 +218,6 @@ class SiteSearch extends HookConsumerWidget { label: (selectedBang != null) ? const Text('Search') : const Text('Address / Search'), - 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); diff --git a/app/lib/presentation/hooks/listenable_callback.dart b/app/lib/presentation/hooks/listenable_callback.dart deleted file mode 100644 index c4f90eb9..00000000 --- a/app/lib/presentation/hooks/listenable_callback.dart +++ /dev/null @@ -1,32 +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/foundation.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; - -void useListenableCallback( - Listenable? listenable, - void Function() callback, [ - List? keys, -]) { - useEffect(() { - listenable?.addListener(callback); - return () => listenable?.removeListener(callback); - }, keys ?? [listenable]); -} diff --git a/app/lib/presentation/hooks/on_listenable_change_selector.dart b/app/lib/presentation/hooks/on_listenable_change_selector.dart new file mode 100644 index 00000000..0146a7f7 --- /dev/null +++ b/app/lib/presentation/hooks/on_listenable_change_selector.dart @@ -0,0 +1,104 @@ +/* + * 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'; + +/// Adds a given [listener] to a [Listenable] and removes it when the hook is +/// disposed. The listener is only called when the [selector] result changes. +/// +/// As opposed to `useListenable`, this hook does not mark the widget as needing +/// build when the listener is called. Use this for side effects that do not +/// require a rebuild. +/// +/// See also: +/// * [Listenable] +/// * [ValueListenable] +/// * [useOnListenableChange] +void useOnListenableChangeSelector( + Listenable? listenable, + R Function() selector, + VoidCallback listener, +) { + return use(_OnListenableChangeSelectorHook(listenable, selector, listener)); +} + +class _OnListenableChangeSelectorHook extends Hook { + const _OnListenableChangeSelectorHook( + this.listenable, + this.selector, + this.listener, + ); + + final Listenable? listenable; + final R Function() selector; + final VoidCallback listener; + + @override + _OnListenableChangeSelectorHookState createState() => + _OnListenableChangeSelectorHookState(); +} + +class _OnListenableChangeSelectorHookState + extends HookState> { + late R _selectorResult = hook.selector(); + + @override + void initHook() { + super.initHook(); + hook.listenable?.addListener(_listener); + } + + @override + void didUpdateHook(_OnListenableChangeSelectorHook oldHook) { + super.didUpdateHook(oldHook); + + if (hook.selector != oldHook.selector) { + _selectorResult = hook.selector(); + } + + if (hook.listenable != oldHook.listenable) { + oldHook.listenable?.removeListener(_listener); + hook.listenable?.addListener(_listener); + _selectorResult = hook.selector(); + } + } + + @override + void build(BuildContext context) {} + + void _listener() { + final latestSelectorResult = hook.selector(); + if (_selectorResult != latestSelectorResult) { + _selectorResult = latestSelectorResult; + hook.listener(); + } + } + + @override + void dispose() { + hook.listenable?.removeListener(_listener); + } + + @override + String get debugLabel => 'useOnListenableChangeSelector<$R>'; + + @override + Object? get debugValue => hook.listenable; +}