refactored bang search, added search with bang to title

This commit is contained in:
Fabian Freund
2024-06-16 06:30:22 +02:00
parent e2b2a46733
commit 2993ca59ea
10 changed files with 530 additions and 320 deletions
@@ -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()),
),
);
}
@@ -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<BangData> 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),
),
);
},
);
}
}
@@ -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<String>(
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<T extends Object> extends StatelessWidget {
const _AutocompleteOptions({
super.key,
required this.displayStringForOption,
required this.onSelected,
required this.openDirection,
required this.options,
required this.maxOptionsHeight,
});
final AutocompleteOptionToString<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected;
final OptionsViewOpenDirection openDirection;
final Iterable<T> 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)),
);
},
),
);
},
),
),
),
);
}
}
@@ -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<FormState>());
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<void> 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),
);
}
}