introduce smart bang selector

This commit is contained in:
Fabian Freund
2026-01-19 17:54:58 +01:00
parent cbbbcf01e8
commit 2f5260ac84
4 changed files with 422 additions and 258 deletions
@@ -24,11 +24,8 @@ 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';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
@@ -46,8 +43,6 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets
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;
@@ -152,28 +147,30 @@ class SearchScreen extends HookConsumerWidget {
return null;
}, []);
useEffect(() {
void measureHeight() {
Future<void> measureHeightWithRetry() async {
const delays = [25, 50, 75, 100];
for (final delay in delays) {
await Future.delayed(Duration(milliseconds: delay));
final measuredHeight = getTextFieldHeight(textFieldKey);
if (measuredHeight != null) {
preferredHeight.value = measuredHeight;
return;
}
}
}
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
measureHeight();
measureHeightWithRetry(); // ignore: discarded_futures
});
return null;
}, [textFieldKey, isEditMode]);
useOnListenableChange(isEditMode ? searchTextController : null, () {
final measuredHeight = getTextFieldHeight(textFieldKey);
if (measuredHeight != null && preferredHeight.value != measuredHeight) {
preferredHeight.value = measuredHeight;
}
measureHeightWithRetry(); // ignore: discarded_futures
});
useOnAppLifecycleStateChange((previous, current) {
@@ -195,22 +192,25 @@ class SearchScreen extends HookConsumerWidget {
defaultSearchBangDataProvider.select((value) => value.value),
);
final selectedBang = useState<BangData?>(null);
final activeBang = selectedBang.value ?? defaultSearchBang;
final showBangIcon = useState(false);
// Watch both selection providers - only one should be set at a time
// due to mutual exclusion in SmartBangSelector
final siteSelectedBang = isEditMode
? ref.watch(selectedBangDataProvider(domain: existingTabState.url.host))
: null;
final globalSelectedBang = ref.watch(selectedBangDataProvider());
ref.listen(
selectedBangDataProvider(
domain: isEditMode ? existingTabState.url.host : null,
),
(previous, next) {
if (previous != next) {
showBangIcon.value = true;
}
// The active selection is whichever one is set (site takes priority if both somehow set)
final selectedBang = siteSelectedBang ?? globalSelectedBang;
final activeBang = selectedBang ?? defaultSearchBang;
final showBangIcon = selectedBang != null;
selectedBang.value = next;
},
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
measureHeightWithRetry(); // ignore: discarded_futures
});
return null;
}, [showBangIcon]);
Future<void> submitSearch(String query) async {
if (activeBang != null && (formKey.currentState?.validate() == true)) {
@@ -263,12 +263,9 @@ class SearchScreen extends HookConsumerWidget {
floating: true,
pinned: true,
automaticallyImplyLeading: false,
toolbarHeight: isEditMode ? 0 : kToolbarHeight,
title: isEditMode
? _SiteBangsSelector(
tabId: tabId!,
domain: existingTabState.url.host,
searchTextController: searchTextController,
)
? null
: Align(
child: Focus(
canRequestFocus: false,
@@ -325,7 +322,7 @@ class SearchScreen extends HookConsumerWidget {
padding: const EdgeInsets.only(left: 16.0),
child: SearchField(
textFieldKey: textFieldKey,
showBangIcon: showBangIcon.value,
showBangIcon: showBangIcon,
textEditingController: searchTextController,
focusNode: searchFocusNode,
maxLines: isEditMode ? 3 : 1,
@@ -342,14 +339,20 @@ class SearchScreen extends HookConsumerWidget {
);
if (newUrl == null) {
// Read from both providers - use site if set, otherwise global
final siteBang = isEditMode
? ref.read(
selectedBangDataProvider(
domain: existingTabState.url.host,
),
)
: null;
final globalBang = ref.read(
selectedBangDataProvider(),
);
final bang =
ref.read(
selectedBangDataProvider(
domain: isEditMode
? existingTabState.url.host
: null,
),
) ??
siteBang ??
globalBang ??
await ref.read(
defaultSearchBangDataProvider.future,
);
@@ -418,6 +421,7 @@ class SearchScreen extends HookConsumerWidget {
searchTextController: searchTextController,
activeBang: activeBang,
submitSearch: submitSearch,
domain: isEditMode ? existingTabState.url.host : null,
),
TabSearch(searchTextListenable: sampledSearchText),
FeedSearch(searchTextNotifier: sampledSearchText),
@@ -434,68 +438,3 @@ 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 availableBangs = ref.watch(
bangListProvider(
domain: domain,
orderMostFrequentFirst: true,
).select((value) => value.value ?? const []),
);
if (availableBangs.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox(
height: 48,
width: double.maxFinite,
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) => UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
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());
},
onDeleted: (bang) {
if (ref.read(selectedBangTriggerProvider(domain: domain)) ==
bang.toKey()) {
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.clearTrigger();
}
},
),
);
}
}
@@ -1,121 +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 <http://www.gnu.org/licenses/>.
*/
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/data/models/bang_data.dart';
import 'package:weblibre/features/bangs/data/models/bang_key.dart';
import 'package:weblibre/features/bangs/domain/providers/search.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class BangChips extends HookConsumerWidget {
final bool displayMenu;
final BangData? activeBang;
final void Function(BangData)? onSelected;
final void Function(BangData)? onDeleted;
final TextEditingController? searchTextController;
const BangChips({
required this.activeBang,
required this.onSelected,
required this.onDeleted,
this.searchTextController,
this.displayMenu = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableBangs = ref.watch(seamlessBangProvider);
useOnListenableChange(searchTextController, () {
ref
.read(seamlessBangProvider.notifier)
.search(searchTextController!.text);
});
return availableBangs.when(
skipLoadingOnReload: true,
data: (availableBangs) {
return SizedBox(
height: 48,
child: Row(
children: [
if (activeBang != null || availableBangs.isNotEmpty)
Expanded(
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) =>
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
availableItems: availableBangs,
selectedItem: activeBang,
onSelected: onSelected,
onDeleted: onDeleted,
),
)
else
Visibility(
visible: displayMenu,
child: Expanded(
child: Text(
"Press '>' to search Bangs.",
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
),
),
),
),
if (displayMenu)
IconButton(
onPressed: () async {
final searchText = searchTextController?.text.trim();
final trigger = await BangSearchRoute(
searchText: (searchText.isEmpty)
? BangSearchRoute.emptySearchText
: searchText!,
).push<BangKey?>(context);
if (trigger != null) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(trigger);
}
},
icon: const Icon(Icons.chevron_right),
),
],
),
);
},
error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox(height: 48, width: double.infinity),
);
}
}
@@ -27,20 +27,23 @@ import 'package:sliver_tools/sliver_tools.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/repositories/data.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chips.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
class FullSearchTermSuggestions extends HookConsumerWidget {
final TextEditingController searchTextController;
final Future<void> Function(String query) submitSearch;
final BangData? activeBang;
/// The domain to scope site-specific bangs to.
/// When null, only global bangs are shown (new tab mode).
final String? domain;
const FullSearchTermSuggestions({
required this.searchTextController,
required this.submitSearch,
required this.activeBang,
this.domain,
super.key,
});
@@ -152,35 +155,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
'Search Provider',
style: Theme.of(context).textTheme.labelSmall,
),
BangChips(
key: ValueKey(activeBang),
activeBang: activeBang,
onSelected: (bang) {
searchTextController.clear();
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.toKey());
},
onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) ==
bang.toKey()) {
ref
.read(selectedBangTriggerProvider().notifier)
.clearTrigger();
} else {
final dialogResult = await showResetBangDialog(
context,
triggerName: bang.trigger,
);
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
}
},
SmartBangSelector(
domain: domain,
searchTextController: searchTextController,
),
],
@@ -0,0 +1,370 @@
/*
* 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.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_key.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/repositories/data.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/reset_bang_dialog.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;
/// A unified bang selector widget that displays site-specific and/or global
/// search bangs with a tabbed interface when in edit mode.
///
/// In edit mode (domain != null):
/// - Shows "Site" and "All" tabs when site-specific bangs exist
/// - "Site" tab displays only domain-specific bangs
/// - "All" tab displays global bangs (same as new tab mode)
/// - Selecting in one tab clears selection in the other (mutual exclusion)
///
/// In new tab mode (domain == null):
/// - Shows global bangs without tabs
class SmartBangSelector extends HookConsumerWidget {
/// The domain to scope site-specific bangs to.
/// When null, only global bangs are shown (new tab mode).
final String? domain;
/// Controller for the search text field.
/// Used for text clearing on selection and for seamless bang search.
final TextEditingController searchTextController;
/// Whether to display the menu button to open bang search.
final bool displayMenu;
const SmartBangSelector({
required this.domain,
required this.searchTextController,
this.displayMenu = true,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isEditMode = domain != null;
// Watch site-specific bangs (only in edit mode)
final siteBangs = isEditMode
? ref.watch(
bangListProvider(
domain: domain,
orderMostFrequentFirst: true,
).select((value) => value.value ?? const []),
)
: const <BangData>[];
// Watch global bangs via seamless provider (supports search)
// When search text is empty: shows frequentBangListProvider
// When search text is not empty: shows search results
final searchBangs = ref.watch(
seamlessBangProvider.select((v) => v.value ?? const []),
);
// Also watch frequent bangs as fallback when search returns empty
final frequentBangs = ref.watch(
frequentBangListProvider.select((v) => v.value ?? const []),
);
// Use search results if available, otherwise fall back to frequent bangs
final globalBangs = searchBangs.isNotEmpty ? searchBangs : frequentBangs;
// Trigger search when text changes
useOnListenableChange(searchTextController, () {
ref.read(seamlessBangProvider.notifier).search(searchTextController.text);
});
// Determine if we should show tabs
final showTabs = isEditMode && siteBangs.isNotEmpty;
// Watch both selection providers to determine the active bang
final siteSelectedBang = isEditMode
? ref.watch(selectedBangDataProvider(domain: domain))
: null;
final globalSelectedBang = ref.watch(selectedBangDataProvider());
// The active bang is whichever one is set (only one should be set at a time)
final activeBang = siteSelectedBang ?? globalSelectedBang;
if (showTabs) {
return _TabbedBangSelector(
domain: domain!,
siteBangs: siteBangs,
globalBangs: globalBangs,
activeBang: activeBang,
isSiteSelected: siteSelectedBang != null,
searchTextController: searchTextController,
displayMenu: displayMenu,
);
}
// No tabs - show global bangs directly
return _BangChipsList(
domain: domain,
siteDomain: null, // No mutual exclusion needed without tabs
bangs: globalBangs,
selectedBang: activeBang,
searchTextController: searchTextController,
displayMenu: displayMenu,
);
}
}
/// Tabbed bang selector with "Site" and "All" tabs.
class _TabbedBangSelector extends HookConsumerWidget {
final String domain;
final List<BangData> siteBangs;
final List<BangData> globalBangs;
final BangData? activeBang;
final bool isSiteSelected;
final TextEditingController searchTextController;
final bool displayMenu;
const _TabbedBangSelector({
required this.domain,
required this.siteBangs,
required this.globalBangs,
required this.activeBang,
required this.isSiteSelected,
required this.searchTextController,
required this.displayMenu,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tab bar - full width
SizedBox(
height: 32,
width: double.infinity,
child: TabBar(
controller: tabController,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Site'),
Tab(text: 'All'),
],
),
),
// Tab content
SizedBox(
height: 48,
child: TabBarView(
controller: tabController,
children: [
// Site tab - uses domain-scoped provider, clears global on select
_BangChipsList(
domain: domain,
siteDomain: domain, // Pass for mutual exclusion
bangs: siteBangs,
selectedBang: isSiteSelected ? activeBang : null,
searchTextController: searchTextController,
displayMenu: displayMenu,
),
// All tab - uses global provider, clears site on select
_BangChipsList(
domain: null,
siteDomain: domain, // Pass for mutual exclusion
bangs: globalBangs,
selectedBang: !isSiteSelected ? activeBang : null,
searchTextController: searchTextController,
displayMenu: displayMenu,
),
],
),
),
],
);
}
}
/// The actual chip list with selection handling.
class _BangChipsList extends HookConsumerWidget {
/// The domain for this list's selection provider.
/// null = global provider, non-null = domain-scoped provider.
final String? domain;
/// The site domain for mutual exclusion.
/// When selecting, the OTHER provider (site vs global) will be cleared.
/// null = no mutual exclusion (single mode without tabs).
final String? siteDomain;
final List<BangData> bangs;
final BangData? selectedBang;
final TextEditingController searchTextController;
final bool displayMenu;
const _BangChipsList({
required this.domain,
required this.siteDomain,
required this.bangs,
required this.selectedBang,
required this.searchTextController,
required this.displayMenu,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasContent = selectedBang != null || bangs.isNotEmpty;
return SizedBox(
height: 48,
child: Row(
children: [
if (hasContent)
Expanded(
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) =>
UrlIcon([bang.getDefaultUrl()], iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
itemTooltip: (bang) => bang.trigger,
availableItems: bangs,
selectedItem: selectedBang,
onSelected: (bang) => _handleSelection(context, ref, bang),
onDeleted: (bang) => _handleDeletion(context, ref, bang),
),
)
else if (displayMenu)
Expanded(
child: Text(
"Press '>' to search Bangs.",
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
),
),
)
else
const Spacer(),
if (displayMenu)
IconButton(
onPressed: () => _openBangSearch(context, ref),
icon: const Icon(Icons.chevron_right),
),
],
),
);
}
void _handleSelection(BuildContext context, WidgetRef ref, BangData bang) {
// Text clearing logic:
// - Site tab (domain != null): only clear if text is a valid URL
// - All tab (domain == null): always clear (same as original BangChips)
if (domain == null) {
// All tab / new tab mode - always clear
searchTextController.clear();
} else {
// Site tab - only clear if text is a valid URL
final hasSupportedScheme = uri_parser
.tryParseUrl(searchTextController.text)
.mapNotNull((uri) => uri.hasSupportedScheme) ??
false;
if (hasSupportedScheme) {
searchTextController.clear();
}
}
// Clear the OTHER provider for mutual exclusion
if (siteDomain != null) {
if (domain == null) {
// We're in All tab (global), clear Site selection
ref
.read(selectedBangTriggerProvider(domain: siteDomain).notifier)
.clearTrigger();
} else {
// We're in Site tab, clear All (global) selection
ref.read(selectedBangTriggerProvider().notifier).clearTrigger();
}
}
// Set selection using this list's provider scope
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.setTrigger(bang.toKey());
}
Future<void> _handleDeletion(
BuildContext context,
WidgetRef ref,
BangData bang,
) async {
final currentSelection =
ref.read(selectedBangTriggerProvider(domain: domain));
if (currentSelection == bang.toKey()) {
// Clear selection if the deleted bang is currently selected
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.clearTrigger();
} else {
// Show reset frequency dialog for non-selected bangs
final dialogResult = await showResetBangDialog(
context,
triggerName: bang.trigger,
);
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
}
}
Future<void> _openBangSearch(BuildContext context, WidgetRef ref) async {
final searchText = searchTextController.text.trim();
final trigger = await BangSearchRoute(
searchText: searchText.isEmpty
? BangSearchRoute.emptySearchText
: searchText,
).push<BangKey?>(context);
if (trigger != null) {
// Clear the OTHER provider for mutual exclusion
if (siteDomain != null) {
if (domain == null) {
ref
.read(selectedBangTriggerProvider(domain: siteDomain).notifier)
.clearTrigger();
} else {
ref.read(selectedBangTriggerProvider().notifier).clearTrigger();
}
}
ref
.read(selectedBangTriggerProvider(domain: domain).notifier)
.setTrigger(trigger);
}
}
}