merge address bar into search screen

This commit is contained in:
Fabian Freund
2026-01-19 16:01:44 +01:00
parent 629c6da667
commit cbbbcf01e8
9 changed files with 184 additions and 37 deletions
@@ -594,7 +594,7 @@ class _SheetContainer extends HookConsumerWidget {
final SiteSettingsSheet parameter => final SiteSettingsSheet parameter =>
NotificationListener<DraggableScrollableNotification>( NotificationListener<DraggableScrollableNotification>(
onNotification: dismissOnThreshold, onNotification: dismissOnThreshold,
child: _ViewUrlSheet( child: _SiteSettingsSheet(
initialTabState: parameter.tabState, initialTabState: parameter.tabState,
maxChildSize: stableMaxChildSize, maxChildSize: stableMaxChildSize,
bottomAppBarHeight: bottomAppBarHeight, bottomAppBarHeight: bottomAppBarHeight,
@@ -855,12 +855,14 @@ class _BrowserView extends StatelessWidget {
} }
} }
class _ViewUrlSheet extends HookConsumerWidget { class _SiteSettingsSheet extends HookConsumerWidget {
final double maxChildSize; final double maxChildSize;
final TabState initialTabState; final TabState initialTabState;
final double bottomAppBarHeight; final double bottomAppBarHeight;
const _ViewUrlSheet({ static const initialHeight = 0.8;
const _SiteSettingsSheet({
required this.initialTabState, required this.initialTabState,
required this.bottomAppBarHeight, required this.bottomAppBarHeight,
this.maxChildSize = 1.0, this.maxChildSize = 1.0,
@@ -870,7 +872,17 @@ class _ViewUrlSheet extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final draggableScrollableController = useDraggableScrollableController(); 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( return DraggableScrollableSheet(
controller: draggableScrollableController, controller: draggableScrollableController,
@@ -904,6 +916,7 @@ class _ViewUrlSheet extends HookConsumerWidget {
}, },
initialHeight: initialHeight, initialHeight: initialHeight,
bottomAppBarHeight: bottomAppBarHeight, bottomAppBarHeight: bottomAppBarHeight,
onClearSiteDataExpandedChanged: handleClearSiteDataExpansion,
), ),
); );
}, },
@@ -73,6 +73,7 @@ class CertificateTile extends HookConsumerWidget {
child: ListTile( child: ListTile(
leading: const Skeleton.keep(child: Icon(MdiIcons.timerSand)), leading: const Skeleton.keep(child: Icon(MdiIcons.timerSand)),
title: Text(BoneMock.title), title: Text(BoneMock.title),
subtitle: Text(BoneMock.subtitle),
), ),
); );
} }
@@ -28,8 +28,13 @@ import 'package:weblibre/utils/ui_helper.dart';
/// Section widget for clearing site data /// Section widget for clearing site data
class ClearSiteDataSection extends HookConsumerWidget { class ClearSiteDataSection extends HookConsumerWidget {
final Uri url; final Uri url;
final ValueChanged<bool>? onExpandedChanged;
const ClearSiteDataSection({required this.url, super.key}); const ClearSiteDataSection({
required this.url,
this.onExpandedChanged,
super.key,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -65,7 +70,11 @@ class ClearSiteDataSection extends HookConsumerWidget {
trailing: Icon( trailing: Icon(
isExpanded.value ? Icons.expand_less : Icons.expand_more, 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) ...[ if (isExpanded.value) ...[
_DataTypeCheckbox( _DataTypeCheckbox(
@@ -19,6 +19,7 @@
*/ */
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/domain/providers/tab_session.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/repositories/tracking_protection.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'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/tracking_protection_provider.dart';
@@ -41,7 +42,15 @@ class TrackingProtectionSection extends HookConsumerWidget {
tabId: tabId, tabId: tabId,
isEnabled: !hasException, // ETP enabled when NOT in exceptions 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(), error: (error, stack) => const SizedBox.shrink(),
); );
} }
@@ -50,6 +50,7 @@ class ViewTabSheetWidget extends HookConsumerWidget {
final VoidCallback onClose; final VoidCallback onClose;
final double initialHeight; final double initialHeight;
final double bottomAppBarHeight; final double bottomAppBarHeight;
final ValueChanged<bool>? onClearSiteDataExpandedChanged;
const ViewTabSheetWidget({ const ViewTabSheetWidget({
required this.initialTabState, required this.initialTabState,
@@ -58,6 +59,7 @@ class ViewTabSheetWidget extends HookConsumerWidget {
required this.onClose, required this.onClose,
required this.initialHeight, required this.initialHeight,
required this.bottomAppBarHeight, required this.bottomAppBarHeight,
this.onClearSiteDataExpandedChanged,
}); });
@override @override
@@ -152,7 +154,10 @@ class ViewTabSheetWidget extends HookConsumerWidget {
), ),
const Divider(), const Divider(),
// Clear Site Data Section // Clear Site Data Section
ClearSiteDataSection(url: initialTabState.url), ClearSiteDataSection(
url: initialTabState.url,
onExpandedChanged: onClearSiteDataExpandedChanged,
),
const SizedBox(height: 16.0), const SizedBox(height: 16.0),
], ],
); );
@@ -17,13 +17,17 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.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/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/providers/search.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/history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.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/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/hooks/sampled_value_notifier.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart'; import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.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; import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class SearchScreen extends HookConsumerWidget { class SearchScreen extends HookConsumerWidget {
@@ -101,6 +107,74 @@ class SearchScreen extends HookConsumerWidget {
sampleDuration: const Duration(milliseconds: 150), sampleDuration: const Duration(milliseconds: 150),
); );
final searchFocusNode = useFocusNode(); final searchFocusNode = useFocusNode();
final textFieldKey = useMemoized(() => GlobalKey());
final preferredHeight = useState<double>(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) { useOnAppLifecycleStateChange((previous, current) {
switch (current) { switch (current) {
@@ -126,7 +200,9 @@ class SearchScreen extends HookConsumerWidget {
final showBangIcon = useState(false); final showBangIcon = useState(false);
ref.listen( ref.listen(
selectedBangDataProvider(domain: isEditMode ? existingTabState.url.host : null), selectedBangDataProvider(
domain: isEditMode ? existingTabState.url.host : null,
),
(previous, next) { (previous, next) {
if (previous != next) { if (previous != next) {
showBangIcon.value = true; showBangIcon.value = true;
@@ -188,20 +264,10 @@ class SearchScreen extends HookConsumerWidget {
pinned: true, pinned: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
title: isEditMode title: isEditMode
? Column( ? _SiteBangsSelector(
mainAxisSize: MainAxisSize.min, tabId: tabId!,
children: [ domain: existingTabState.url.host,
_SiteBangsSelector( searchTextController: searchTextController,
tabId: tabId!,
domain: existingTabState.url.host,
),
Text(
'Editing current tab',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
) )
: Align( : Align(
child: Focus( child: Focus(
@@ -230,7 +296,9 @@ class SearchScreen extends HookConsumerWidget {
onSelectionChanged: (value) { onSelectionChanged: (value) {
selectedTabType.value = value.first; selectedTabType.value = value.first;
// Restore focus to search field after segment change // Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((
_,
) {
searchFocusNode.requestFocus(); searchFocusNode.requestFocus();
}); });
}, },
@@ -243,8 +311,8 @@ class SearchScreen extends HookConsumerWidget {
TabType.child => TabType.child =>
(currentTabTabType == TabType.private) (currentTabTabType == TabType.private)
? SegmentedButton.styleFrom( ? SegmentedButton.styleFrom(
selectedBackgroundColor: selectedBackgroundColor: appColors
appColors.privateSelectionOverlay, .privateSelectionOverlay,
) )
: null, : null,
}, },
@@ -252,15 +320,20 @@ class SearchScreen extends HookConsumerWidget {
), ),
), ),
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight), preferredSize: Size.fromHeight(preferredHeight.value),
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 16.0), padding: const EdgeInsets.only(left: 16.0),
child: SearchField( child: SearchField(
textFieldKey: textFieldKey,
showBangIcon: showBangIcon.value, showBangIcon: showBangIcon.value,
textEditingController: searchTextController, textEditingController: searchTextController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
maxLines: isEditMode ? 3 : 1,
autofocus: true, autofocus: true,
label: const Text('Address / Search'), label: (activeBang != null)
? const Text('Search')
: const Text('Address / Search'),
unfocusOnTapOutside: !isEditMode,
onSubmitted: (value) async { onSubmitted: (value) async {
if (value.isNotEmpty) { if (value.isNotEmpty) {
var newUrl = uri_parser.tryParseUrl( var newUrl = uri_parser.tryParseUrl(
@@ -270,9 +343,13 @@ class SearchScreen extends HookConsumerWidget {
if (newUrl == null) { if (newUrl == null) {
final bang = final bang =
ref.read(selectedBangDataProvider( ref.read(
domain: isEditMode ? existingTabState.url.host : null, selectedBangDataProvider(
)) ?? domain: isEditMode
? existingTabState.url.host
: null,
),
) ??
await ref.read( await ref.read(
defaultSearchBangDataProvider.future, defaultSearchBangDataProvider.future,
); );
@@ -292,7 +369,11 @@ class SearchScreen extends HookConsumerWidget {
if (isEditMode) { if (isEditMode) {
// Load into existing tab // Load into existing tab
await ref await ref
.read(tabSessionProvider(tabId: tabId).notifier) .read(
tabSessionProvider(
tabId: tabId,
).notifier,
)
.loadUrl(url: newUrl); .loadUrl(url: newUrl);
} else { } else {
// Create new tab // Create new tab
@@ -357,17 +438,17 @@ class SearchScreen extends HookConsumerWidget {
class _SiteBangsSelector extends HookConsumerWidget { class _SiteBangsSelector extends HookConsumerWidget {
final String tabId; final String tabId;
final String domain; final String domain;
final TextEditingController searchTextController;
const _SiteBangsSelector({ const _SiteBangsSelector({
required this.tabId, required this.tabId,
required this.domain, required this.domain,
required this.searchTextController,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedBang = ref.watch( final selectedBang = ref.watch(selectedBangDataProvider(domain: domain));
selectedBangDataProvider(domain: domain),
);
final availableBangs = ref.watch( final availableBangs = ref.watch(
bangListProvider( bangListProvider(
domain: domain, domain: domain,
@@ -390,6 +471,18 @@ class _SiteBangsSelector extends HookConsumerWidget {
availableItems: availableBangs, availableItems: availableBangs,
selectedItem: selectedBang, selectedItem: selectedBang,
onSelected: (bang) { onSelected: (bang) {
if (selectedBang == null) {
final hasSupportedScheme =
uri_parser
.tryParseUrl(searchTextController.text)
.mapNotNull((uri) => uri.hasSupportedScheme) ??
false;
if (hasSupportedScheme) {
searchTextController.clear();
}
}
ref ref
.read(selectedBangTriggerProvider(domain: domain).notifier) .read(selectedBangTriggerProvider(domain: domain).notifier)
.setTrigger(bang.toKey()); .setTrigger(bang.toKey());
@@ -37,8 +37,10 @@ class SearchField extends HookConsumerWidget {
final void Function(String)? onSubmitted; final void Function(String)? onSubmitted;
final bool showSuggestions; final bool showSuggestions;
final int? maxLines; final int? maxLines;
final int? minLines;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool unfocusOnTapOutside; final bool unfocusOnTapOutside;
final GlobalKey? textFieldKey;
final BangData? activeBang; final BangData? activeBang;
final bool showBangIcon; final bool showBangIcon;
@@ -52,10 +54,12 @@ class SearchField extends HookConsumerWidget {
this.label, this.label,
this.focusNode, this.focusNode,
this.maxLines = 1, this.maxLines = 1,
this.minLines = 1,
this.onTap, this.onTap,
this.unfocusOnTapOutside = true, this.unfocusOnTapOutside = true,
this.showBangIcon = true, this.showBangIcon = true,
this.autofocus = false, this.autofocus = false,
this.textFieldKey,
}); });
@override @override
@@ -100,10 +104,11 @@ class SearchField extends HookConsumerWidget {
enableIMEPersonalizedLearning: !incognitoEnabled, enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: safeFocusNode, focusNode: safeFocusNode,
maxLines: maxLines, maxLines: maxLines,
//Submit isntead of newline textFieldKey: textFieldKey,
textInputAction: (maxLines == null || maxLines! > 1) textInputAction: (maxLines == null || maxLines! > 1)
? TextInputAction.done ? TextInputAction.done
: null, : null,
minLines: minLines,
autofocus: autofocus, autofocus: autofocus,
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
@@ -50,6 +50,7 @@ class AutoSuggestTextField extends HookWidget {
final TapRegionCallback? onTapOutside; final TapRegionCallback? onTapOutside;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool autocorrect; final bool autocorrect;
final GlobalKey? textFieldKey;
const AutoSuggestTextField({ const AutoSuggestTextField({
super.key, super.key,
@@ -79,6 +80,7 @@ class AutoSuggestTextField extends HookWidget {
this.onTapOutside, this.onTapOutside,
this.onTap, this.onTap,
this.autocorrect = false, this.autocorrect = false,
this.textFieldKey,
}); });
bool _suggestionHasMatch() => bool _suggestionHasMatch() =>
@@ -88,7 +90,8 @@ class AutoSuggestTextField extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textFieldKey = useMemoized(() => GlobalKey()); final textFieldKey =
this.textFieldKey ?? useMemoized<GlobalKey>(() => GlobalKey());
final showSuggestion = useListenableSelector(controller, () { final showSuggestion = useListenableSelector(controller, () {
if (maxLines != 1) { if (maxLines != 1) {
+9
View File
@@ -47,3 +47,12 @@ int? getTextFieldLineCount(GlobalKey key, String text, TextStyle style) {
return null; return null;
} }
double? getTextFieldHeight(GlobalKey key) {
final box = key.currentContext?.findRenderObject();
if (box case final RenderBox box) {
return box.size.height;
}
return null;
}