pretty good

This commit is contained in:
Fabian Freund
2025-02-07 20:45:47 +01:00
parent 025ecaf2e3
commit a12ef8244f
68 changed files with 2359 additions and 1015 deletions
@@ -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,
);
}
}
@@ -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(),
);
},
),
),
),
],
);
}
}
@@ -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,
],
);
},
),
),
),
],
);
}
}
@@ -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();
}
},
);
},
),
],
);
}
}