From cbbbcf01e8d0daaf480f0b19e3c6304a7377470e Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 19 Jan 2026 16:01:44 +0100 Subject: [PATCH] merge address bar into search screen --- .../browser/presentation/screens/browser.dart | 21 ++- .../widgets/certificate_tile.dart | 1 + .../sheets/clear_site_data_section.dart | 13 +- .../sheets/tracking_protection_section.dart | 11 +- .../presentation/widgets/sheets/view_tab.dart | 7 +- .../search/presentation/screens/search.dart | 147 ++++++++++++++---- .../presentation/widgets/search_field.dart | 7 +- .../widgets/auto_suggest_text_field.dart | 5 +- app/lib/utils/text_field_line_count.dart | 9 ++ 9 files changed, 184 insertions(+), 37 deletions(-) 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 2e3f1898..1c1db16a 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -594,7 +594,7 @@ class _SheetContainer extends HookConsumerWidget { final SiteSettingsSheet parameter => NotificationListener( onNotification: dismissOnThreshold, - child: _ViewUrlSheet( + child: _SiteSettingsSheet( initialTabState: parameter.tabState, maxChildSize: stableMaxChildSize, bottomAppBarHeight: bottomAppBarHeight, @@ -855,12 +855,14 @@ class _BrowserView extends StatelessWidget { } } -class _ViewUrlSheet extends HookConsumerWidget { +class _SiteSettingsSheet extends HookConsumerWidget { final double maxChildSize; final TabState initialTabState; final double bottomAppBarHeight; - const _ViewUrlSheet({ + static const initialHeight = 0.8; + + const _SiteSettingsSheet({ required this.initialTabState, required this.bottomAppBarHeight, this.maxChildSize = 1.0, @@ -870,7 +872,17 @@ class _ViewUrlSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final draggableScrollableController = useDraggableScrollableController(); - final initialHeight = 172.0 / MediaQuery.of(context).size.height; + void handleClearSiteDataExpansion(bool isExpanded) { + if (isExpanded) { + unawaited( + draggableScrollableController.animateTo( + 1.0, + duration: const Duration(milliseconds: 300), + curve: Curves.decelerate, + ), + ); + } + } return DraggableScrollableSheet( controller: draggableScrollableController, @@ -904,6 +916,7 @@ class _ViewUrlSheet extends HookConsumerWidget { }, initialHeight: initialHeight, bottomAppBarHeight: bottomAppBarHeight, + onClearSiteDataExpandedChanged: handleClearSiteDataExpansion, ), ); }, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart index 4887b724..57828dca 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/certificate_tile.dart @@ -73,6 +73,7 @@ class CertificateTile extends HookConsumerWidget { child: ListTile( leading: const Skeleton.keep(child: Icon(MdiIcons.timerSand)), title: Text(BoneMock.title), + subtitle: Text(BoneMock.subtitle), ), ); } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart index 72063f07..b0c55c9f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/clear_site_data_section.dart @@ -28,8 +28,13 @@ import 'package:weblibre/utils/ui_helper.dart'; /// Section widget for clearing site data class ClearSiteDataSection extends HookConsumerWidget { final Uri url; + final ValueChanged? onExpandedChanged; - const ClearSiteDataSection({required this.url, super.key}); + const ClearSiteDataSection({ + required this.url, + this.onExpandedChanged, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -65,7 +70,11 @@ class ClearSiteDataSection extends HookConsumerWidget { trailing: Icon( isExpanded.value ? Icons.expand_less : Icons.expand_more, ), - onTap: () => isExpanded.value = !isExpanded.value, + onTap: () { + final newValue = !isExpanded.value; + isExpanded.value = newValue; + onExpandedChanged?.call(newValue); + }, ), if (isExpanded.value) ...[ _DataTypeCheckbox( diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart index c16c3b48..22234b80 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_section.dart @@ -19,6 +19,7 @@ */ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/repositories/tracking_protection.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart'; @@ -41,7 +42,15 @@ class TrackingProtectionSection extends HookConsumerWidget { tabId: tabId, isEnabled: !hasException, // ETP enabled when NOT in exceptions ), - loading: () => const SizedBox.shrink(), + loading: () => Skeletonizer( + child: SwitchListTile.adaptive( + value: false, + onChanged: null, + title: Text(BoneMock.title), + subtitle: Text(BoneMock.subtitle), + secondary: const Icon(Icons.shield_outlined), + ), + ), error: (error, stack) => const SizedBox.shrink(), ); } 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 index 321b011a..9c2f4e86 100644 --- 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 @@ -50,6 +50,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { final VoidCallback onClose; final double initialHeight; final double bottomAppBarHeight; + final ValueChanged? onClearSiteDataExpandedChanged; const ViewTabSheetWidget({ required this.initialTabState, @@ -58,6 +59,7 @@ class ViewTabSheetWidget extends HookConsumerWidget { required this.onClose, required this.initialHeight, required this.bottomAppBarHeight, + this.onClearSiteDataExpandedChanged, }); @override @@ -152,7 +154,10 @@ class ViewTabSheetWidget extends HookConsumerWidget { ), const Divider(), // Clear Site Data Section - ClearSiteDataSection(url: initialTabState.url), + ClearSiteDataSection( + url: initialTabState.url, + onExpandedChanged: onClearSiteDataExpandedChanged, + ), const SizedBox(height: 16.0), ], ); 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 02743e6d..703094d8 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -17,13 +17,17 @@ * 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:fading_scroll/fading_scroll.dart'; 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:nullability/nullability.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.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'; @@ -40,9 +44,11 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets 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/tab_search.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/text_field_line_count.dart'; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; class SearchScreen extends HookConsumerWidget { @@ -101,6 +107,74 @@ class SearchScreen extends HookConsumerWidget { sampleDuration: const Duration(milliseconds: 150), ); final searchFocusNode = useFocusNode(); + final textFieldKey = useMemoized(() => GlobalKey()); + final preferredHeight = useState(kToolbarHeight); + + useOnListenableChangeSelector( + searchFocusNode, + () => searchFocusNode.hasFocus, + () { + if (searchFocusNode.hasFocus && isEditMode) { + // Select all text when the field is focused + searchTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: searchTextController.text.length, + ); + } + }, + ); + + useEffect(() { + if (isEditMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + searchTextController.selection = TextSelection( + baseOffset: 0, + extentOffset: searchTextController.text.length, + ); + }); + } + return null; + }, []); + + //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; + }, []); + + useEffect(() { + void measureHeight() { + final measuredHeight = getTextFieldHeight(textFieldKey); + + if (measuredHeight != null) { + preferredHeight.value = measuredHeight; + } + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + measureHeight(); + }); + + return null; + }, [textFieldKey, isEditMode]); + + useOnListenableChange(isEditMode ? searchTextController : null, () { + final measuredHeight = getTextFieldHeight(textFieldKey); + + if (measuredHeight != null && preferredHeight.value != measuredHeight) { + preferredHeight.value = measuredHeight; + } + }); useOnAppLifecycleStateChange((previous, current) { switch (current) { @@ -126,7 +200,9 @@ class SearchScreen extends HookConsumerWidget { final showBangIcon = useState(false); ref.listen( - selectedBangDataProvider(domain: isEditMode ? existingTabState.url.host : null), + selectedBangDataProvider( + domain: isEditMode ? existingTabState.url.host : null, + ), (previous, next) { if (previous != next) { showBangIcon.value = true; @@ -188,20 +264,10 @@ class SearchScreen extends HookConsumerWidget { pinned: true, automaticallyImplyLeading: false, title: isEditMode - ? Column( - mainAxisSize: MainAxisSize.min, - children: [ - _SiteBangsSelector( - tabId: tabId!, - domain: existingTabState.url.host, - ), - Text( - 'Editing current tab', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], + ? _SiteBangsSelector( + tabId: tabId!, + domain: existingTabState.url.host, + searchTextController: searchTextController, ) : Align( child: Focus( @@ -230,7 +296,9 @@ class SearchScreen extends HookConsumerWidget { onSelectionChanged: (value) { selectedTabType.value = value.first; // Restore focus to search field after segment change - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback(( + _, + ) { searchFocusNode.requestFocus(); }); }, @@ -243,8 +311,8 @@ class SearchScreen extends HookConsumerWidget { TabType.child => (currentTabTabType == TabType.private) ? SegmentedButton.styleFrom( - selectedBackgroundColor: - appColors.privateSelectionOverlay, + selectedBackgroundColor: appColors + .privateSelectionOverlay, ) : null, }, @@ -252,15 +320,20 @@ class SearchScreen extends HookConsumerWidget { ), ), bottom: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), + preferredSize: Size.fromHeight(preferredHeight.value), child: Padding( padding: const EdgeInsets.only(left: 16.0), child: SearchField( + textFieldKey: textFieldKey, showBangIcon: showBangIcon.value, textEditingController: searchTextController, focusNode: searchFocusNode, + maxLines: isEditMode ? 3 : 1, autofocus: true, - label: const Text('Address / Search'), + label: (activeBang != null) + ? const Text('Search') + : const Text('Address / Search'), + unfocusOnTapOutside: !isEditMode, onSubmitted: (value) async { if (value.isNotEmpty) { var newUrl = uri_parser.tryParseUrl( @@ -270,9 +343,13 @@ class SearchScreen extends HookConsumerWidget { if (newUrl == null) { final bang = - ref.read(selectedBangDataProvider( - domain: isEditMode ? existingTabState.url.host : null, - )) ?? + ref.read( + selectedBangDataProvider( + domain: isEditMode + ? existingTabState.url.host + : null, + ), + ) ?? await ref.read( defaultSearchBangDataProvider.future, ); @@ -292,7 +369,11 @@ class SearchScreen extends HookConsumerWidget { if (isEditMode) { // Load into existing tab await ref - .read(tabSessionProvider(tabId: tabId).notifier) + .read( + tabSessionProvider( + tabId: tabId, + ).notifier, + ) .loadUrl(url: newUrl); } else { // Create new tab @@ -357,17 +438,17 @@ class SearchScreen extends HookConsumerWidget { class _SiteBangsSelector extends HookConsumerWidget { final String tabId; final String domain; + final TextEditingController searchTextController; const _SiteBangsSelector({ required this.tabId, required this.domain, + required this.searchTextController, }); @override Widget build(BuildContext context, WidgetRef ref) { - final selectedBang = ref.watch( - selectedBangDataProvider(domain: domain), - ); + final selectedBang = ref.watch(selectedBangDataProvider(domain: domain)); final availableBangs = ref.watch( bangListProvider( domain: domain, @@ -390,6 +471,18 @@ class _SiteBangsSelector 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.toKey()); 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 1edf0db7..6e4fe89e 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 @@ -37,8 +37,10 @@ class SearchField extends HookConsumerWidget { final void Function(String)? onSubmitted; final bool showSuggestions; final int? maxLines; + final int? minLines; final VoidCallback? onTap; final bool unfocusOnTapOutside; + final GlobalKey? textFieldKey; final BangData? activeBang; final bool showBangIcon; @@ -52,10 +54,12 @@ class SearchField extends HookConsumerWidget { this.label, this.focusNode, this.maxLines = 1, + this.minLines = 1, this.onTap, this.unfocusOnTapOutside = true, this.showBangIcon = true, this.autofocus = false, + this.textFieldKey, }); @override @@ -100,10 +104,11 @@ class SearchField extends HookConsumerWidget { enableIMEPersonalizedLearning: !incognitoEnabled, focusNode: safeFocusNode, maxLines: maxLines, - //Submit isntead of newline + textFieldKey: textFieldKey, textInputAction: (maxLines == null || maxLines! > 1) ? TextInputAction.done : null, + minLines: minLines, autofocus: autofocus, decoration: InputDecoration( border: InputBorder.none, diff --git a/app/lib/presentation/widgets/auto_suggest_text_field.dart b/app/lib/presentation/widgets/auto_suggest_text_field.dart index d26ff106..66b958df 100644 --- a/app/lib/presentation/widgets/auto_suggest_text_field.dart +++ b/app/lib/presentation/widgets/auto_suggest_text_field.dart @@ -50,6 +50,7 @@ class AutoSuggestTextField extends HookWidget { final TapRegionCallback? onTapOutside; final VoidCallback? onTap; final bool autocorrect; + final GlobalKey? textFieldKey; const AutoSuggestTextField({ super.key, @@ -79,6 +80,7 @@ class AutoSuggestTextField extends HookWidget { this.onTapOutside, this.onTap, this.autocorrect = false, + this.textFieldKey, }); bool _suggestionHasMatch() => @@ -88,7 +90,8 @@ class AutoSuggestTextField extends HookWidget { @override Widget build(BuildContext context) { - final textFieldKey = useMemoized(() => GlobalKey()); + final textFieldKey = + this.textFieldKey ?? useMemoized(() => GlobalKey()); final showSuggestion = useListenableSelector(controller, () { if (maxLines != 1) { diff --git a/app/lib/utils/text_field_line_count.dart b/app/lib/utils/text_field_line_count.dart index daee917a..08a51657 100644 --- a/app/lib/utils/text_field_line_count.dart +++ b/app/lib/utils/text_field_line_count.dart @@ -47,3 +47,12 @@ int? getTextFieldLineCount(GlobalKey key, String text, TextStyle style) { return null; } + +double? getTextFieldHeight(GlobalKey key) { + final box = key.currentContext?.findRenderObject(); + if (box case final RenderBox box) { + return box.size.height; + } + + return null; +}