diff --git a/app/lib/features/bangs/data/database/daos/bang.dart b/app/lib/features/bangs/data/database/daos/bang.dart index 8de9504c..b0cb7a06 100644 --- a/app/lib/features/bangs/data/database/daos/bang.dart +++ b/app/lib/features/bangs/data/database/daos/bang.dart @@ -28,11 +28,26 @@ class BangDao extends DatabaseAccessor with _$BangDaoMixin { return select(db.bangDataView)..where((t) => t.trigger.equals(trigger)); } - Selectable getBangDataList({Iterable? groups}) { + Selectable getBangDataList({ + Iterable? groups, + String? domain, + String? category, + String? subCategory, + }) { final selectable = select(db.bangDataView); if (groups != null) { selectable.where((t) => t.group.isInValues(groups)); } + if (domain != null) { + selectable.where((t) => t.domain.equals(domain)); + } + if (category != null) { + selectable.where((t) => t.category.equals(category)); + + if (subCategory != null) { + selectable.where((t) => t.subCategory.equals(subCategory)); + } + } selectable.orderBy([(t) => OrderingTerm.asc(t.websiteName)]); diff --git a/app/lib/features/bangs/domain/providers.dart b/app/lib/features/bangs/domain/providers.dart index 6c4330e8..247e3eb3 100644 --- a/app/lib/features/bangs/domain/providers.dart +++ b/app/lib/features/bangs/domain/providers.dart @@ -19,9 +19,26 @@ Stream kagiSearchBangData(KagiSearchBangDataRef ref) { } @Riverpod() -Stream> bangDataList(BangDataListRef ref) { +Stream>> bangCategories(BangCategoriesRef ref) { final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.watchBangs(); + return repository.watchCategories(); +} + +@Riverpod() +Stream> bangDataList( + BangDataListRef ref, { + ({ + Iterable? groups, + String? domain, + ({String category, String? subCategory})? categoryFilter, + })? filter, +}) { + final repository = ref.watch(bangDataRepositoryProvider.notifier); + return repository.watchBangs( + groups: filter?.groups, + domain: filter?.domain, + categoryFilter: filter?.categoryFilter, + ); } @Riverpod() diff --git a/app/lib/features/bangs/domain/repositories/bang_data.dart b/app/lib/features/bangs/domain/repositories/bang_data.dart index 7c61605c..4ce51906 100644 --- a/app/lib/features/bangs/domain/repositories/bang_data.dart +++ b/app/lib/features/bangs/domain/repositories/bang_data.dart @@ -31,8 +31,19 @@ class BangDataRepository extends _$BangDataRepository { return _db.bangDao.getBangCount(groups: [group]).watchSingle(); } - Stream> watchBangs({Iterable? groups}) { - return _db.bangDao.getBangDataList(groups: groups).watch(); + Stream> watchBangs({ + Iterable? groups, + String? domain, + ({String category, String? subCategory})? categoryFilter, + }) { + return _db.bangDao + .getBangDataList( + groups: groups, + domain: domain, + category: categoryFilter?.category, + subCategory: categoryFilter?.subCategory, + ) + .watch(); } Stream> watchFrequentBangs({Iterable? groups}) { diff --git a/app/lib/features/bangs/presentation/screens/search.dart b/app/lib/features/bangs/presentation/screens/search.dart index 9bf58d46..b999867b 100644 --- a/app/lib/features/bangs/presentation/screens/search.dart +++ b/app/lib/features/bangs/presentation/screens/search.dart @@ -8,6 +8,7 @@ import 'package:bang_navigator/features/search_browser/domain/entities/sheet.dar import 'package:bang_navigator/features/search_browser/domain/providers.dart'; import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/presentation/hooks/listenable_callback.dart'; +import 'package:bang_navigator/presentation/widgets/failure_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -66,14 +67,12 @@ class BangSearchScreen extends HookConsumerWidget { bang, onTap: () { ref - .read(selectedBangTriggerProvider.notifier) + .read(selectedBangTriggerProvider().notifier) .setTrigger(bang.trigger); if (ref.read(bottomSheetProvider) is! CreateTab) { ref.read(bottomSheetProvider.notifier).show( - CreateTab( - preferredTool: KagiTool.search, - ), + CreateTab(preferredTool: KagiTool.search), ); } @@ -82,8 +81,13 @@ class BangSearchScreen extends HookConsumerWidget { ); }, ), - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => SizedBox.shrink(), + error: (error, stackTrace) => Center( + child: FailureWidget( + title: 'Bang Search failed', + exception: error, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), ), ); } diff --git a/app/lib/features/bangs/presentation/widgets/bang_chips.dart b/app/lib/features/bangs/presentation/widgets/bang_chips.dart new file mode 100644 index 00000000..a030982b --- /dev/null +++ b/app/lib/features/bangs/presentation/widgets/bang_chips.dart @@ -0,0 +1,69 @@ +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; +import 'package:flutter/material.dart'; + +class BangChips extends StatelessWidget { + final List availableBangs; + final BangData? selectedBang; + final int maxBangCount; + + final void Function(String trigger)? onSelected; + final void Function(String trigger)? onDeleted; + + const BangChips({ + required this.availableBangs, + this.selectedBang, + this.maxBangCount = 25, + this.onSelected, + this.onDeleted, + super.key, + }); + + @override + Widget build(BuildContext context) { + var bangs = availableBangs.take(maxBangCount).toList(); + if (selectedBang != null) { + final selectedIndex = + bangs.indexWhere((bang) => bang.trigger == selectedBang?.trigger); + if (selectedIndex < 0) { + bangs = [ + selectedBang!, + ...bangs, + ]; + } else { + bangs = [ + bangs.removeAt(selectedIndex), + ...bangs, + ]; + } + } + + return ListView.builder( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: bangs.length, + itemBuilder: (context, index) { + final bang = bangs[index]; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilterChip( + selected: bang.trigger == selectedBang?.trigger, + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected?.call(bang.trigger); + } else { + onDeleted?.call(bang.trigger); + } + }, + onDeleted: () { + onDeleted?.call(bang.trigger); + }, + avatar: BangIcon(bang, iconSize: 20), + label: Text(bang.websiteName), + ), + ); + }, + ); + } +} diff --git a/app/lib/features/bangs/presentation/widgets/search_field.dart b/app/lib/features/bangs/presentation/widgets/search_field.dart new file mode 100644 index 00000000..3e8a075d --- /dev/null +++ b/app/lib/features/bangs/presentation/widgets/search_field.dart @@ -0,0 +1,207 @@ +// The default Material-style Autocomplete options. +import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; +import 'package:bang_navigator/features/kagi/domain/repositories/autosuggest.dart'; +import 'package:bang_navigator/features/search_browser/presentation/widgets/speech_to_text_button.dart'; +import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; +import 'package:bang_navigator/presentation/widgets/autocomplete.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'; + +class SearchField extends HookConsumerWidget { + static const defaultMaxOptionsHeight = 158.0; + + final TextEditingController textController; + final BangData? activeBang; + final OptionsViewOpenDirection openDirection; + final double maxOptionsHeight; + final void Function(String)? onFieldSubmitted; + + const SearchField({ + required this.textController, + required this.activeBang, + this.openDirection = OptionsViewOpenDirection.up, + this.maxOptionsHeight = defaultMaxOptionsHeight, + this.onFieldSubmitted, + super.key, + }); + @override + Widget build(BuildContext context, WidgetRef ref) { + final incognitoEnabled = ref.watch( + settingsRepositoryProvider + .select((value) => value.valueOrNull?.incognitoMode ?? false), + ); + + final optionsStream = ref.watch(autosuggestRepositoryProvider); + + final focusNode = useFocusNode(); + + final quickAnswer = useListenableSelector( + textController, + () => textController.text.endsWith('?'), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExternalResultsAutocomplete( + textEditingController: textController, + optionsStream: optionsStream, + focusNode: focusNode, + optionsViewOpenDirection: openDirection, + displayStringForOption: + // ignore: avoid_redundant_argument_values + RawAutocomplete.defaultStringForOption, + optionsViewBuilder: (context, onSelected, options) { + return _AutocompleteOptions( + //Must match RawAutocomplete parent + displayStringForOption: RawAutocomplete.defaultStringForOption, + onSelected: onSelected, + options: options, + //Must match RawAutocomplete parent + openDirection: openDirection, + maxOptionsHeight: maxOptionsHeight, + ); + }, + onTextChanged: (textEditingValue) { + ref + .read(autosuggestRepositoryProvider.notifier) + .addQuery(textEditingValue.text); + }, + fieldViewBuilder: ( + context, + textEditingController, + focusNode, + onFieldSubmitted, + ) { + return TextFormField( + controller: textEditingController, + enableIMEPersonalizedLearning: !incognitoEnabled, + focusNode: focusNode, + decoration: InputDecoration( + prefixIcon: (activeBang != null) + ? Padding( + padding: const EdgeInsetsDirectional.all(12.0), + child: BangIcon(activeBang!, iconSize: 24.0), + ) + : null, + label: const Text('Query'), + hintText: 'Ask anything...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: SpeechToTextButton( + onTextReceived: (data) { + textEditingController.text = data.toString(); + }, + ), + ), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + if (value?.isEmpty ?? true) { + return ''; + } + + return null; + }, + onTapOutside: (event) { + focusNode.unfocus(); + }, + onFieldSubmitted: this.onFieldSubmitted, + ); + }, + ), + if (activeBang?.domain.endsWith('kagi.com') == true) + SwitchListTile( + value: quickAnswer, + onChanged: (_) { + if (textController.text.endsWith('?')) { + textController.text = textController.text + .substring(0, textController.text.length - 1); + } else { + textController.text = '${textController.text}?'; + } + }, + contentPadding: EdgeInsets.zero, + title: const Text('Quick Answer'), + secondary: const Icon(MdiIcons.lightningBolt), + ), + ], + ); + } +} + +class _AutocompleteOptions extends StatelessWidget { + const _AutocompleteOptions({ + super.key, + required this.displayStringForOption, + required this.onSelected, + required this.openDirection, + required this.options, + required this.maxOptionsHeight, + }); + + final AutocompleteOptionToString displayStringForOption; + + final AutocompleteOnSelected onSelected; + final OptionsViewOpenDirection openDirection; + + final Iterable options; + final double maxOptionsHeight; + + @override + Widget build(BuildContext context) { + final AlignmentDirectional optionsAlignment = switch (openDirection) { + OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart, + OptionsViewOpenDirection.down => AlignmentDirectional.topStart, + }; + return Align( + alignment: optionsAlignment, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxOptionsHeight), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + reverse: switch (openDirection) { + OptionsViewOpenDirection.up => true, + OptionsViewOpenDirection.down => false, + }, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final T option = options.elementAt(index); + return InkWell( + onTap: () { + onSelected(option); + }, + child: Builder( + builder: (BuildContext context) { + final bool highlight = + AutocompleteHighlightedOption.of(context) == index; + // if (highlight) { + // SchedulerBinding.instance.addPostFrameCallback( + // (Duration timeStamp) async { + // await Scrollable.ensureVisible( + // context, + // alignment: 0.5, + // ); + // }, + // debugLabel: 'AutocompleteOptions.ensureVisible', + // ); + // } + return Container( + color: highlight ? Theme.of(context).focusColor : null, + padding: const EdgeInsets.all(16.0), + child: Text(displayStringForOption(option)), + ); + }, + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart new file mode 100644 index 00000000..bbcb8943 --- /dev/null +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -0,0 +1,103 @@ +import 'package:bang_navigator/features/bangs/domain/providers.dart'; +import 'package:bang_navigator/features/bangs/domain/repositories/bang_data.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_chips.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/search_field.dart'; +import 'package:bang_navigator/features/search_browser/domain/providers.dart'; +import 'package:bang_navigator/features/web_view/presentation/controllers/switch_new_tab.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class SiteSearch extends HookConsumerWidget { + final String domain; + + const SiteSearch({required this.domain, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final availableBangsAsync = ref.watch( + bangDataListProvider( + filter: (domain: domain, groups: null, categoryFilter: null), + ), + ); + final availableBangCount = availableBangsAsync.valueOrNull?.length ?? 0; + + final selectedBang = ref.watch( + selectedBangDataProvider(domain: domain) + .select((value) => value.valueOrNull), + ); + + final activeBang = + selectedBang ?? availableBangsAsync.valueOrNull?.firstOrNull; + + final textController = useTextEditingController(); + + Future submitSearch() async { + if (activeBang != null && (formKey.currentState?.validate() == true)) { + await ref + .read(bangDataRepositoryProvider.notifier) + .increaseFrequency(activeBang.trigger); + + await ref + .read(switchNewTabControllerProvider.notifier) + .add(activeBang.getUrl(textController.text)); + + ref.watch(overlayDialogProvider.notifier).dismiss(); + } + } + + return availableBangsAsync.when( + data: (availableBangs) { + return Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (availableBangCount > 1) + SizedBox( + height: 48, + width: double.maxFinite, + child: BangChips( + availableBangs: availableBangs, + selectedBang: selectedBang, + onSelected: (trigger) { + ref + .read( + selectedBangTriggerProvider(domain: domain) + .notifier, + ) + .setTrigger(trigger); + }, + onDeleted: (trigger) { + if (ref.read( + selectedBangTriggerProvider(domain: domain), + ) == + trigger) { + ref + .read( + selectedBangTriggerProvider(domain: domain) + .notifier, + ) + .clearTrigger(); + } + }, + ), + ), + SearchField( + textController: textController, + activeBang: activeBang, + onFieldSubmitted: (_) async { + await submitSearch(); + }, + ), + ], + ), + ); + }, + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox(height: 48), + ); + } +} diff --git a/app/lib/features/search_browser/presentation/widgets/bang_chips.dart b/app/lib/features/search_browser/presentation/widgets/bang_chips.dart deleted file mode 100644 index 94ae2f6b..00000000 --- a/app/lib/features/search_browser/presentation/widgets/bang_chips.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:bang_navigator/core/routing/routes.dart'; -import 'package:bang_navigator/features/bangs/data/models/bang_data.dart'; -import 'package:bang_navigator/features/bangs/domain/providers.dart'; -import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class BangChips extends HookConsumerWidget { - static const _maxBangCount = 25; - - final BangData? selectedBang; - final void Function(String trigger)? onSelected; - final void Function(String trigger)? onDeleted; - - const BangChips({ - this.selectedBang, - this.onSelected, - this.onDeleted, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final frequentBangsAsync = ref.watch(frequentBangDataListProvider); - - return frequentBangsAsync.when( - data: (bangs) { - bangs = bangs.take(_maxBangCount).toList(); - if (selectedBang != null) { - final selectedIndex = - bangs.indexWhere((bang) => bang.trigger == selectedBang?.trigger); - if (selectedIndex < 0) { - bangs = [ - selectedBang!, - ...bangs, - ]; - } else { - bangs = [ - bangs.removeAt(selectedIndex), - ...bangs, - ]; - } - } - - return SizedBox( - height: 48, - child: Row( - children: [ - Expanded( - child: ListView.builder( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - itemCount: bangs.length, - itemBuilder: (context, index) { - final bang = bangs[index]; - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: FilterChip( - selected: bang.trigger == selectedBang?.trigger, - showCheckmark: false, - onSelected: (value) { - if (value) { - onSelected?.call(bang.trigger); - } else { - onDeleted?.call(bang.trigger); - } - }, - onDeleted: () { - onDeleted?.call(bang.trigger); - }, - avatar: BangIcon(bang, iconSize: 20), - label: Text(bang.websiteName), - ), - ); - }, - ), - ), - IconButton( - onPressed: () async { - await context.push(BangSearchRoute().location); - }, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - ); - }, - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => SizedBox.shrink(), - ); - } -} diff --git a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart b/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart index cf99fe07..2028ae79 100644 --- a/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart +++ b/app/lib/features/search_browser/presentation/widgets/tabs/search_tab.dart @@ -1,98 +1,18 @@ +import 'package:bang_navigator/core/routing/routes.dart'; import 'package:bang_navigator/features/bangs/domain/providers.dart'; import 'package:bang_navigator/features/bangs/domain/repositories/bang_data.dart'; -import 'package:bang_navigator/features/bangs/presentation/widgets/bang_icon.dart'; -import 'package:bang_navigator/features/kagi/domain/repositories/autosuggest.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/bang_chips.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/search_field.dart'; import 'package:bang_navigator/features/search_browser/domain/providers.dart'; -import 'package:bang_navigator/features/search_browser/presentation/widgets/bang_chips.dart'; import 'package:bang_navigator/features/search_browser/presentation/widgets/sheets/shared_content_sheet.dart'; -import 'package:bang_navigator/features/search_browser/presentation/widgets/speech_to_text_button.dart'; -import 'package:bang_navigator/features/settings/data/repositories/settings_repository.dart'; import 'package:bang_navigator/features/share_intent/domain/entities/shared_content.dart'; -import 'package:bang_navigator/presentation/widgets/autocomplete.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:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -// The default Material-style Autocomplete options. -class _AutocompleteOptions extends StatelessWidget { - const _AutocompleteOptions({ - super.key, - required this.displayStringForOption, - required this.onSelected, - required this.openDirection, - required this.options, - required this.maxOptionsHeight, - }); - - final AutocompleteOptionToString displayStringForOption; - - final AutocompleteOnSelected onSelected; - final OptionsViewOpenDirection openDirection; - - final Iterable options; - final double maxOptionsHeight; - - @override - Widget build(BuildContext context) { - final AlignmentDirectional optionsAlignment = switch (openDirection) { - OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart, - OptionsViewOpenDirection.down => AlignmentDirectional.topStart, - }; - return Align( - alignment: optionsAlignment, - child: Material( - elevation: 4.0, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxOptionsHeight), - child: ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - reverse: switch (openDirection) { - OptionsViewOpenDirection.up => true, - OptionsViewOpenDirection.down => false, - }, - itemCount: options.length, - itemBuilder: (BuildContext context, int index) { - final T option = options.elementAt(index); - return InkWell( - onTap: () { - onSelected(option); - }, - child: Builder( - builder: (BuildContext context) { - final bool highlight = - AutocompleteHighlightedOption.of(context) == index; - // if (highlight) { - // SchedulerBinding.instance.addPostFrameCallback( - // (Duration timeStamp) async { - // await Scrollable.ensureVisible( - // context, - // alignment: 0.5, - // ); - // }, - // debugLabel: 'AutocompleteOptions.ensureVisible', - // ); - // } - return Container( - color: highlight ? Theme.of(context).focusColor : null, - padding: const EdgeInsets.all(16.0), - child: Text(displayStringForOption(option)), - ); - }, - ), - ); - }, - ), - ), - ), - ); - } -} - class SearchTab extends HookConsumerWidget { - static const _maxOptionsHeight = 158.0; - final SharedContent? sharedContent; final OnSubmitUri onSubmit; @@ -104,29 +24,29 @@ class SearchTab extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final incognitoEnabled = ref.watch( - settingsRepositoryProvider - .select((value) => value.valueOrNull?.incognitoMode ?? false), - ); - useAutomaticKeepAlive(); final formKey = useMemoized(() => GlobalKey()); - final focusNode = useFocusNode(); - final selectedBangAsync = ref.watch(selectedBangDataProvider); - final defaultSearchBangAsync = ref.watch(kagiSearchBangDataProvider); + final selectedBang = ref + .watch(selectedBangDataProvider().select((value) => value.valueOrNull)); + final defaultSearchBang = ref + .watch(kagiSearchBangDataProvider.select((value) => value.valueOrNull)); - final activeBang = - selectedBangAsync.valueOrNull ?? defaultSearchBangAsync.valueOrNull; + final activeBang = selectedBang ?? defaultSearchBang; final textController = useTextEditingController(text: sharedContent?.toString()); - final quickAnswer = useListenableSelector( - textController, - () => textController.text.endsWith('?'), - ); + Future submitSearch() async { + if (activeBang != null && (formKey.currentState?.validate() == true)) { + await ref + .read(bangDataRepositoryProvider.notifier) + .increaseFrequency(activeBang.trigger); + + onSubmit(activeBang.getUrl(textController.text)); + } + } return Form( key: formKey, @@ -134,37 +54,75 @@ class SearchTab extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 4), - selectedBangAsync.when( - skipLoadingOnReload: true, - data: (selectedBang) { - return BangChips( - selectedBang: selectedBang, - onSelected: (trigger) { - ref - .read(selectedBangTriggerProvider.notifier) - .setTrigger(trigger); - }, - onDeleted: (trigger) { - if (ref.read(selectedBangTriggerProvider) == trigger) { - ref - .read(selectedBangTriggerProvider.notifier) - .clearTrigger(); - } - }, + Consumer( + builder: (context, ref, child) { + final frequentBangsAsync = + ref.watch(frequentBangDataListProvider); + + return frequentBangsAsync.when( + data: (availableBangs) => SizedBox( + height: 48, + child: Row( + children: [ + if (selectedBang != null || availableBangs.isNotEmpty) + Expanded( + child: BangChips( + availableBangs: availableBangs, + selectedBang: selectedBang, + onSelected: (trigger) { + ref + .read(selectedBangTriggerProvider().notifier) + .setTrigger(trigger); + }, + onDeleted: (trigger) { + if (ref.read(selectedBangTriggerProvider()) == + trigger) { + ref + .read( + selectedBangTriggerProvider().notifier, + ) + .clearTrigger(); + } + }, + ), + ) + else + Expanded( + child: Text( + "Press '>' to search Bangs.", + style: TextStyle( + color: Theme.of(context).hintColor, + fontStyle: FontStyle.italic, + ), + ), + ), + IconButton( + onPressed: () async { + await context.push(BangSearchRoute().location); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox( + height: 48, + width: double.infinity, + ), ); }, - error: (error, stackTrace) => SizedBox.shrink(), - loading: () => SizedBox.shrink(), ), Consumer( builder: (context, ref, child) { - final optionsStream = ref.watch(autosuggestRepositoryProvider); + const maxOptionsHeight = SearchField.defaultMaxOptionsHeight; + final openDirection = ref.watch( bottomSheetExtendProvider.select((value) { final extend = value.valueOrNull; if (extend == null || (MediaQuery.of(context).size.height * (1 - extend)) > - _maxOptionsHeight) { + maxOptionsHeight) { return OptionsViewOpenDirection.up; } else { return OptionsViewOpenDirection.down; @@ -172,113 +130,23 @@ class SearchTab extends HookConsumerWidget { }), ); - return ExternalResultsAutocomplete( - textEditingController: textController, - optionsStream: optionsStream, - focusNode: focusNode, - optionsViewOpenDirection: openDirection, - displayStringForOption: - // ignore: avoid_redundant_argument_values - RawAutocomplete.defaultStringForOption, - optionsViewBuilder: (context, onSelected, options) { - return _AutocompleteOptions( - //Must match RawAutocomplete parent - displayStringForOption: - RawAutocomplete.defaultStringForOption, - onSelected: onSelected, - options: options, - //Must match RawAutocomplete parent - openDirection: openDirection, - maxOptionsHeight: _maxOptionsHeight, - ); - }, - onTextChanged: (textEditingValue) { - ref - .read(autosuggestRepositoryProvider.notifier) - .addQuery(textEditingValue.text); - }, - fieldViewBuilder: ( - context, - textEditingController, - focusNode, - onFieldSubmitted, - ) { - return TextFormField( - controller: textEditingController, - enableIMEPersonalizedLearning: !incognitoEnabled, - focusNode: focusNode, - decoration: InputDecoration( - prefixIcon: (activeBang != null) - ? Padding( - padding: const EdgeInsetsDirectional.all(12.0), - child: BangIcon(activeBang, iconSize: 24.0), - ) - : null, - label: const Text('Query'), - hintText: 'Ask anything...', - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: SpeechToTextButton( - onTextReceived: (data) { - textEditingController.text = data.toString(); - }, - ), - ), - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - if (value?.isEmpty ?? true) { - return ''; - } - - return null; - }, - onTapOutside: (event) { - focusNode.unfocus(); - }, - onFieldSubmitted: (_) async { - if (activeBang != null && - (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); - - onSubmit(activeBang.getUrl(textController.text)); - } - }, - ); + return SearchField( + textController: textController, + activeBang: activeBang, + openDirection: openDirection, + onFieldSubmitted: (_) async { + await submitSearch(); }, ); }, ), - SwitchListTile( - value: quickAnswer, - onChanged: (_) { - if (textController.text.endsWith('?')) { - textController.text = textController.text - .substring(0, textController.text.length - 1); - } else { - textController.text = '${textController.text}?'; - } - }, - contentPadding: EdgeInsets.zero, - title: const Text('Quick Answer'), - secondary: const Icon(MdiIcons.lightningBolt), - ), const SizedBox( height: 12, ), SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: () async { - if (activeBang != null && - (formKey.currentState?.validate() == true)) { - await ref - .read(bangDataRepositoryProvider.notifier) - .increaseFrequency(activeBang.trigger); - - onSubmit(activeBang.getUrl(textController.text)); - } - }, + onPressed: submitSearch, label: const Text('Search'), icon: const Icon(MdiIcons.invoiceTextSend), ), diff --git a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart index 98b730d9..59753817 100644 --- a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart +++ b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart @@ -1,4 +1,5 @@ import 'package:bang_navigator/domain/entities/web_page_info.dart'; +import 'package:bang_navigator/features/bangs/presentation/widgets/site_search.dart'; import 'package:bang_navigator/features/search_browser/domain/entities/modes.dart'; import 'package:bang_navigator/features/search_browser/utils/url_builder.dart' as uri_builder; @@ -133,6 +134,14 @@ class WebPageDialog extends HookConsumerWidget { ), ), ), + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SiteSearch( + domain: page.url.host, + ), + ), + const Divider(), ListTile( leading: const Icon(MdiIcons.contentCopy), title: const Text('Copy address'),