From 4c33183c97710758b5066c26f3e84a96d88cd4cd Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Thu, 2 Jul 2026 11:10:05 +0200 Subject: [PATCH] use keyed state hook --- .../browser/presentation/screens/browser.dart | 15 ++--- .../empty_state/top_sites_section.dart | 8 +-- .../screens/general_settings.dart | 8 +-- .../screens/web_content_settings.dart | 9 +-- .../widgets/toolbar_layout_content.dart | 16 ++--- .../widgets/web_search_infobox_card.dart | 10 +-- .../lib/presentation/hooks/keyed_state.dart | 63 +++++++++++++++++++ 7 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 apps/weblibre/lib/presentation/hooks/keyed_state.dart diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 3fb84734..b6fe94ca 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -72,6 +72,7 @@ import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart'; +import 'package:weblibre/presentation/hooks/keyed_state.dart'; import 'package:weblibre/utils/move_to_background.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -687,15 +688,11 @@ class BrowserScreen extends HookConsumerWidget { // Track dismissed state without triggering full rebuild on every // hide/show. Updated via ref.listen below and synced on tab switch. - final toolbarDismissed = useState(false); - - // Sync dismissed state when selected tab changes - useEffect(() { - toolbarDismissed.value = - ref.read(toolbarVisibilityControllerProvider(selectedTabId)) == - ToolbarVisibility.dismissed; - return null; - }, [selectedTabId]); + final toolbarDismissed = useKeyedState( + ref.read(toolbarVisibilityControllerProvider(selectedTabId)) == + ToolbarVisibility.dismissed, + [selectedTabId], + ); final stableToolbarHeight = autoHideTabBar && !toolbarDismissed.value && !tabInFullScreen diff --git a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart index 9a1ddb4e..e2b5dbec 100644 --- a/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart +++ b/apps/weblibre/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart @@ -32,6 +32,7 @@ import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/t import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; +import 'package:weblibre/presentation/hooks/keyed_state.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -228,12 +229,7 @@ class _ReorderableTopSitesGrid extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final localItems = useState(persistedItems); - - useEffect(() { - localItems.value = persistedItems; - return null; - }, [persistedItems]); + final localItems = useKeyedState(persistedItems, [persistedItems]); return SliverToBoxAdapter( child: Padding( diff --git a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart index 7fc6de4e..a27124ea 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/general_settings.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/settings/presentation/widgets/settings_detail. import 'package:weblibre/features/user/data/models/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/keyed_state.dart'; const List generalSettingsSections = [ SettingsSectionDefinition( @@ -198,12 +199,7 @@ class _UiZoomSection extends HookConsumerWidget { final uiScaleFactor = ref.watch( generalSettingsWithDefaultsProvider.select((s) => s.uiScaleFactor), ); - final sliderValue = useState(uiScaleFactor); - - useEffect(() { - sliderValue.value = uiScaleFactor; - return null; - }, [uiScaleFactor]); + final sliderValue = useKeyedState(uiScaleFactor, [uiScaleFactor]); final sliderLabel = '${(sliderValue.value * 100).round()}%'; diff --git a/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart b/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart index 4f8261ba..0c2d3a58 100644 --- a/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart +++ b/apps/weblibre/lib/features/settings/presentation/screens/web_content_settings.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ import 'package:flutter/material.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:weblibre/features/settings/presentation/controllers/save_settings.dart'; @@ -27,6 +26,7 @@ import 'package:weblibre/features/user/data/models/engine_settings.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/engine_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/keyed_state.dart'; const List webContentSettingsSections = [ SettingsSectionDefinition( @@ -178,12 +178,7 @@ class _FontSizeFactorSlider extends HookConsumerWidget { final fontSizeFactor = ref.watch( engineSettingsWithDefaultsProvider.select((s) => s.fontSizeFactor), ); - final sliderValue = useState(fontSizeFactor); - - useEffect(() { - sliderValue.value = fontSizeFactor; - return null; - }, [fontSizeFactor]); + final sliderValue = useKeyedState(fontSizeFactor, [fontSizeFactor]); final sliderLabel = '${(sliderValue.value * 100).round()}%'; diff --git a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart index 8a38b01b..6cc495a4 100644 --- a/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart +++ b/apps/weblibre/lib/features/settings/presentation/widgets/toolbar_layout_content.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ import 'package:flutter/material.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:weblibre/core/routing/routes.dart'; @@ -26,6 +25,7 @@ import 'package:weblibre/features/settings/presentation/controllers/save_setting import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart'; import 'package:weblibre/features/user/data/models/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/keyed_state.dart'; const List toolbarLayoutSettingsSections = [ SettingsSectionDefinition( @@ -460,11 +460,7 @@ class _QuickTabSwitcherTitleWidthTile extends HookConsumerWidget { ), ); - final sliderValue = useState(titleWidth); - useEffect(() { - sliderValue.value = titleWidth; - return null; - }, [titleWidth]); + final sliderValue = useKeyedState(titleWidth, [titleWidth]); final enabled = switcherEnabled && showTitles; @@ -626,11 +622,9 @@ class _QuickTabSwitcherHierarchyGlyphsTile extends HookConsumerWidget { ), ); - final sliderValue = useState(hierarchyGlyphs.toDouble()); - useEffect(() { - sliderValue.value = hierarchyGlyphs.toDouble(); - return null; - }, [hierarchyGlyphs]); + final sliderValue = useKeyedState(hierarchyGlyphs.toDouble(), [ + hierarchyGlyphs, + ]); final currentGlyphs = sliderValue.value.round(); final enabled = switcherEnabled; diff --git a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart index 66679698..00cd74c5 100644 --- a/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart +++ b/apps/weblibre/lib/features/web_search/presentation/widgets/web_search_infobox_card.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:search_protocol/search_protocol.dart'; import 'package:weblibre/core/providers/persisted_bool.dart'; import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart'; +import 'package:weblibre/presentation/hooks/keyed_state.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; @@ -541,16 +542,11 @@ class WebSearchInfoboxCarousel extends HookConsumerWidget { // Natural (content) height of each page, measured off-stage. `null` until // a page has been laid out at least once. - final heights = useState>( + final heights = useKeyedState>( List.filled(infos.length, null), + [infos.length], ); - // Reset the measurement cache when the page set changes. - useEffect(() { - heights.value = List.filled(infos.length, null); - return null; - }, [infos.length]); - useEffect(() { void listener() { final page = controller.page?.round() ?? 0; diff --git a/apps/weblibre/lib/presentation/hooks/keyed_state.dart b/apps/weblibre/lib/presentation/hooks/keyed_state.dart new file mode 100644 index 00000000..35079b37 --- /dev/null +++ b/apps/weblibre/lib/presentation/hooks/keyed_state.dart @@ -0,0 +1,63 @@ +/* + * 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 . + */ +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Creates a [ValueNotifier] that is re-created when [keys] change. +ValueNotifier useKeyedState(T initialData, List keys) { + return use(_KeyedStateHook(initialData: initialData, keys: keys)); +} + +class _KeyedStateHook extends Hook> { + const _KeyedStateHook({ + required this.initialData, + required List keys, + }) : super(keys: keys); + + final T initialData; + + @override + _KeyedStateHookState createState() => _KeyedStateHookState(); +} + +class _KeyedStateHookState + extends HookState, _KeyedStateHook> { + late final _state = ValueNotifier(hook.initialData) + ..addListener(_listener); + + @override + void dispose() { + _state.dispose(); + super.dispose(); + } + + @override + ValueNotifier build(BuildContext context) => _state; + + void _listener() { + setState(() {}); + } + + @override + Object? get debugValue => _state.value; + + @override + String get debugLabel => 'useKeyedState<$T>'; +}