intermediate

This commit is contained in:
Fabian Freund
2024-12-12 09:44:33 +01:00
parent 0710116feb
commit 6b2ae28828
108 changed files with 5531 additions and 2265 deletions
@@ -0,0 +1,6 @@
abstract interface class IQueryBuilder {
int get ftsTokenLimit;
int get ftsMinTokenLength;
String buildFtsQuery(String input);
}
@@ -0,0 +1,5 @@
import 'package:exceptions/exceptions.dart';
abstract interface class ISearchSuggestionProvider {
Future<Result<List<String>>> getSuggestions(String query);
}
@@ -0,0 +1,70 @@
const barewordConcat = ' ';
sealed class Bareword {
final String word;
@override
int get hashCode => word.hashCode;
Bareword(String word) : word = word.replaceAll('"', '""');
@override
bool operator ==(Object other) {
if (other is Bareword) {
return word == other.word;
}
return false;
}
JoinedBareword join(Bareword other) {
return JoinedBareword(other.word, this);
}
}
final class SimpleBareword extends Bareword {
SimpleBareword(super.word);
@override
String toString() => word;
}
final class EnclosedBareword extends Bareword {
EnclosedBareword(super.word);
@override
String toString() {
return '"$word"';
}
}
final class JoinedBareword extends Bareword {
final Bareword parent;
JoinedBareword(super.bareword, this.parent);
bool hasDependency(Bareword other) {
if (parent is JoinedBareword) {
return (parent as JoinedBareword).hasDependency(other);
} else {
return parent == other;
}
}
Iterable<Bareword> get dependencies {
final depencies = <Bareword>[parent];
while (depencies.last is JoinedBareword) {
depencies.add((depencies.last as JoinedBareword).parent);
}
return depencies.reversed;
}
@override
String toString() {
final deps = dependencies
.map((bareword) => bareword.toString())
.join(barewordConcat);
return '"$deps$barewordConcat$word"';
}
}
@@ -0,0 +1,243 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_query_builder.dart';
import 'package:lensai/features/search/domain/entities/bareword.dart';
typedef _Phrase = List<Bareword>;
sealed class FtsQueryBuilder {
///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by
///a whitespace (group 2)
static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)');
///Reserved FTS5 baewords
static const _reservedBarewords = {'AND', 'OR', 'NOT'};
///Checks if string is an bareword accoring to:
///https://www.sqlite.org/fts5.html#fts5_strings
///
///Elsewise ths string needs to get quoted
static final _barewordPattern =
RegExp(r'^([^\x00-\x7F]|[\w]|[\d]|[_]|[\x1A])+$');
static bool _isReservedBareword(Bareword input) =>
_reservedBarewords.contains(input.word);
late final _Phrase _tokens;
bool get hasTokens => _tokens.isNotEmpty;
FtsQueryBuilder.tokenize({
required String input,
required int minTokenLength,
required int tokenLimit,
}) {
final matches = _tokenizePattern.allMatches(input);
final barewords = matches
.map((match) {
if (match.group(1) != null) {
return EnclosedBareword(match.group(1)!);
} else if (_barewordPattern.hasMatch(match.group(2)!)) {
return SimpleBareword(match.group(2)!);
} else {
return EnclosedBareword(match.group(2)!);
}
})
.where((token) => token.word.isNotEmpty)
.whereNot(_isReservedBareword)
.toList();
//Merge short tokens
_mergeShortBarewords(barewords, minTokenLength);
_tokens = barewords.take(tokenLimit).toList();
}
static void _mergeShortBarewords(
List<Bareword> barewords,
int minTokenLength,
) {
for (var i = 0; i < barewords.length; i++) {
final bareword = barewords[i];
if (i != 0) {
if (bareword.word.length < minTokenLength) {
barewords[i] = barewords[i - 1].join(bareword);
}
}
}
}
//Get weighted groups of tokens
//Weight depends on order
static List<_Phrase> _getWeighted(List<Bareword> tokens) {
if (tokens.isEmpty) {
return [];
}
final phrases = List.generate(
tokens.length - 1,
(index) => tokens.sublist(0, tokens.length - index),
);
phrases.addAll(tokens.map((token) => [token]));
return phrases;
}
//Get all combinations of input tokens and treat them as equally
//Match of bigger group will have greater score
static List<_Phrase> _getCombinations(List<Bareword> tokens) {
if (tokens.isEmpty) {
return [];
}
final combinations = <_Phrase>[];
final slent = math.pow(2, tokens.length);
for (var i = 0; i < slent; i++) {
final temp = <Bareword>[];
for (var j = 0; j < tokens.length; j++) {
if ((i & math.pow(2, j).toInt()) > 0) {
temp.add(tokens[j]);
}
}
if (temp.isNotEmpty) {
combinations.add(temp);
}
}
return combinations;
}
//If we have multiple unenclosed token, combine them into quoted to
//get a better full range match
//TODO: Before migration this was only used for non-prefix tokens, check for side effects
static List<_Phrase> _combineUnenclosed(_Phrase phrase) {
return [
phrase,
if (phrase.length > 2 &&
phrase.every((bareword) => bareword is SimpleBareword))
[EnclosedBareword(phrase.join(barewordConcat))],
];
}
//Decinding query strategy
static List<_Phrase> _generatePhrases(List<Bareword> tokens) {
if (tokens.length <= 4) {
return _getCombinations(tokens);
} else if (tokens.length <= 8) {
return _getWeighted(tokens);
} else {
return [tokens];
}
}
static _Phrase _removeDependendBarewords(List<Bareword> phrase) {
final joinedPhrase = List.of(phrase);
for (var i = 0; i < joinedPhrase.length; i++) {
final bareword = joinedPhrase[i];
//Remove dependend phrases
if (joinedPhrase.whereType<JoinedBareword>().any(
(joined) => joined.dependencies.contains(bareword),
)) {
joinedPhrase.removeAt(i);
i--;
}
}
return joinedPhrase;
}
String _buildQuery(
List<Bareword> tokens,
String Function(List<Bareword> phrase) barewordConcat,
String phraseConcat,
) {
final phrases = _generatePhrases(tokens)
.map(_removeDependendBarewords)
.expand(_combineUnenclosed);
return phrases
.where((phrase) => phrase.isNotEmpty)
.sortedBy<num>((x) => x.length)
.reversed
.map(barewordConcat)
.toSet()
.join(phraseConcat);
}
String build();
}
final class PrefixQueryBuilder extends FtsQueryBuilder {
PrefixQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
required super.tokenLimit,
}) : super.tokenize();
@override
String build() {
return _buildQuery(
_tokens,
(phrase) {
if (phrase.length == 1) {
return '${phrase.first}*';
}
return 'NEAR(${phrase.map((bareword) => '$bareword*').join(' ')})';
},
' OR ',
);
}
}
final class TrigramQueryBuilder extends FtsQueryBuilder {
TrigramQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
required super.tokenLimit,
}) : super.tokenize();
@override
String build() {
return _buildQuery(
_tokens,
(phrase) => phrase.join(' '),
' OR ',
);
}
}
mixin PrefixQueryBuilderMixin implements IQueryBuilder {
@override
String buildFtsQuery(String input) {
final queryBuilder = PrefixQueryBuilder.tokenize(
input: input,
minTokenLength: ftsMinTokenLength,
tokenLimit: ftsTokenLimit,
);
return queryBuilder.build();
}
}
mixin TrigramQueryBuilderMixin implements IQueryBuilder {
@override
String buildFtsQuery(String input) {
final queryBuilder = TrigramQueryBuilder.tokenize(
input: input,
minTokenLength: ftsMinTokenLength,
tokenLimit: ftsTokenLimit,
);
return queryBuilder.build();
}
}
@@ -0,0 +1,35 @@
import 'package:lensai/features/kagi/data/services/autosuggest.dart';
import 'package:lensai/features/kagi/domain/repositories/search_suggestions.dart';
import 'package:lensai/features/search/domain/entities/abstract/i_search_suggestion_provider.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_suggestions.g.dart';
@Riverpod(keepAlive: true)
ISearchSuggestionProvider defaultSearchSuggestions(Ref ref) {
return ref.watch(kagiAutosuggestServiceProvider.notifier);
}
@Riverpod(keepAlive: true)
class SearchSuggestions extends _$SearchSuggestions {
late void Function(String query) _addQueryBinding;
void addQuery(String query) => _addQueryBinding(query);
@override
Raw<Stream<List<String>>> build({
ISearchSuggestionProvider? suggestionsProvider,
}) {
final defaultProvider = ref.watch(defaultSearchSuggestionsProvider);
final resolvedProvider = suggestionsProvider ?? defaultProvider;
_addQueryBinding = ref
.watch(searchSuggestionsRepositoryProvider(resolvedProvider).notifier)
.addQuery;
return ref.watch(
searchSuggestionsRepositoryProvider(resolvedProvider),
);
}
}
@@ -0,0 +1,196 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_suggestions.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$defaultSearchSuggestionsHash() =>
r'56514064baa64acf0a28ec34cfad957dd863538b';
/// See also [defaultSearchSuggestions].
@ProviderFor(defaultSearchSuggestions)
final defaultSearchSuggestionsProvider =
Provider<ISearchSuggestionProvider>.internal(
defaultSearchSuggestions,
name: r'defaultSearchSuggestionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$defaultSearchSuggestionsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef DefaultSearchSuggestionsRef = ProviderRef<ISearchSuggestionProvider>;
String _$searchSuggestionsHash() => r'ecc378aca325cc6759292a06d8d4d5ace910eb7f';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$SearchSuggestions
extends BuildlessNotifier<Raw<Stream<List<String>>>> {
late final ISearchSuggestionProvider? suggestionsProvider;
Raw<Stream<List<String>>> build({
ISearchSuggestionProvider? suggestionsProvider,
});
}
/// See also [SearchSuggestions].
@ProviderFor(SearchSuggestions)
const searchSuggestionsProvider = SearchSuggestionsFamily();
/// See also [SearchSuggestions].
class SearchSuggestionsFamily extends Family<Raw<Stream<List<String>>>> {
/// See also [SearchSuggestions].
const SearchSuggestionsFamily();
/// See also [SearchSuggestions].
SearchSuggestionsProvider call({
ISearchSuggestionProvider? suggestionsProvider,
}) {
return SearchSuggestionsProvider(
suggestionsProvider: suggestionsProvider,
);
}
@override
SearchSuggestionsProvider getProviderOverride(
covariant SearchSuggestionsProvider provider,
) {
return call(
suggestionsProvider: provider.suggestionsProvider,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'searchSuggestionsProvider';
}
/// See also [SearchSuggestions].
class SearchSuggestionsProvider
extends NotifierProviderImpl<SearchSuggestions, Raw<Stream<List<String>>>> {
/// See also [SearchSuggestions].
SearchSuggestionsProvider({
ISearchSuggestionProvider? suggestionsProvider,
}) : this._internal(
() => SearchSuggestions()..suggestionsProvider = suggestionsProvider,
from: searchSuggestionsProvider,
name: r'searchSuggestionsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$searchSuggestionsHash,
dependencies: SearchSuggestionsFamily._dependencies,
allTransitiveDependencies:
SearchSuggestionsFamily._allTransitiveDependencies,
suggestionsProvider: suggestionsProvider,
);
SearchSuggestionsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.suggestionsProvider,
}) : super.internal();
final ISearchSuggestionProvider? suggestionsProvider;
@override
Raw<Stream<List<String>>> runNotifierBuild(
covariant SearchSuggestions notifier,
) {
return notifier.build(
suggestionsProvider: suggestionsProvider,
);
}
@override
Override overrideWith(SearchSuggestions Function() create) {
return ProviderOverride(
origin: this,
override: SearchSuggestionsProvider._internal(
() => create()..suggestionsProvider = suggestionsProvider,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
suggestionsProvider: suggestionsProvider,
),
);
}
@override
NotifierProviderElement<SearchSuggestions, Raw<Stream<List<String>>>>
createElement() {
return _SearchSuggestionsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SearchSuggestionsProvider &&
other.suggestionsProvider == suggestionsProvider;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, suggestionsProvider.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SearchSuggestionsRef on NotifierProviderRef<Raw<Stream<List<String>>>> {
/// The parameter `suggestionsProvider` of this provider.
ISearchSuggestionProvider? get suggestionsProvider;
}
class _SearchSuggestionsProviderElement extends NotifierProviderElement<
SearchSuggestions, Raw<Stream<List<String>>>> with SearchSuggestionsRef {
_SearchSuggestionsProviderElement(super.provider);
@override
ISearchSuggestionProvider? get suggestionsProvider =>
(origin as SearchSuggestionsProvider).suggestionsProvider;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -0,0 +1,68 @@
import 'package:lensai/features/search/domain/entities/bareword.dart';
typedef _Phrase = List<Bareword>;
sealed class UnixTokenizer {
///Matching quoted strings like "xda bda" (group 1), as well as strings delimetered by
///a whitespace (group 2)
static final _tokenizePattern = RegExp('"([^"]+)"|([^ ]+)');
late final _Phrase _tokens;
bool get hasTokens => _tokens.isNotEmpty;
static void _mergeShortBarewords(
List<Bareword> barewords,
int minTokenLength,
) {
for (var i = 0; i < barewords.length; i++) {
final bareword = barewords[i];
if (i != 0) {
if (bareword.word.length < minTokenLength) {
barewords[i] = barewords[i - 1].join(bareword);
}
}
}
}
UnixTokenizer.tokenize({
required String input,
required int minTokenLength,
required int tokenLimit,
}) {
final matches = _tokenizePattern.allMatches(input);
final barewords = matches
.map((match) {
if (match.group(1) != null) {
return EnclosedBareword(match.group(1)!);
} else {
return SimpleBareword(match.group(2)!);
}
})
.where((token) => token.word.isNotEmpty)
.toList();
//Merge short tokens
_mergeShortBarewords(barewords, minTokenLength);
_tokens = barewords.take(tokenLimit).toList();
}
String build({bool wildcard});
}
final class UnixLikeQueryBuilder extends UnixTokenizer {
UnixLikeQueryBuilder.tokenize({
required super.input,
required super.minTokenLength,
required super.tokenLimit,
}) : super.tokenize();
@override
String build({bool wildcard = true}) {
final joined = _tokens.join('%');
return wildcard ? '%$joined%' : joined;
}
}
@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/data/models/equatable_iterable.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/providers/bangs.dart';
import 'package:lensai/features/bangs/domain/repositories/data.dart';
import 'package:lensai/features/geckoview/domain/providers/tab_state.dart';
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:lensai/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:lensai/features/search/domain/providers/search_suggestions.dart';
import 'package:lensai/features/search/presentation/widgets/bang_chips.dart';
import 'package:lensai/features/search/presentation/widgets/search_field.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:skeletonizer/skeletonizer.dart';
class SearchScreen extends HookConsumerWidget {
final String? initialSearchText;
const SearchScreen({required this.initialSearchText});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final searchTextController =
useTextEditingController(text: initialSearchText);
useListenableCallback(
searchTextController,
() async {
ref
.read(searchSuggestionsProvider().notifier)
.addQuery(searchTextController.text);
await ref
.read(tabSearchRepositoryProvider.notifier)
.addQuery(searchTextController.text);
},
);
final globalSelectedContainer = ref.watch(
selectedContainerDataProvider.select((value) => value.valueOrNull),
);
final defaultSearchBang = ref.watch(
defaultSearchBangDataProvider.select((value) => value.valueOrNull),
);
final selectedBang = useState<BangData?>(null);
final activeBang = selectedBang.value ?? defaultSearchBang;
final showBangIcon = useState(false);
ref.listen(
selectedBangDataProvider(),
(previous, next) {
if (next.hasValue) {
if ((previous?.hasValue ?? false) && previous!.value != next.value) {
showBangIcon.value = true;
}
selectedBang.value = next.value;
}
},
);
final selectedContainer = useState<ContainerData?>(globalSelectedContainer);
final availableTabs = ref
.watch(
availableTabIdsProvider(selectedContainer.value?.id).select(
(value) => EquatableCollection(value, immutable: true),
),
)
.collection;
return Scaffold(
body: Form(
key: formKey,
child: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
pinned: true,
automaticallyImplyLeading: false,
title: SearchField(
showBangIcon: showBangIcon.value,
textEditingController: searchTextController,
onFieldSubmitted: (text) {},
activeBang: activeBang,
),
),
const SliverToBoxAdapter(
child: Divider(),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Site Search',
style: Theme.of(context).textTheme.labelSmall,
),
BangChips(
activeBang: activeBang,
onSelected: (bang) {
ref
.read(selectedBangTriggerProvider().notifier)
.setTrigger(bang.trigger);
},
onDeleted: (bang) async {
if (ref.read(selectedBangTriggerProvider()) ==
bang.trigger) {
ref
.read(
selectedBangTriggerProvider().notifier,
)
.clearTrigger();
} else {
final dialogResult = await BangChips.resetBangDialog(
context,
bang.trigger,
);
if (dialogResult == true) {
await ref
.read(bangDataRepositoryProvider.notifier)
.resetFrequency(bang.trigger);
}
}
},
searchTextController: searchTextController,
),
],
),
),
),
HookConsumer(
builder: (context, ref, child) {
final hasSearchText = useListenableSelector(
searchTextController,
() => searchTextController.text.isNotEmpty,
);
final suggestions =
useStream(ref.watch(searchSuggestionsProvider()));
return (!hasSearchText || !suggestions.hasData)
? SliverList.builder(
itemCount: 5,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.history),
title: Text('Search History $index'),
);
},
)
: SliverList.builder(
itemCount: suggestions.data!.length,
itemBuilder: (context, index) {
final suggestion = suggestions.data![index];
return ListTile(
leading: const Icon(Icons.search),
title: Text(suggestion),
onLongPress: () {
searchTextController.text = suggestion;
},
);
},
);
},
),
const SliverToBoxAdapter(
child: Divider(),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tabs',
style: Theme.of(context).textTheme.labelSmall,
),
ContainerChips(
displayMenu: false,
selectedContainer: selectedContainer.value,
onSelected: (container) {
selectedContainer.value = container;
},
onDeleted: (container) {
selectedContainer.value = null;
},
searchTextController: searchTextController,
),
],
),
),
),
Consumer(
builder: (context, ref, child) {
final tabs = ref.watch(tabSearchRepositoryProvider);
return SliverSkeletonizer(
enabled: tabs.isLoading,
child: tabs.when(
data: (data) {
return SliverList.builder(itemBuilder: itemBuilder);
},
error: error,
loading: () => SliverList.builder(
itemCount: tabs.valueOrNull?.length ?? 3,
itemBuilder: (context, index) {
return ListTile()
},),
),
);
},
),
SliverList.builder(
itemCount: availableTabs.length,
itemBuilder: (context, index) {
return Consumer(
key: ValueKey(availableTabs[index]),
builder: (context, ref, child) {
final tab =
ref.watch(tabStateProvider(availableTabs[index]));
if (tab == null) {
return const SizedBox.shrink();
}
return ListTile(
leading: RawImage(
image: tab.icon?.value,
height: 24,
width: 24,
),
title: Text(tab.title),
subtitle: Text(
tab.url.toString(),
softWrap: false,
overflow: TextOverflow.fade,
),
);
},
);
},
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/core/routing/routes.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/domain/repositories/search.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/presentation/hooks/listenable_callback.dart';
import 'package:lensai/presentation/widgets/selectable_chips.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,
});
static Future<bool?> resetBangDialog(
BuildContext context,
String triggerName,
) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Reset usage frequency of $triggerName?',
),
content: const Text(
'This will remove the Bang from quick select.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Reset'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final availableBangs = ref.watch(seamlessBangProviderProvider);
useListenableCallback(
searchTextController,
() {
ref
.read(seamlessBangProviderProvider.notifier)
.search(searchTextController!.text);
},
);
return availableBangs.when(
data: (availableBangs) => SizedBox(
height: 48,
child: Row(
children: [
if (activeBang != null || availableBangs.isNotEmpty)
Expanded(
child: SelectableChips(
itemId: (bang) => bang.trigger,
itemAvatar: (bang) => BangIcon(bang, iconSize: 20),
itemLabel: (bang) => Text(bang.websiteName),
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();
await context.push(
BangSearchRoute(
searchText: (searchText?.isEmpty ?? true)
? BangSearchRoute.emptySearchText
: searchText!,
).location,
);
},
icon: const Icon(Icons.chevron_right),
),
],
),
),
error: (error, stackTrace) => const SizedBox.shrink(),
loading: () => const SizedBox(
height: 48,
width: double.infinity,
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lensai/features/bangs/data/models/bang_data.dart';
import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart';
import 'package:lensai/features/geckoview/features/browser/presentation/widgets/speech_to_text_button.dart';
import 'package:lensai/features/settings/data/models/settings.dart';
import 'package:lensai/features/settings/data/repositories/settings_repository.dart';
class SearchField extends HookConsumerWidget {
final TextEditingController textEditingController;
final FocusNode? focusNode;
final void Function(String)? onFieldSubmitted;
final BangData? activeBang;
final bool showBangIcon;
const SearchField({
super.key,
required this.textEditingController,
required this.onFieldSubmitted,
required this.activeBang,
this.focusNode,
this.showBangIcon = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final incognitoEnabled = ref.watch(
settingsRepositoryProvider.select(
(value) => (value.valueOrNull ?? Settings.withDefaults()).incognitoMode,
),
);
final hasText = useListenableSelector(
textEditingController,
() => textEditingController.text.isNotEmpty,
);
return TextFormField(
controller: textEditingController,
enableIMEPersonalizedLearning: !incognitoEnabled,
focusNode: focusNode,
decoration: InputDecoration(
border: InputBorder.none,
prefixIcon: (showBangIcon && activeBang != null)
? Padding(
padding: const EdgeInsetsDirectional.all(12.0),
child: BangIcon(activeBang!, iconSize: 24.0),
)
: null,
label: const Text('Search'),
hintText: 'Ask anything...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: hasText
? IconButton(
onPressed: () {
textEditingController.clear();
},
icon: const Icon(Icons.clear),
)
: SpeechToTextButton(
onTextReceived: (data) {
textEditingController.text = data.toString();
},
),
),
// autovalidateMode: AutovalidateMode.onUserInteraction,
// validator: (value) {
// if (value?.isEmpty ?? true) {
// return '';
// }
// return null;
// },
onTapOutside: (focusNode != null)
? (event) {
focusNode!.unfocus();
}
: null,
onFieldSubmitted: onFieldSubmitted,
);
}
}
@@ -0,0 +1,48 @@
typedef ToString<T> = String? Function(T);
class TokenizedFilter<T> {
final Iterable<T> original;
final List<T> filtered;
static List<String> _tokenize(String input) {
return input
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^\w\s]'), '') // Remove punctuation
.split(RegExp(r'\s+')) // Split on whitespace
.where((word) => word.isNotEmpty)
.toList();
}
static bool _matchTokens(
List<String> queryTokens,
List<String> targetTokens,
) {
return queryTokens.every(
(queryToken) =>
targetTokens.any((targetToken) => targetToken.contains(queryToken)),
);
}
factory TokenizedFilter({
required Iterable<T> items,
required ToString<T> toString,
required String query,
}) {
final queryTokens = _tokenize(query);
final filtered = items.where((item) {
final itemString = toString(item);
if (itemString == null || itemString.isEmpty) {
return false;
}
final itemTokens = _tokenize(itemString);
return _matchTokens(queryTokens, itemTokens);
}).toList();
return TokenizedFilter._(items, filtered);
}
const TokenizedFilter._(this.original, this.filtered);
}