introduce smart bang selector
This commit is contained in:
@@ -24,11 +24,8 @@ 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/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';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.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/url_icon.dart';
|
|
||||||
import 'package:weblibre/utils/text_field_line_count.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;
|
||||||
|
|
||||||
@@ -152,28 +147,30 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() {
|
Future<void> measureHeightWithRetry() async {
|
||||||
void measureHeight() {
|
const delays = [25, 50, 75, 100];
|
||||||
|
|
||||||
|
for (final delay in delays) {
|
||||||
|
await Future.delayed(Duration(milliseconds: delay));
|
||||||
final measuredHeight = getTextFieldHeight(textFieldKey);
|
final measuredHeight = getTextFieldHeight(textFieldKey);
|
||||||
|
|
||||||
if (measuredHeight != null) {
|
if (measuredHeight != null) {
|
||||||
preferredHeight.value = measuredHeight;
|
preferredHeight.value = measuredHeight;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
measureHeight();
|
measureHeightWithRetry(); // ignore: discarded_futures
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [textFieldKey, isEditMode]);
|
}, [textFieldKey, isEditMode]);
|
||||||
|
|
||||||
useOnListenableChange(isEditMode ? searchTextController : null, () {
|
useOnListenableChange(isEditMode ? searchTextController : null, () {
|
||||||
final measuredHeight = getTextFieldHeight(textFieldKey);
|
measureHeightWithRetry(); // ignore: discarded_futures
|
||||||
|
|
||||||
if (measuredHeight != null && preferredHeight.value != measuredHeight) {
|
|
||||||
preferredHeight.value = measuredHeight;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((previous, current) {
|
useOnAppLifecycleStateChange((previous, current) {
|
||||||
@@ -195,22 +192,25 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
defaultSearchBangDataProvider.select((value) => value.value),
|
defaultSearchBangDataProvider.select((value) => value.value),
|
||||||
);
|
);
|
||||||
|
|
||||||
final selectedBang = useState<BangData?>(null);
|
// Watch both selection providers - only one should be set at a time
|
||||||
final activeBang = selectedBang.value ?? defaultSearchBang;
|
// due to mutual exclusion in SmartBangSelector
|
||||||
final showBangIcon = useState(false);
|
final siteSelectedBang = isEditMode
|
||||||
|
? ref.watch(selectedBangDataProvider(domain: existingTabState.url.host))
|
||||||
|
: null;
|
||||||
|
final globalSelectedBang = ref.watch(selectedBangDataProvider());
|
||||||
|
|
||||||
ref.listen(
|
// The active selection is whichever one is set (site takes priority if both somehow set)
|
||||||
selectedBangDataProvider(
|
final selectedBang = siteSelectedBang ?? globalSelectedBang;
|
||||||
domain: isEditMode ? existingTabState.url.host : null,
|
final activeBang = selectedBang ?? defaultSearchBang;
|
||||||
),
|
final showBangIcon = selectedBang != null;
|
||||||
(previous, next) {
|
|
||||||
if (previous != next) {
|
|
||||||
showBangIcon.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedBang.value = next;
|
useEffect(() {
|
||||||
},
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
);
|
measureHeightWithRetry(); // ignore: discarded_futures
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [showBangIcon]);
|
||||||
|
|
||||||
Future<void> submitSearch(String query) async {
|
Future<void> submitSearch(String query) async {
|
||||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||||
@@ -263,12 +263,9 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
floating: true,
|
floating: true,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
|
toolbarHeight: isEditMode ? 0 : kToolbarHeight,
|
||||||
title: isEditMode
|
title: isEditMode
|
||||||
? _SiteBangsSelector(
|
? null
|
||||||
tabId: tabId!,
|
|
||||||
domain: existingTabState.url.host,
|
|
||||||
searchTextController: searchTextController,
|
|
||||||
)
|
|
||||||
: Align(
|
: Align(
|
||||||
child: Focus(
|
child: Focus(
|
||||||
canRequestFocus: false,
|
canRequestFocus: false,
|
||||||
@@ -325,7 +322,7 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16.0),
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
child: SearchField(
|
child: SearchField(
|
||||||
textFieldKey: textFieldKey,
|
textFieldKey: textFieldKey,
|
||||||
showBangIcon: showBangIcon.value,
|
showBangIcon: showBangIcon,
|
||||||
textEditingController: searchTextController,
|
textEditingController: searchTextController,
|
||||||
focusNode: searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
maxLines: isEditMode ? 3 : 1,
|
maxLines: isEditMode ? 3 : 1,
|
||||||
@@ -342,14 +339,20 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (newUrl == null) {
|
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 =
|
final bang =
|
||||||
ref.read(
|
siteBang ??
|
||||||
selectedBangDataProvider(
|
globalBang ??
|
||||||
domain: isEditMode
|
|
||||||
? existingTabState.url.host
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
) ??
|
|
||||||
await ref.read(
|
await ref.read(
|
||||||
defaultSearchBangDataProvider.future,
|
defaultSearchBangDataProvider.future,
|
||||||
);
|
);
|
||||||
@@ -418,6 +421,7 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
searchTextController: searchTextController,
|
searchTextController: searchTextController,
|
||||||
activeBang: activeBang,
|
activeBang: activeBang,
|
||||||
submitSearch: submitSearch,
|
submitSearch: submitSearch,
|
||||||
|
domain: isEditMode ? existingTabState.url.host : null,
|
||||||
),
|
),
|
||||||
TabSearch(searchTextListenable: sampledSearchText),
|
TabSearch(searchTextListenable: sampledSearchText),
|
||||||
FeedSearch(searchTextNotifier: 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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-32
@@ -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/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/repositories/data.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/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/smart_bang_selector.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/bang_chips.dart';
|
|
||||||
|
|
||||||
class FullSearchTermSuggestions extends HookConsumerWidget {
|
class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||||
final TextEditingController searchTextController;
|
final TextEditingController searchTextController;
|
||||||
final Future<void> Function(String query) submitSearch;
|
final Future<void> Function(String query) submitSearch;
|
||||||
final BangData? activeBang;
|
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({
|
const FullSearchTermSuggestions({
|
||||||
required this.searchTextController,
|
required this.searchTextController,
|
||||||
required this.submitSearch,
|
required this.submitSearch,
|
||||||
required this.activeBang,
|
required this.activeBang,
|
||||||
|
this.domain,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,35 +155,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
|||||||
'Search Provider',
|
'Search Provider',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
BangChips(
|
SmartBangSelector(
|
||||||
key: ValueKey(activeBang),
|
domain: domain,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
searchTextController: searchTextController,
|
searchTextController: searchTextController,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+370
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user