refactored bang search, added search with bang to title
This commit is contained in:
@@ -28,11 +28,26 @@ class BangDao extends DatabaseAccessor<BangDatabase> with _$BangDaoMixin {
|
|||||||
return select(db.bangDataView)..where((t) => t.trigger.equals(trigger));
|
return select(db.bangDataView)..where((t) => t.trigger.equals(trigger));
|
||||||
}
|
}
|
||||||
|
|
||||||
Selectable<BangData> getBangDataList({Iterable<BangGroup>? groups}) {
|
Selectable<BangData> getBangDataList({
|
||||||
|
Iterable<BangGroup>? groups,
|
||||||
|
String? domain,
|
||||||
|
String? category,
|
||||||
|
String? subCategory,
|
||||||
|
}) {
|
||||||
final selectable = select(db.bangDataView);
|
final selectable = select(db.bangDataView);
|
||||||
if (groups != null) {
|
if (groups != null) {
|
||||||
selectable.where((t) => t.group.isInValues(groups));
|
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)]);
|
selectable.orderBy([(t) => OrderingTerm.asc(t.websiteName)]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,26 @@ Stream<BangData?> kagiSearchBangData(KagiSearchBangDataRef ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Riverpod()
|
@Riverpod()
|
||||||
Stream<List<BangData>> bangDataList(BangDataListRef ref) {
|
Stream<Map<String, List<String>>> bangCategories(BangCategoriesRef ref) {
|
||||||
final repository = ref.watch(bangDataRepositoryProvider.notifier);
|
final repository = ref.watch(bangDataRepositoryProvider.notifier);
|
||||||
return repository.watchBangs();
|
return repository.watchCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
Stream<List<BangData>> bangDataList(
|
||||||
|
BangDataListRef ref, {
|
||||||
|
({
|
||||||
|
Iterable<BangGroup>? 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()
|
@Riverpod()
|
||||||
|
|||||||
@@ -31,8 +31,19 @@ class BangDataRepository extends _$BangDataRepository {
|
|||||||
return _db.bangDao.getBangCount(groups: [group]).watchSingle();
|
return _db.bangDao.getBangCount(groups: [group]).watchSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<BangData>> watchBangs({Iterable<BangGroup>? groups}) {
|
Stream<List<BangData>> watchBangs({
|
||||||
return _db.bangDao.getBangDataList(groups: groups).watch();
|
Iterable<BangGroup>? groups,
|
||||||
|
String? domain,
|
||||||
|
({String category, String? subCategory})? categoryFilter,
|
||||||
|
}) {
|
||||||
|
return _db.bangDao
|
||||||
|
.getBangDataList(
|
||||||
|
groups: groups,
|
||||||
|
domain: domain,
|
||||||
|
category: categoryFilter?.category,
|
||||||
|
subCategory: categoryFilter?.subCategory,
|
||||||
|
)
|
||||||
|
.watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<BangData>> watchFrequentBangs({Iterable<BangGroup>? groups}) {
|
Stream<List<BangData>> watchFrequentBangs({Iterable<BangGroup>? groups}) {
|
||||||
|
|||||||
@@ -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/search_browser/domain/providers.dart';
|
||||||
import 'package:bang_navigator/features/settings/data/repositories/settings_repository.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/hooks/listenable_callback.dart';
|
||||||
|
import 'package:bang_navigator/presentation/widgets/failure_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -66,14 +67,12 @@ class BangSearchScreen extends HookConsumerWidget {
|
|||||||
bang,
|
bang,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref
|
ref
|
||||||
.read(selectedBangTriggerProvider.notifier)
|
.read(selectedBangTriggerProvider().notifier)
|
||||||
.setTrigger(bang.trigger);
|
.setTrigger(bang.trigger);
|
||||||
|
|
||||||
if (ref.read(bottomSheetProvider) is! CreateTab) {
|
if (ref.read(bottomSheetProvider) is! CreateTab) {
|
||||||
ref.read(bottomSheetProvider.notifier).show(
|
ref.read(bottomSheetProvider.notifier).show(
|
||||||
CreateTab(
|
CreateTab(preferredTool: KagiTool.search),
|
||||||
preferredTool: KagiTool.search,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +81,13 @@ class BangSearchScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
error: (error, stackTrace) => SizedBox.shrink(),
|
error: (error, stackTrace) => Center(
|
||||||
loading: () => SizedBox.shrink(),
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/providers.dart';
|
||||||
import 'package:bang_navigator/features/bangs/domain/repositories/bang_data.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/bangs/presentation/widgets/bang_chips.dart';
|
||||||
import 'package:bang_navigator/features/kagi/domain/repositories/autosuggest.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/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/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/features/share_intent/domain/entities/shared_content.dart';
|
||||||
import 'package:bang_navigator/presentation/widgets/autocomplete.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
// The default Material-style Autocomplete options.
|
|
||||||
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)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SearchTab extends HookConsumerWidget {
|
class SearchTab extends HookConsumerWidget {
|
||||||
static const _maxOptionsHeight = 158.0;
|
|
||||||
|
|
||||||
final SharedContent? sharedContent;
|
final SharedContent? sharedContent;
|
||||||
final OnSubmitUri onSubmit;
|
final OnSubmitUri onSubmit;
|
||||||
|
|
||||||
@@ -104,29 +24,29 @@ class SearchTab extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final incognitoEnabled = ref.watch(
|
|
||||||
settingsRepositoryProvider
|
|
||||||
.select((value) => value.valueOrNull?.incognitoMode ?? false),
|
|
||||||
);
|
|
||||||
|
|
||||||
useAutomaticKeepAlive();
|
useAutomaticKeepAlive();
|
||||||
|
|
||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||||
final focusNode = useFocusNode();
|
|
||||||
|
|
||||||
final selectedBangAsync = ref.watch(selectedBangDataProvider);
|
final selectedBang = ref
|
||||||
final defaultSearchBangAsync = ref.watch(kagiSearchBangDataProvider);
|
.watch(selectedBangDataProvider().select((value) => value.valueOrNull));
|
||||||
|
final defaultSearchBang = ref
|
||||||
|
.watch(kagiSearchBangDataProvider.select((value) => value.valueOrNull));
|
||||||
|
|
||||||
final activeBang =
|
final activeBang = selectedBang ?? defaultSearchBang;
|
||||||
selectedBangAsync.valueOrNull ?? defaultSearchBangAsync.valueOrNull;
|
|
||||||
|
|
||||||
final textController =
|
final textController =
|
||||||
useTextEditingController(text: sharedContent?.toString());
|
useTextEditingController(text: sharedContent?.toString());
|
||||||
|
|
||||||
final quickAnswer = useListenableSelector(
|
Future<void> submitSearch() async {
|
||||||
textController,
|
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||||
() => textController.text.endsWith('?'),
|
await ref
|
||||||
);
|
.read(bangDataRepositoryProvider.notifier)
|
||||||
|
.increaseFrequency(activeBang.trigger);
|
||||||
|
|
||||||
|
onSubmit(activeBang.getUrl(textController.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
@@ -134,37 +54,75 @@ class SearchTab extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
selectedBangAsync.when(
|
Consumer(
|
||||||
skipLoadingOnReload: true,
|
builder: (context, ref, child) {
|
||||||
data: (selectedBang) {
|
final frequentBangsAsync =
|
||||||
return BangChips(
|
ref.watch(frequentBangDataListProvider);
|
||||||
selectedBang: selectedBang,
|
|
||||||
onSelected: (trigger) {
|
return frequentBangsAsync.when(
|
||||||
ref
|
data: (availableBangs) => SizedBox(
|
||||||
.read(selectedBangTriggerProvider.notifier)
|
height: 48,
|
||||||
.setTrigger(trigger);
|
child: Row(
|
||||||
},
|
children: [
|
||||||
onDeleted: (trigger) {
|
if (selectedBang != null || availableBangs.isNotEmpty)
|
||||||
if (ref.read(selectedBangTriggerProvider) == trigger) {
|
Expanded(
|
||||||
ref
|
child: BangChips(
|
||||||
.read(selectedBangTriggerProvider.notifier)
|
availableBangs: availableBangs,
|
||||||
.clearTrigger();
|
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(
|
Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
final optionsStream = ref.watch(autosuggestRepositoryProvider);
|
const maxOptionsHeight = SearchField.defaultMaxOptionsHeight;
|
||||||
|
|
||||||
final openDirection = ref.watch(
|
final openDirection = ref.watch(
|
||||||
bottomSheetExtendProvider.select((value) {
|
bottomSheetExtendProvider.select((value) {
|
||||||
final extend = value.valueOrNull;
|
final extend = value.valueOrNull;
|
||||||
if (extend == null ||
|
if (extend == null ||
|
||||||
(MediaQuery.of(context).size.height * (1 - extend)) >
|
(MediaQuery.of(context).size.height * (1 - extend)) >
|
||||||
_maxOptionsHeight) {
|
maxOptionsHeight) {
|
||||||
return OptionsViewOpenDirection.up;
|
return OptionsViewOpenDirection.up;
|
||||||
} else {
|
} else {
|
||||||
return OptionsViewOpenDirection.down;
|
return OptionsViewOpenDirection.down;
|
||||||
@@ -172,113 +130,23 @@ class SearchTab extends HookConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ExternalResultsAutocomplete<String>(
|
return SearchField(
|
||||||
textEditingController: textController,
|
textController: textController,
|
||||||
optionsStream: optionsStream,
|
activeBang: activeBang,
|
||||||
focusNode: focusNode,
|
openDirection: openDirection,
|
||||||
optionsViewOpenDirection: openDirection,
|
onFieldSubmitted: (_) async {
|
||||||
displayStringForOption:
|
await submitSearch();
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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(
|
const SizedBox(
|
||||||
height: 12,
|
height: 12,
|
||||||
),
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: submitSearch,
|
||||||
if (activeBang != null &&
|
|
||||||
(formKey.currentState?.validate() == true)) {
|
|
||||||
await ref
|
|
||||||
.read(bangDataRepositoryProvider.notifier)
|
|
||||||
.increaseFrequency(activeBang.trigger);
|
|
||||||
|
|
||||||
onSubmit(activeBang.getUrl(textController.text));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label: const Text('Search'),
|
label: const Text('Search'),
|
||||||
icon: const Icon(MdiIcons.invoiceTextSend),
|
icon: const Icon(MdiIcons.invoiceTextSend),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:bang_navigator/domain/entities/web_page_info.dart';
|
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/domain/entities/modes.dart';
|
||||||
import 'package:bang_navigator/features/search_browser/utils/url_builder.dart'
|
import 'package:bang_navigator/features/search_browser/utils/url_builder.dart'
|
||||||
as uri_builder;
|
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(
|
ListTile(
|
||||||
leading: const Icon(MdiIcons.contentCopy),
|
leading: const Icon(MdiIcons.contentCopy),
|
||||||
title: const Text('Copy address'),
|
title: const Text('Copy address'),
|
||||||
|
|||||||
Reference in New Issue
Block a user