pretty good
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/providers/search.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_field.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.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);
|
||||
final searchFocusNode = useFocusNode();
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> submitSearch(String query) async {
|
||||
if (activeBang != null && (formKey.currentState?.validate() == true)) {
|
||||
final searchUri = await ref.read(
|
||||
triggerBangSearchProvider(activeBang, query).future,
|
||||
);
|
||||
|
||||
await ref.read(tabRepositoryProvider.notifier).addTab(url: searchUri);
|
||||
|
||||
if (context.mounted) {
|
||||
ref.read(bottomSheetControllerProvider.notifier).dismiss();
|
||||
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Form(
|
||||
key: formKey,
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
title: SearchField(
|
||||
showBangIcon: showBangIcon.value,
|
||||
textEditingController: searchTextController,
|
||||
focusNode: searchFocusNode,
|
||||
autofocus: true,
|
||||
onSubmitted: (text) async {
|
||||
if (Uri.tryParse(text) case final Uri url) {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: url);
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.dismiss();
|
||||
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
activeBang: activeBang,
|
||||
showSuggestions: true,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(),
|
||||
),
|
||||
SearchTermSuggestions(
|
||||
searchTextController: searchTextController,
|
||||
activeBang: activeBang,
|
||||
submitSearch: submitSearch,
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(),
|
||||
),
|
||||
TabSearch(
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
HistorySuggestions(
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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/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) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
if (activeBang != null || availableBangs.isNotEmpty)
|
||||
Expanded(
|
||||
child: SelectableChips(
|
||||
itemId: (bang) => bang.trigger,
|
||||
itemAvatar: (bang) =>
|
||||
UrlIcon(bang.getUrl(''), 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,97 @@
|
||||
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/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:lensai/features/user/domain/providers.dart';
|
||||
import 'package:lensai/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:lensai/presentation/widgets/auto_suggest_text_field.dart';
|
||||
import 'package:lensai/presentation/widgets/bang_icon.dart';
|
||||
import 'package:lensai/presentation/widgets/speech_to_text_button.dart';
|
||||
|
||||
class SearchField extends HookConsumerWidget {
|
||||
final TextEditingController textEditingController;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
final void Function(String)? onSubmitted;
|
||||
final bool showSuggestions;
|
||||
|
||||
final BangData? activeBang;
|
||||
final bool showBangIcon;
|
||||
|
||||
const SearchField({
|
||||
super.key,
|
||||
required this.textEditingController,
|
||||
required this.onSubmitted,
|
||||
required this.activeBang,
|
||||
required this.showSuggestions,
|
||||
this.focusNode,
|
||||
this.showBangIcon = true,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final incognitoEnabled = ref.watch(incognitoModeEnabledProvider);
|
||||
|
||||
final hasText = useListenableSelector(
|
||||
textEditingController,
|
||||
() => textEditingController.text.isNotEmpty,
|
||||
);
|
||||
|
||||
final suggestion = useState<String?>(null);
|
||||
|
||||
if (showSuggestions) {
|
||||
useListenableCallback(
|
||||
textEditingController,
|
||||
() async {
|
||||
if (textEditingController.text.isNotEmpty) {
|
||||
final result = await ref
|
||||
.read(engineSuggestionsProvider.notifier)
|
||||
.getAutocompleteSuggestion(textEditingController.text);
|
||||
|
||||
suggestion.value = result;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return AutoSuggestTextField(
|
||||
controller: textEditingController,
|
||||
suggestion: suggestion.value,
|
||||
enableIMEPersonalizedLearning: !incognitoEnabled,
|
||||
focusNode: focusNode,
|
||||
autofocus: autofocus,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
prefixIcon: (showBangIcon && activeBang != null)
|
||||
? Padding(
|
||||
padding: const EdgeInsetsDirectional.all(12.0),
|
||||
child: UrlIcon(activeBang!.getUrl(''), 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
onTapOutside: (focusNode != null)
|
||||
? (event) {
|
||||
focusNode!.unfocus();
|
||||
}
|
||||
: null,
|
||||
onSubmitted: onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/extensions/nullable.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:lensai/features/geckoview/utils/image_helper.dart';
|
||||
import 'package:lensai/presentation/hooks/cached_future.dart';
|
||||
import 'package:lensai/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:lensai/presentation/widgets/failure_widget.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class HistorySuggestions extends HookConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
|
||||
const HistorySuggestions({super.key, required this.searchTextController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historySuggestions = ref.watch(engineHistorySuggestionsProvider);
|
||||
|
||||
useListenableCallback(
|
||||
searchTextController,
|
||||
() async {
|
||||
await ref
|
||||
.watch(engineSuggestionsProvider.notifier)
|
||||
.addQuery(searchTextController.text);
|
||||
},
|
||||
);
|
||||
|
||||
if (historySuggestions.hasValue &&
|
||||
(historySuggestions.valueOrNull?.isEmpty ?? true)) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Text(
|
||||
'History',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverSkeletonizer(
|
||||
enabled: historySuggestions.isLoading,
|
||||
child: historySuggestions.when(
|
||||
data: (historySuggestions) {
|
||||
return SliverList.builder(
|
||||
itemCount: historySuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = historySuggestions[index];
|
||||
|
||||
return HookBuilder(
|
||||
key: ValueKey(suggestion.id),
|
||||
builder: (context) {
|
||||
final icon = useCachedFuture(
|
||||
() async => suggestion.icon.mapNotNull(tryDecodeImage),
|
||||
//URL as key
|
||||
[suggestion.description],
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: RawImage(
|
||||
image: icon.data,
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
),
|
||||
title:
|
||||
suggestion.title.mapNotNull((title) => Text(title)),
|
||||
subtitle: suggestion.description.mapNotNull(
|
||||
(description) => Text(
|
||||
description,
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
if (suggestion.description != null) {
|
||||
if (Uri.tryParse(suggestion.description!)
|
||||
case final Uri url) {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: url);
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(
|
||||
bottomSheetControllerProvider.notifier,
|
||||
)
|
||||
.dismiss();
|
||||
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
return FailureWidget(
|
||||
title: 'Could not load history',
|
||||
exception: error,
|
||||
);
|
||||
},
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: historySuggestions.valueOrNull?.length ?? 3,
|
||||
itemBuilder: (context, index) {
|
||||
return const ListTile(
|
||||
title: Bone.text(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
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/domain/providers/bangs.dart';
|
||||
import 'package:lensai/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:lensai/features/geckoview/features/search/presentation/widgets/bang_chips.dart';
|
||||
import 'package:lensai/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class SearchTermSuggestions extends HookConsumerWidget {
|
||||
final TextEditingController searchTextController;
|
||||
final Future<void> Function(String query) submitSearch;
|
||||
final BangData? activeBang;
|
||||
|
||||
final bool showHistory;
|
||||
final bool showChips;
|
||||
|
||||
const SearchTermSuggestions({
|
||||
required this.searchTextController,
|
||||
required this.submitSearch,
|
||||
required this.activeBang,
|
||||
this.showHistory = true,
|
||||
this.showChips = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchTextIsNotEmpty = useListenableSelector(
|
||||
searchTextController,
|
||||
() => searchTextController.text.isNotEmpty,
|
||||
);
|
||||
|
||||
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
||||
|
||||
final searchHistory = ref.watch(searchHistoryProvider);
|
||||
|
||||
useListenableCallback(
|
||||
searchTextController,
|
||||
() async {
|
||||
ref
|
||||
.read(searchSuggestionsProvider().notifier)
|
||||
.addQuery(searchTextController.text);
|
||||
},
|
||||
);
|
||||
|
||||
final Widget listSliver;
|
||||
|
||||
if (showHistory &&
|
||||
!searchTextIsNotEmpty &&
|
||||
(searchHistory.value?.isNotEmpty ?? false)) {
|
||||
final entries = searchHistory.value!;
|
||||
|
||||
listSliver = SliverList.builder(
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final query = entries[index].searchQuery;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(query),
|
||||
onLongPress: () {
|
||||
searchTextController.text = query;
|
||||
},
|
||||
onTap: () async {
|
||||
await submitSearch(query);
|
||||
},
|
||||
trailing: IconButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.removeSearchEntry(query);
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
final prioritizedSuggestions = [
|
||||
if (searchTextIsNotEmpty) searchTextController.text,
|
||||
if (searchSuggestions.valueOrNull != null)
|
||||
...searchSuggestions.valueOrNull!.whereNot(
|
||||
(suggestion) => suggestion == searchTextController.text,
|
||||
),
|
||||
];
|
||||
|
||||
listSliver = SliverList.builder(
|
||||
itemCount: prioritizedSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = prioritizedSuggestions[index];
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.search),
|
||||
title: Text(suggestion),
|
||||
onLongPress: () {
|
||||
searchTextController.text = suggestion;
|
||||
},
|
||||
onTap: () async {
|
||||
await submitSearch(suggestion);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
if (showChips)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
BangChips(
|
||||
activeBang: activeBang,
|
||||
onSelected: (bang) {
|
||||
searchTextController.clear();
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
controller: controller,
|
||||
slivers: [
|
||||
listSliver,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lensai/extensions/nullable.dart';
|
||||
import 'package:lensai/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:lensai/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:lensai/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:lensai/features/geckoview/features/tabs/data/entities/container_filter.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/presentation/hooks/listenable_callback.dart';
|
||||
import 'package:lensai/presentation/widgets/bang_icon.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
|
||||
class TabSearch extends HookConsumerWidget {
|
||||
static const _matchPrefix = '***';
|
||||
static const _matchSuffix = '***';
|
||||
|
||||
final TextEditingController searchTextController;
|
||||
|
||||
const TabSearch({required this.searchTextController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedContainer = useState<ContainerData?>(
|
||||
ref.read(
|
||||
selectedContainerDataProvider.select((value) => value.valueOrNull),
|
||||
),
|
||||
);
|
||||
|
||||
final tabs = ref
|
||||
.watch(
|
||||
seamlessFilteredTabPreviewsProvider(
|
||||
TabSearchPartition.search,
|
||||
(selectedContainer.value != null)
|
||||
? ContainerFilterById(containerId: selectedContainer.value!.id)
|
||||
: ContainerFilterDisabled(),
|
||||
),
|
||||
)
|
||||
.collection;
|
||||
|
||||
useListenableCallback(
|
||||
searchTextController,
|
||||
() async {
|
||||
await ref
|
||||
.read(
|
||||
tabSearchRepositoryProvider(TabSearchPartition.search).notifier,
|
||||
)
|
||||
.addQuery(
|
||||
searchTextController.text,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
matchPrefix: _matchPrefix,
|
||||
// ignore: avoid_redundant_argument_values
|
||||
matchSuffix: _matchSuffix,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
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;
|
||||
},
|
||||
containerFilter: (container) => (container.tabCount ?? 0) > 0,
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: tabs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final result = tabs[index];
|
||||
|
||||
final urlHasMatch =
|
||||
result.highlightedUrl?.contains(_matchPrefix) ?? false;
|
||||
final bodyHasMatch =
|
||||
result.content?.contains(_matchPrefix) ?? false;
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: (result.icon != null)
|
||||
? RawImage(
|
||||
image: result.icon?.value,
|
||||
height: 24,
|
||||
width: 24,
|
||||
)
|
||||
: UrlIcon(result.url, iconSize: 24),
|
||||
),
|
||||
title: result.title.mapNotNull(
|
||||
(title) => MarkdownBody(
|
||||
data: title,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: MarkdownBody(
|
||||
data: (bodyHasMatch && !urlHasMatch)
|
||||
? result.content!
|
||||
: result.highlightedUrl ?? result.url.toString(),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
a: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.selectTab(result.id);
|
||||
|
||||
if (context.mounted) {
|
||||
ref.read(bottomSheetControllerProvider.notifier).dismiss();
|
||||
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user