diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index cde94816..c505bb99 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -97,7 +97,7 @@ ?android:attr/colorBackground ?android:attr/textColorPrimary + + diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 2aee79e2..53f25239 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -14,7 +14,7 @@ import 'package:lensai/features/geckoview/features/browser/presentation/screens/ import 'package:lensai/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; import 'package:lensai/features/geckoview/features/tabs/presentation/screens/container_list.dart'; -import 'package:lensai/features/search/presentation/screens/search.dart'; +import 'package:lensai/features/geckoview/features/search/presentation/screens/search.dart'; import 'package:lensai/features/settings/presentation/screens/bang_settings.dart'; import 'package:lensai/features/settings/presentation/screens/general_settings.dart'; import 'package:lensai/features/settings/presentation/screens/settings.dart'; diff --git a/app/lib/domain/services/generic_website.dart b/app/lib/domain/services/generic_website.dart index cead5471..068162a5 100644 --- a/app/lib/domain/services/generic_website.dart +++ b/app/lib/domain/services/generic_website.dart @@ -274,6 +274,18 @@ class GenericWebsiteService extends _$GenericWebsiteService { ); } + Future getUrlIcon(Uri url) async { + final cachedIcon = await getCachedIcon(url); + + if (cachedIcon != null) { + return cachedIcon; + } + + return fetchPageInfo(url).then( + (result) => result.flatMap((pageInfo) => pageInfo.favicon!).value, + ); + } + // Future tryUpgradeToHttps(Uri httpUri) async { // if (httpUri.isScheme('https')) { // return httpUri; diff --git a/app/lib/domain/services/generic_website.g.dart b/app/lib/domain/services/generic_website.g.dart index 3a5035e9..44d9c141 100644 --- a/app/lib/domain/services/generic_website.g.dart +++ b/app/lib/domain/services/generic_website.g.dart @@ -7,7 +7,7 @@ part of 'generic_website.dart'; // ************************************************************************** String _$genericWebsiteServiceHash() => - r'27430ba17a04b0fa3cca20abf00f9d1c15fa843a'; + r'4c8c1c9ad430ab01f637f02ec97ee4ce5202975e'; /// See also [GenericWebsiteService]. @ProviderFor(GenericWebsiteService) diff --git a/app/lib/features/bangs/domain/providers/bangs.dart b/app/lib/features/bangs/domain/providers/bangs.dart index 4734617f..2e7e69b5 100644 --- a/app/lib/features/bangs/domain/providers/bangs.dart +++ b/app/lib/features/bangs/domain/providers/bangs.dart @@ -57,17 +57,6 @@ Stream> searchHistory(Ref ref) { return repository.watchSearchHistory(limit: 3); //TODO: make count dynamic } -@Riverpod() -Future bangDataEnsureIcon( - Ref ref, - BangData bang, -) { - final repository = ref.watch(bangDataRepositoryProvider.notifier); - return repository.ensureIconAvailable(bang).then( - (value) => value.value, - ); -} - @Riverpod() Stream lastSyncOfGroup( Ref ref, diff --git a/app/lib/features/bangs/domain/providers/bangs.g.dart b/app/lib/features/bangs/domain/providers/bangs.g.dart index bc57e9b5..545c9ad3 100644 --- a/app/lib/features/bangs/domain/providers/bangs.g.dart +++ b/app/lib/features/bangs/domain/providers/bangs.g.dart @@ -387,138 +387,6 @@ final searchHistoryProvider = // ignore: unused_element typedef SearchHistoryRef = AutoDisposeStreamProviderRef>; -String _$bangDataEnsureIconHash() => - r'423b8e92fd89ec328c2fcc8f9e55365c30633fd2'; - -/// See also [bangDataEnsureIcon]. -@ProviderFor(bangDataEnsureIcon) -const bangDataEnsureIconProvider = BangDataEnsureIconFamily(); - -/// See also [bangDataEnsureIcon]. -class BangDataEnsureIconFamily extends Family> { - /// See also [bangDataEnsureIcon]. - const BangDataEnsureIconFamily(); - - /// See also [bangDataEnsureIcon]. - BangDataEnsureIconProvider call( - BangData bang, - ) { - return BangDataEnsureIconProvider( - bang, - ); - } - - @override - BangDataEnsureIconProvider getProviderOverride( - covariant BangDataEnsureIconProvider provider, - ) { - return call( - provider.bang, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'bangDataEnsureIconProvider'; -} - -/// See also [bangDataEnsureIcon]. -class BangDataEnsureIconProvider extends AutoDisposeFutureProvider { - /// See also [bangDataEnsureIcon]. - BangDataEnsureIconProvider( - BangData bang, - ) : this._internal( - (ref) => bangDataEnsureIcon( - ref as BangDataEnsureIconRef, - bang, - ), - from: bangDataEnsureIconProvider, - name: r'bangDataEnsureIconProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$bangDataEnsureIconHash, - dependencies: BangDataEnsureIconFamily._dependencies, - allTransitiveDependencies: - BangDataEnsureIconFamily._allTransitiveDependencies, - bang: bang, - ); - - BangDataEnsureIconProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.bang, - }) : super.internal(); - - final BangData bang; - - @override - Override overrideWith( - FutureOr Function(BangDataEnsureIconRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: BangDataEnsureIconProvider._internal( - (ref) => create(ref as BangDataEnsureIconRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - bang: bang, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _BangDataEnsureIconProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is BangDataEnsureIconProvider && other.bang == bang; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, bang.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin BangDataEnsureIconRef on AutoDisposeFutureProviderRef { - /// The parameter `bang` of this provider. - BangData get bang; -} - -class _BangDataEnsureIconProviderElement - extends AutoDisposeFutureProviderElement - with BangDataEnsureIconRef { - _BangDataEnsureIconProviderElement(super.provider); - - @override - BangData get bang => (origin as BangDataEnsureIconProvider).bang; -} - String _$lastSyncOfGroupHash() => r'23d07f3132ba9bb35a31f74e3a69698d31d4c569'; /// See also [lastSyncOfGroup]. diff --git a/app/lib/features/bangs/domain/repositories/data.dart b/app/lib/features/bangs/domain/repositories/data.dart index ae40e303..1270842c 100644 --- a/app/lib/features/bangs/domain/repositories/data.dart +++ b/app/lib/features/bangs/domain/repositories/data.dart @@ -111,26 +111,6 @@ class BangDataRepository extends _$BangDataRepository { .removeSearchEntry(searchQuery); } - Future> ensureIconAvailable(BangData bang) async { - if (bang.icon != null) { - return Result.success(bang); - } - - final url = bang.getUrl(''); - - final websiteProvider = ref.read(genericWebsiteServiceProvider.notifier); - final cachedIcon = await websiteProvider.getCachedIcon(url); - - if (cachedIcon != null) { - return Result.success(bang.copyWith.icon(cachedIcon)); - } - - return websiteProvider.fetchPageInfo(url).then( - (result) => result - .flatMap((pageInfo) => bang.copyWith.icon(pageInfo.favicon)), - ); - } - Future resetFrequencies() { return ref.read(bangDatabaseProvider).bangFrequency.deleteAll(); } diff --git a/app/lib/features/bangs/domain/repositories/data.g.dart b/app/lib/features/bangs/domain/repositories/data.g.dart index a7452919..7c23dec8 100644 --- a/app/lib/features/bangs/domain/repositories/data.g.dart +++ b/app/lib/features/bangs/domain/repositories/data.g.dart @@ -7,7 +7,7 @@ part of 'data.dart'; // ************************************************************************** String _$bangDataRepositoryHash() => - r'2c009519cf02df4d3cbb6768dbe55e9eb696e126'; + r'de89d7089ce6a92015bee233f08d920981518793'; /// See also [BangDataRepository]. @ProviderFor(BangDataRepository) diff --git a/app/lib/features/bangs/domain/repositories/search.dart b/app/lib/features/bangs/domain/repositories/search.dart index e65699f2..463342e7 100644 --- a/app/lib/features/bangs/domain/repositories/search.dart +++ b/app/lib/features/bangs/domain/repositories/search.dart @@ -13,12 +13,13 @@ class BangSearch extends _$BangSearch { Future search(String input) async { if (input.isNotEmpty) { - await ref - .read(bangDatabaseProvider) - .bangDao - .queryBangs(input) - .get() - .then(_streamController.add); + await ref.read(bangDatabaseProvider).bangDao.queryBangs(input).get().then( + (value) { + if (!_streamController.isClosed) { + _streamController.add(value); + } + }, + ); } } diff --git a/app/lib/features/bangs/domain/repositories/search.g.dart b/app/lib/features/bangs/domain/repositories/search.g.dart index 82d09180..ac09eab8 100644 --- a/app/lib/features/bangs/domain/repositories/search.g.dart +++ b/app/lib/features/bangs/domain/repositories/search.g.dart @@ -6,7 +6,7 @@ part of 'search.dart'; // RiverpodGenerator // ************************************************************************** -String _$bangSearchHash() => r'632ddd09a1198394884cb5c27a2bde7850c0515b'; +String _$bangSearchHash() => r'abf9ff5badf2ecfbcbde148fd2c849beac83bec4'; /// See also [BangSearch]. @ProviderFor(BangSearch) diff --git a/app/lib/features/bangs/presentation/widgets/bang_details.dart b/app/lib/features/bangs/presentation/widgets/bang_details.dart index 0ad01526..d8b66849 100644 --- a/app/lib/features/bangs/presentation/widgets/bang_details.dart +++ b/app/lib/features/bangs/presentation/widgets/bang_details.dart @@ -3,7 +3,7 @@ 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/presentation/widgets/bang_icon.dart'; +import 'package:lensai/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; class BangDetails extends HookConsumerWidget { @@ -38,7 +38,7 @@ class BangDetails extends HookConsumerWidget { children: [ Row( children: [ - BangIcon(bangData), + UrlIcon(bangData.getUrl(''), iconSize: 34.0), const SizedBox(width: 12.0), Expanded( child: Column( diff --git a/app/lib/features/bangs/presentation/widgets/bang_icon.dart b/app/lib/features/bangs/presentation/widgets/bang_icon.dart deleted file mode 100644 index 5613d89b..00000000 --- a/app/lib/features/bangs/presentation/widgets/bang_icon.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.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:skeletonizer/skeletonizer.dart'; - -class BangIcon extends HookConsumerWidget { - final double iconSize; - final BangData bangData; - - const BangIcon(this.bangData, {this.iconSize = 34.0, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final bangAsync = ref.watch(bangDataEnsureIconProvider(bangData)); - final bang = bangAsync.valueOrNull ?? bangData; - - return Skeletonizer( - enabled: bangAsync.isLoading, - child: SizedBox.square( - dimension: iconSize, - child: (bang.icon != null) - ? RawImage( - image: bang.icon?.image.value, - height: iconSize, - width: iconSize, - fit: BoxFit.fill, - ) - : Icon( - MdiIcons.web, - size: iconSize, - ), - ), - ); - } -} diff --git a/app/lib/features/bangs/presentation/widgets/site_search.dart b/app/lib/features/bangs/presentation/widgets/site_search.dart index 1d10aece..021e7133 100644 --- a/app/lib/features/bangs/presentation/widgets/site_search.dart +++ b/app/lib/features/bangs/presentation/widgets/site_search.dart @@ -5,13 +5,13 @@ 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/search.dart'; -import 'package:lensai/features/bangs/presentation/widgets/bang_icon.dart'; import 'package:lensai/features/geckoview/domain/repositories/tab.dart'; import 'package:lensai/features/geckoview/features/browser/domain/providers.dart'; -import 'package:lensai/features/search/domain/providers/search_suggestions.dart'; -import 'package:lensai/features/search/presentation/widgets/search_field.dart'; -import 'package:lensai/features/search/presentation/widgets/search_suggestion_list.dart'; +import 'package:lensai/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_field.dart'; +import 'package:lensai/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart'; +import 'package:lensai/presentation/widgets/bang_icon.dart'; import 'package:lensai/presentation/widgets/selectable_chips.dart'; class SiteSearch extends HookConsumerWidget { @@ -70,7 +70,7 @@ class SiteSearch extends HookConsumerWidget { width: double.maxFinite, child: SelectableChips( itemId: (bang) => bang.trigger, - itemAvatar: (bang) => BangIcon(bang, iconSize: 20), + itemAvatar: (bang) => UrlIcon(bang.getUrl(''), iconSize: 20), itemLabel: (bang) => Text(bang.websiteName), availableItems: availableBangs, selectedItem: selectedBang, @@ -98,9 +98,10 @@ class SiteSearch extends HookConsumerWidget { SearchField( textEditingController: searchTextController, activeBang: activeBang, - onFieldSubmitted: (_) async { + onSubmitted: (_) async { await submitSearch(searchTextController.text); }, + showSuggestions: false, ), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 150), @@ -111,10 +112,12 @@ class SiteSearch extends HookConsumerWidget { shrinkWrap: true, controller: controller, slivers: [ - SearchSuggestionList( + SearchTermSuggestions( searchTextController: searchTextController, + activeBang: activeBang, submitSearch: submitSearch, showHistory: false, + showChips: false, ), ], ); diff --git a/app/lib/features/chat_archive/domain/repositories/search.dart b/app/lib/features/chat_archive/domain/repositories/search.dart index 295f5012..4d464815 100644 --- a/app/lib/features/chat_archive/domain/repositories/search.dart +++ b/app/lib/features/chat_archive/domain/repositories/search.dart @@ -74,7 +74,13 @@ class ChatArchiveSearchRepository extends _$ChatArchiveSearchRepository { ellipsis: ellipsis, ) .get() - .then(_streamController.add); + .then( + (value) { + if (!_streamController.isClosed) { + _streamController.add(value); + } + }, + ); } } diff --git a/app/lib/features/chat_archive/domain/repositories/search.g.dart b/app/lib/features/chat_archive/domain/repositories/search.g.dart index da8e8236..f4f7e975 100644 --- a/app/lib/features/chat_archive/domain/repositories/search.g.dart +++ b/app/lib/features/chat_archive/domain/repositories/search.g.dart @@ -7,7 +7,7 @@ part of 'search.dart'; // ************************************************************************** String _$chatArchiveSearchRepositoryHash() => - r'6762958d43d2efc95f5d9848bfcae41436564f2b'; + r'407023f1ee8683f21d4263dccc9eab1d7a40278e'; /// See also [ChatArchiveSearchRepository]. @ProviderFor(ChatArchiveSearchRepository) diff --git a/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart b/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart index bef1c226..289e9401 100644 --- a/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart +++ b/app/lib/features/geckoview/domain/controllers/bottom_sheet.dart @@ -29,7 +29,9 @@ class BottomSheetExtend extends _$BottomSheetExtend { late StreamController _extentStreamController; void add(double extent) { - _extentStreamController.add(extent); + if (!_extentStreamController.isClosed) { + _extentStreamController.add(extent); + } } @override diff --git a/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart b/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart index c3ad5cc2..53b6b562 100644 --- a/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart +++ b/app/lib/features/geckoview/domain/controllers/bottom_sheet.g.dart @@ -23,7 +23,7 @@ final bottomSheetControllerProvider = ); typedef _$BottomSheetController = AutoDisposeNotifier; -String _$bottomSheetExtendHash() => r'81ae254499d1d7f57309274b0f18747b94d6dda3'; +String _$bottomSheetExtendHash() => r'7e11b9047c15bdeb4dfcb488a8573daadb64e25c'; /// See also [BottomSheetExtend]. @ProviderFor(BottomSheetExtend) diff --git a/app/lib/features/geckoview/domain/providers.dart b/app/lib/features/geckoview/domain/providers.dart index e7f03d27..b73957b4 100644 --- a/app/lib/features/geckoview/domain/providers.dart +++ b/app/lib/features/geckoview/domain/providers.dart @@ -107,6 +107,17 @@ GeckoTabContentService tabContentService(Ref ref) { return service; } +@Riverpod(keepAlive: true) +GeckoSuggestionsService engineSuggestionsService(Ref ref) { + final service = GeckoSuggestionsService.setUp(); + + ref.onDispose(() { + service.dispose(); + }); + + return service; +} + @Riverpod(keepAlive: true) class EngineReadyState extends _$EngineReadyState { @override diff --git a/app/lib/features/geckoview/domain/providers.g.dart b/app/lib/features/geckoview/domain/providers.g.dart index 39260be1..d8ed4af6 100644 --- a/app/lib/features/geckoview/domain/providers.g.dart +++ b/app/lib/features/geckoview/domain/providers.g.dart @@ -74,6 +74,25 @@ final tabContentServiceProvider = Provider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TabContentServiceRef = ProviderRef; +String _$engineSuggestionsServiceHash() => + r'f7414b335564578b2c7f6a86baf8bf13f2d5ba2d'; + +/// See also [engineSuggestionsService]. +@ProviderFor(engineSuggestionsService) +final engineSuggestionsServiceProvider = + Provider.internal( + engineSuggestionsService, + name: r'engineSuggestionsServiceProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSuggestionsServiceHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EngineSuggestionsServiceRef = ProviderRef; String _$selectedTabSessionNotifierHash() => r'aef19991d5b05a2bb28e8a6ccb57dc75c2f3148f'; diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index 1bc6dd9e..e81b3338 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -3,9 +3,14 @@ import 'dart:async'; import 'package:lensai/data/models/equatable_iterable.dart'; +import 'package:lensai/extensions/nullable.dart'; import 'package:lensai/features/bangs/data/models/bang_data.dart'; import 'package:lensai/features/bangs/domain/repositories/data.dart'; +import 'package:lensai/features/geckoview/domain/entities/tab_state.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_list.dart'; +import 'package:lensai/features/geckoview/domain/providers/tab_state.dart'; +import 'package:lensai/features/geckoview/features/search/domain/entities/tab_preview.dart'; +import 'package:lensai/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:lensai/features/kagi/data/entities/modes.dart'; @@ -87,27 +92,48 @@ class ShowFindInPage extends _$ShowFindInPage { } @Riverpod() -List availableTabIds( +EquatableCollection> availableTabIds( Ref ref, - String? containerId, + ContainerFilter containerFilter, ) { final containerTabs = ref.watch( - containerTabIdsProvider(containerId).select((value) => value.valueOrNull), + containerTabIdsProvider(containerFilter) + .select((value) => value.valueOrNull), ); - final tabStates = ref.watch(tabListProvider); + final tabList = ref.watch(tabListProvider); - return containerTabs?.where((tabId) => tabStates.contains(tabId)).toList() ?? - []; + return EquatableCollection( + containerTabs?.where((tabId) => tabList.contains(tabId)).toList() ?? [], + immutable: true, + ); } @Riverpod() -List seamlessFilteredTabs( +EquatableCollection> availableTabStates( Ref ref, - String? containerId, + ContainerFilter containerFilter, +) { + final availableTabs = ref.watch(availableTabIdsProvider(containerFilter)); + final tabStates = ref.watch(tabStatesProvider); + + return EquatableCollection( + { + for (final tabId in availableTabs.collection) + if (tabStates.containsKey(tabId)) tabId: tabStates[tabId]!, + }, + immutable: true, + ); +} + +@Riverpod() +EquatableCollection> seamlessFilteredTabIds( + Ref ref, + TabSearchPartition searchPartition, + ContainerFilter containerFilter, ) { final tabSearchResults = ref .watch( - tabSearchRepositoryProvider.select( + tabSearchRepositoryProvider(searchPartition).select( (value) => EquatableCollection( value.valueOrNull?.map((tab) => tab.id).toList(), immutable: true, @@ -116,17 +142,74 @@ List seamlessFilteredTabs( ) .collection; - final availableTabs = ref - .watch( - availableTabIdsProvider(containerId).select( - (value) => EquatableCollection(value, immutable: true), - ), - ) - .collection; + final availableTabs = ref.watch(availableTabIdsProvider(containerFilter)); if (tabSearchResults == null) { return availableTabs; } - return tabSearchResults.where((tab) => availableTabs.contains(tab)).toList(); + return EquatableCollection( + tabSearchResults + .where((tab) => availableTabs.collection.contains(tab)) + .toList(), + immutable: true, + ); +} + +@Riverpod() +EquatableCollection> seamlessFilteredTabPreviews( + Ref ref, + TabSearchPartition searchPartition, + ContainerFilter containerFilter, +) { + final tabSearchResults = ref + .watch( + tabSearchRepositoryProvider(searchPartition).select( + (value) => EquatableCollection( + value.valueOrNull, + immutable: true, + ), + ), + ) + .collection; + + final availableTabStates = + ref.watch(availableTabStatesProvider(containerFilter)); + + if (tabSearchResults == null) { + return EquatableCollection( + availableTabStates.collection.values + .map( + (state) => TabPreview( + id: state.id, + title: state.title, + icon: state.icon, + url: state.url, + highlightedUrl: null, + content: null, + ), + ) + .toList(), + immutable: true, + ); + } + + return EquatableCollection( + tabSearchResults + .where((tab) => availableTabStates.collection.containsKey(tab.id)) + .map((tab) { + return TabPreview( + id: tab.id, + title: tab.title ?? availableTabStates.collection[tab.id]!.title, + icon: null, + url: tab.cleanUrl.mapNotNull(Uri.tryParse) ?? + availableTabStates.collection[tab.id]!.url, + highlightedUrl: tab.url, + content: tab.extractedContent ?? tab.fullContent, + ); + }) + .whereType() + .toList(), + immutable: true, + ); } diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart index 0d19b141..957d509f 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -158,23 +158,23 @@ class _SelectedBangDataProviderElement String? get domain => (origin as SelectedBangDataProvider).domain; } -String _$availableTabIdsHash() => r'646d42c747d815496849c7342903caf48258a8ab'; +String _$availableTabIdsHash() => r'd8b55b012b38111c8d5e069987a55645d1a71b89'; /// See also [availableTabIds]. @ProviderFor(availableTabIds) const availableTabIdsProvider = AvailableTabIdsFamily(); /// See also [availableTabIds]. -class AvailableTabIdsFamily extends Family> { +class AvailableTabIdsFamily extends Family>> { /// See also [availableTabIds]. const AvailableTabIdsFamily(); /// See also [availableTabIds]. AvailableTabIdsProvider call( - String? containerId, + ContainerFilter containerFilter, ) { return AvailableTabIdsProvider( - containerId, + containerFilter, ); } @@ -183,7 +183,7 @@ class AvailableTabIdsFamily extends Family> { covariant AvailableTabIdsProvider provider, ) { return call( - provider.containerId, + provider.containerFilter, ); } @@ -203,14 +203,15 @@ class AvailableTabIdsFamily extends Family> { } /// See also [availableTabIds]. -class AvailableTabIdsProvider extends AutoDisposeProvider> { +class AvailableTabIdsProvider + extends AutoDisposeProvider>> { /// See also [availableTabIds]. AvailableTabIdsProvider( - String? containerId, + ContainerFilter containerFilter, ) : this._internal( (ref) => availableTabIds( ref as AvailableTabIdsRef, - containerId, + containerFilter, ), from: availableTabIdsProvider, name: r'availableTabIdsProvider', @@ -221,7 +222,7 @@ class AvailableTabIdsProvider extends AutoDisposeProvider> { dependencies: AvailableTabIdsFamily._dependencies, allTransitiveDependencies: AvailableTabIdsFamily._allTransitiveDependencies, - containerId: containerId, + containerFilter: containerFilter, ); AvailableTabIdsProvider._internal( @@ -231,14 +232,15 @@ class AvailableTabIdsProvider extends AutoDisposeProvider> { required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.containerId, + required this.containerFilter, }) : super.internal(); - final String? containerId; + final ContainerFilter containerFilter; @override Override overrideWith( - List Function(AvailableTabIdsRef provider) create, + EquatableCollection> Function(AvailableTabIdsRef provider) + create, ) { return ProviderOverride( origin: this, @@ -249,25 +251,27 @@ class AvailableTabIdsProvider extends AutoDisposeProvider> { dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - containerId: containerId, + containerFilter: containerFilter, ), ); } @override - AutoDisposeProviderElement> createElement() { + AutoDisposeProviderElement>> + createElement() { return _AvailableTabIdsProviderElement(this); } @override bool operator ==(Object other) { - return other is AvailableTabIdsProvider && other.containerId == containerId; + return other is AvailableTabIdsProvider && + other.containerFilter == containerFilter; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, containerId.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); return _SystemHash.finish(hash); } @@ -275,46 +279,50 @@ class AvailableTabIdsProvider extends AutoDisposeProvider> { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin AvailableTabIdsRef on AutoDisposeProviderRef> { - /// The parameter `containerId` of this provider. - String? get containerId; +mixin AvailableTabIdsRef + on AutoDisposeProviderRef>> { + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; } class _AvailableTabIdsProviderElement - extends AutoDisposeProviderElement> with AvailableTabIdsRef { + extends AutoDisposeProviderElement>> + with AvailableTabIdsRef { _AvailableTabIdsProviderElement(super.provider); @override - String? get containerId => (origin as AvailableTabIdsProvider).containerId; + ContainerFilter get containerFilter => + (origin as AvailableTabIdsProvider).containerFilter; } -String _$seamlessFilteredTabsHash() => - r'1abfb0e9ae834cdde5ab64b6174f24f45f36a272'; +String _$availableTabStatesHash() => + r'ca42dc8cb88070fb58ab085ab6c9bcbf31ef4f0b'; -/// See also [seamlessFilteredTabs]. -@ProviderFor(seamlessFilteredTabs) -const seamlessFilteredTabsProvider = SeamlessFilteredTabsFamily(); +/// See also [availableTabStates]. +@ProviderFor(availableTabStates) +const availableTabStatesProvider = AvailableTabStatesFamily(); -/// See also [seamlessFilteredTabs]. -class SeamlessFilteredTabsFamily extends Family> { - /// See also [seamlessFilteredTabs]. - const SeamlessFilteredTabsFamily(); +/// See also [availableTabStates]. +class AvailableTabStatesFamily + extends Family>> { + /// See also [availableTabStates]. + const AvailableTabStatesFamily(); - /// See also [seamlessFilteredTabs]. - SeamlessFilteredTabsProvider call( - String? containerId, + /// See also [availableTabStates]. + AvailableTabStatesProvider call( + ContainerFilter containerFilter, ) { - return SeamlessFilteredTabsProvider( - containerId, + return AvailableTabStatesProvider( + containerFilter, ); } @override - SeamlessFilteredTabsProvider getProviderOverride( - covariant SeamlessFilteredTabsProvider provider, + AvailableTabStatesProvider getProviderOverride( + covariant AvailableTabStatesProvider provider, ) { return call( - provider.containerId, + provider.containerFilter, ); } @@ -330,76 +338,80 @@ class SeamlessFilteredTabsFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'seamlessFilteredTabsProvider'; + String? get name => r'availableTabStatesProvider'; } -/// See also [seamlessFilteredTabs]. -class SeamlessFilteredTabsProvider extends AutoDisposeProvider> { - /// See also [seamlessFilteredTabs]. - SeamlessFilteredTabsProvider( - String? containerId, +/// See also [availableTabStates]. +class AvailableTabStatesProvider + extends AutoDisposeProvider>> { + /// See also [availableTabStates]. + AvailableTabStatesProvider( + ContainerFilter containerFilter, ) : this._internal( - (ref) => seamlessFilteredTabs( - ref as SeamlessFilteredTabsRef, - containerId, + (ref) => availableTabStates( + ref as AvailableTabStatesRef, + containerFilter, ), - from: seamlessFilteredTabsProvider, - name: r'seamlessFilteredTabsProvider', + from: availableTabStatesProvider, + name: r'availableTabStatesProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$seamlessFilteredTabsHash, - dependencies: SeamlessFilteredTabsFamily._dependencies, + : _$availableTabStatesHash, + dependencies: AvailableTabStatesFamily._dependencies, allTransitiveDependencies: - SeamlessFilteredTabsFamily._allTransitiveDependencies, - containerId: containerId, + AvailableTabStatesFamily._allTransitiveDependencies, + containerFilter: containerFilter, ); - SeamlessFilteredTabsProvider._internal( + AvailableTabStatesProvider._internal( super._createNotifier, { required super.name, required super.dependencies, required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.containerId, + required this.containerFilter, }) : super.internal(); - final String? containerId; + final ContainerFilter containerFilter; @override Override overrideWith( - List Function(SeamlessFilteredTabsRef provider) create, + EquatableCollection> Function( + AvailableTabStatesRef provider) + create, ) { return ProviderOverride( origin: this, - override: SeamlessFilteredTabsProvider._internal( - (ref) => create(ref as SeamlessFilteredTabsRef), + override: AvailableTabStatesProvider._internal( + (ref) => create(ref as AvailableTabStatesRef), from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - containerId: containerId, + containerFilter: containerFilter, ), ); } @override - AutoDisposeProviderElement> createElement() { - return _SeamlessFilteredTabsProviderElement(this); + AutoDisposeProviderElement>> + createElement() { + return _AvailableTabStatesProviderElement(this); } @override bool operator ==(Object other) { - return other is SeamlessFilteredTabsProvider && - other.containerId == containerId; + return other is AvailableTabStatesProvider && + other.containerFilter == containerFilter; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, containerId.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); return _SystemHash.finish(hash); } @@ -407,19 +419,333 @@ class SeamlessFilteredTabsProvider extends AutoDisposeProvider> { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin SeamlessFilteredTabsRef on AutoDisposeProviderRef> { - /// The parameter `containerId` of this provider. - String? get containerId; +mixin AvailableTabStatesRef + on AutoDisposeProviderRef>> { + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; } -class _SeamlessFilteredTabsProviderElement - extends AutoDisposeProviderElement> - with SeamlessFilteredTabsRef { - _SeamlessFilteredTabsProviderElement(super.provider); +class _AvailableTabStatesProviderElement extends AutoDisposeProviderElement< + EquatableCollection>> with AvailableTabStatesRef { + _AvailableTabStatesProviderElement(super.provider); @override - String? get containerId => - (origin as SeamlessFilteredTabsProvider).containerId; + ContainerFilter get containerFilter => + (origin as AvailableTabStatesProvider).containerFilter; +} + +String _$seamlessFilteredTabIdsHash() => + r'2e29ca53c35be89972d3f9aea24198fe1feb9e05'; + +/// See also [seamlessFilteredTabIds]. +@ProviderFor(seamlessFilteredTabIds) +const seamlessFilteredTabIdsProvider = SeamlessFilteredTabIdsFamily(); + +/// See also [seamlessFilteredTabIds]. +class SeamlessFilteredTabIdsFamily + extends Family>> { + /// See also [seamlessFilteredTabIds]. + const SeamlessFilteredTabIdsFamily(); + + /// See also [seamlessFilteredTabIds]. + SeamlessFilteredTabIdsProvider call( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) { + return SeamlessFilteredTabIdsProvider( + searchPartition, + containerFilter, + ); + } + + @override + SeamlessFilteredTabIdsProvider getProviderOverride( + covariant SeamlessFilteredTabIdsProvider provider, + ) { + return call( + provider.searchPartition, + provider.containerFilter, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'seamlessFilteredTabIdsProvider'; +} + +/// See also [seamlessFilteredTabIds]. +class SeamlessFilteredTabIdsProvider + extends AutoDisposeProvider>> { + /// See also [seamlessFilteredTabIds]. + SeamlessFilteredTabIdsProvider( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) : this._internal( + (ref) => seamlessFilteredTabIds( + ref as SeamlessFilteredTabIdsRef, + searchPartition, + containerFilter, + ), + from: seamlessFilteredTabIdsProvider, + name: r'seamlessFilteredTabIdsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessFilteredTabIdsHash, + dependencies: SeamlessFilteredTabIdsFamily._dependencies, + allTransitiveDependencies: + SeamlessFilteredTabIdsFamily._allTransitiveDependencies, + searchPartition: searchPartition, + containerFilter: containerFilter, + ); + + SeamlessFilteredTabIdsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchPartition, + required this.containerFilter, + }) : super.internal(); + + final TabSearchPartition searchPartition; + final ContainerFilter containerFilter; + + @override + Override overrideWith( + EquatableCollection> Function( + SeamlessFilteredTabIdsRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: SeamlessFilteredTabIdsProvider._internal( + (ref) => create(ref as SeamlessFilteredTabIdsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchPartition: searchPartition, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeProviderElement>> + createElement() { + return _SeamlessFilteredTabIdsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SeamlessFilteredTabIdsProvider && + other.searchPartition == searchPartition && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchPartition.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SeamlessFilteredTabIdsRef + on AutoDisposeProviderRef>> { + /// The parameter `searchPartition` of this provider. + TabSearchPartition get searchPartition; + + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _SeamlessFilteredTabIdsProviderElement + extends AutoDisposeProviderElement>> + with SeamlessFilteredTabIdsRef { + _SeamlessFilteredTabIdsProviderElement(super.provider); + + @override + TabSearchPartition get searchPartition => + (origin as SeamlessFilteredTabIdsProvider).searchPartition; + @override + ContainerFilter get containerFilter => + (origin as SeamlessFilteredTabIdsProvider).containerFilter; +} + +String _$seamlessFilteredTabPreviewsHash() => + r'7a70eb2f3f59de5188d94a73790e39c339a6293b'; + +/// See also [seamlessFilteredTabPreviews]. +@ProviderFor(seamlessFilteredTabPreviews) +const seamlessFilteredTabPreviewsProvider = SeamlessFilteredTabPreviewsFamily(); + +/// See also [seamlessFilteredTabPreviews]. +class SeamlessFilteredTabPreviewsFamily + extends Family>> { + /// See also [seamlessFilteredTabPreviews]. + const SeamlessFilteredTabPreviewsFamily(); + + /// See also [seamlessFilteredTabPreviews]. + SeamlessFilteredTabPreviewsProvider call( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) { + return SeamlessFilteredTabPreviewsProvider( + searchPartition, + containerFilter, + ); + } + + @override + SeamlessFilteredTabPreviewsProvider getProviderOverride( + covariant SeamlessFilteredTabPreviewsProvider provider, + ) { + return call( + provider.searchPartition, + provider.containerFilter, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'seamlessFilteredTabPreviewsProvider'; +} + +/// See also [seamlessFilteredTabPreviews]. +class SeamlessFilteredTabPreviewsProvider + extends AutoDisposeProvider>> { + /// See also [seamlessFilteredTabPreviews]. + SeamlessFilteredTabPreviewsProvider( + TabSearchPartition searchPartition, + ContainerFilter containerFilter, + ) : this._internal( + (ref) => seamlessFilteredTabPreviews( + ref as SeamlessFilteredTabPreviewsRef, + searchPartition, + containerFilter, + ), + from: seamlessFilteredTabPreviewsProvider, + name: r'seamlessFilteredTabPreviewsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$seamlessFilteredTabPreviewsHash, + dependencies: SeamlessFilteredTabPreviewsFamily._dependencies, + allTransitiveDependencies: + SeamlessFilteredTabPreviewsFamily._allTransitiveDependencies, + searchPartition: searchPartition, + containerFilter: containerFilter, + ); + + SeamlessFilteredTabPreviewsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.searchPartition, + required this.containerFilter, + }) : super.internal(); + + final TabSearchPartition searchPartition; + final ContainerFilter containerFilter; + + @override + Override overrideWith( + EquatableCollection> Function( + SeamlessFilteredTabPreviewsRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: SeamlessFilteredTabPreviewsProvider._internal( + (ref) => create(ref as SeamlessFilteredTabPreviewsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + searchPartition: searchPartition, + containerFilter: containerFilter, + ), + ); + } + + @override + AutoDisposeProviderElement>> + createElement() { + return _SeamlessFilteredTabPreviewsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SeamlessFilteredTabPreviewsProvider && + other.searchPartition == searchPartition && + other.containerFilter == containerFilter; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, searchPartition.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SeamlessFilteredTabPreviewsRef + on AutoDisposeProviderRef>> { + /// The parameter `searchPartition` of this provider. + TabSearchPartition get searchPartition; + + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; +} + +class _SeamlessFilteredTabPreviewsProviderElement + extends AutoDisposeProviderElement>> + with SeamlessFilteredTabPreviewsRef { + _SeamlessFilteredTabPreviewsProviderElement(super.provider); + + @override + TabSearchPartition get searchPartition => + (origin as SeamlessFilteredTabPreviewsProvider).searchPartition; + @override + ContainerFilter get containerFilter => + (origin as SeamlessFilteredTabPreviewsProvider).containerFilter; } String _$selectedBangTriggerHash() => diff --git a/app/lib/features/geckoview/features/browser/domain/services/create_tab.dart b/app/lib/features/geckoview/features/browser/domain/services/create_tab.dart index afc6dd54..894da417 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/create_tab.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/create_tab.dart @@ -48,6 +48,8 @@ class CreateTabStream extends _$CreateTabStream { } void createTab(CreateTabSheet parameter) { - _streamController.add(parameter); + if (!_streamController.isClosed) { + _streamController.add(parameter); + } } } diff --git a/app/lib/features/geckoview/features/browser/domain/services/create_tab.g.dart b/app/lib/features/geckoview/features/browser/domain/services/create_tab.g.dart index fa9f416c..fc65f9f0 100644 --- a/app/lib/features/geckoview/features/browser/domain/services/create_tab.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/services/create_tab.g.dart @@ -6,7 +6,7 @@ part of 'create_tab.dart'; // RiverpodGenerator // ************************************************************************** -String _$createTabStreamHash() => r'05ad914e41e0d346e082e4638afe5fe5a04052f1'; +String _$createTabStreamHash() => r'2e802a10ecaf1862074f32b76e89c26997bdd7b2'; /// See also [CreateTabStream]. @ProviderFor(CreateTabStream) diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart index 55b2a891..a1acdb64 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart @@ -7,15 +7,13 @@ import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lensai/core/providers.dart'; import 'package:lensai/data/models/drag_data.dart'; -import 'package:lensai/data/models/equatable_iterable.dart'; -import 'package:lensai/features/geckoview/domain/controllers/overlay_dialog.dart'; import 'package:lensai/features/geckoview/domain/providers/selected_tab.dart'; import 'package:lensai/features/geckoview/domain/providers/tab_state.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/browser/presentation/dialogs/tab_action.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; import 'package:lensai/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; +import 'package:lensai/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/tab.dart'; @@ -94,7 +92,9 @@ class _Tab extends HookConsumerWidget { searchTextController, () async { await ref - .read(tabSearchRepositoryProvider.notifier) + .read( + tabSearchRepositoryProvider(TabSearchPartition.preview).notifier, + ) .addQuery(searchTextController.text); }, ); @@ -248,14 +248,12 @@ class ViewTabsSheetWidget extends HookConsumerWidget { builder: (context, ref, child) { final container = ref.watch(selectedContainerProvider); - final filteredTabs = ref - .watch( - seamlessFilteredTabsProvider(container).select( - (value) => - EquatableCollection(value, immutable: true), - ), - ) - .collection; + final filteredTabIds = ref.watch( + seamlessFilteredTabIdsProvider( + TabSearchPartition.preview, + ContainerFilterById(containerId: container), + ), + ); final activeTab = ref.watch(selectedTabProvider); @@ -273,7 +271,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { useEffect( () { - final index = filteredTabs + final index = filteredTabIds.collection .indexWhere((webView) => webView == activeTab); if (index > -1) { @@ -292,12 +290,12 @@ class ViewTabsSheetWidget extends HookConsumerWidget { return null; }, - [filteredTabs, activeTab], + [filteredTabIds, activeTab], ); final tabs = useMemoized( () { - return filteredTabs + return filteredTabIds.collection .mapIndexed( (index, tabId) => CustomDraggable( key: Key(tabId), @@ -350,7 +348,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { .toList(); }, [ - EquatableCollection(filteredTabs, immutable: true), + filteredTabIds, activeTab, ], ); @@ -375,7 +373,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget { final containerRepository = ref.read(containerRepositoryProvider.notifier); - final tabId = filteredTabs[oldIndex]; + final tabId = filteredTabIds.collection[oldIndex]; final containerId = await ref .read(tabDataRepositoryProvider.notifier) .containerTabId(tabId); @@ -384,13 +382,14 @@ class ViewTabsSheetWidget extends HookConsumerWidget { if (newIndex <= 0) { key = await containerRepository .getLeadingOrderKey(containerId); - } else if (newIndex >= filteredTabs.length - 1) { + } else if (newIndex >= + filteredTabIds.collection.length - 1) { key = await containerRepository .getTrailingOrderKey(containerId); } else { final orderAfterIndex = newIndex; key = await containerRepository.getOrderKeyAfterTab( - filteredTabs[orderAfterIndex], + filteredTabIds.collection[orderAfterIndex], containerId, ); } diff --git a/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart new file mode 100644 index 00000000..e665fdf6 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/entities/tab_preview.dart @@ -0,0 +1,36 @@ +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:lensai/domain/entities/equatable_image.dart'; + +class TabPreview with FastEquatable { + final String id; + + final String title; + final EquatableImage? icon; + + final Uri url; + final String? highlightedUrl; + + final String? content; + + TabPreview({ + required this.id, + required this.title, + required this.icon, + required this.url, + required this.highlightedUrl, + required this.content, + }); + + @override + List get hashParameters => [ + id, + title, + icon, + url, + highlightedUrl, + content, + ]; + + @override + bool get cacheHash => true; +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart new file mode 100644 index 00000000..d538136c --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart @@ -0,0 +1,55 @@ +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:lensai/extensions/nullable.dart'; +import 'package:lensai/features/geckoview/domain/providers.dart'; +import 'package:riverpod/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'engine_suggestions.g.dart'; + +@Riverpod() +class EngineSuggestions extends _$EngineSuggestions { + Future getAutocompleteSuggestion(String query) { + return ref + .read(engineSuggestionsServiceProvider) + .getAutocompleteSuggestion(query) + .then((result) => result?.text); + } + + Future addQuery( + String query, { + List providers = const [ + GeckoSuggestionType.history, + ], + }) { + return ref + .read(engineSuggestionsServiceProvider) + .querySuggestions(query, providers: providers); + } + + @override + Stream> build() { + final service = ref.watch(engineSuggestionsServiceProvider); + return ConcatStream([Stream.value([]), service.suggestionsStream]); + } +} + +@Riverpod() +AsyncValue> engineHistorySuggestions(Ref ref) { + return ref.watch( + engineSuggestionsProvider.select( + (suggestions) => suggestions.whenData( + (suggestions) => suggestions + .where( + (suggestion) => + suggestion.type == GeckoSuggestionType.history && + (suggestion.title?.isNotEmpty ?? false) && + (suggestion.description + .mapNotNull((url) => Uri.tryParse(url)) != + null), + ) + .toList(), + ), + ), + ); +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart new file mode 100644 index 00000000..6ff9bc3e --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'engine_suggestions.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$engineHistorySuggestionsHash() => + r'218f1da2140a76c3b02634619955b1382c6ef182'; + +/// See also [engineHistorySuggestions]. +@ProviderFor(engineHistorySuggestions) +final engineHistorySuggestionsProvider = + AutoDisposeProvider>>.internal( + engineHistorySuggestions, + name: r'engineHistorySuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineHistorySuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EngineHistorySuggestionsRef + = AutoDisposeProviderRef>>; +String _$engineSuggestionsHash() => r'ba2c2d7f0a5e99fd9e639acd020b8cf3016c6126'; + +/// See also [EngineSuggestions]. +@ProviderFor(EngineSuggestions) +final engineSuggestionsProvider = AutoDisposeStreamNotifierProvider< + EngineSuggestions, List>.internal( + EngineSuggestions.new, + name: r'engineSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$engineSuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$EngineSuggestions = AutoDisposeStreamNotifier>; +// 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 diff --git a/app/lib/features/search/domain/providers/search_suggestions.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart similarity index 94% rename from app/lib/features/search/domain/providers/search_suggestions.dart rename to app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart index bf95d52a..affaea3f 100644 --- a/app/lib/features/search/domain/providers/search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.dart @@ -11,14 +11,14 @@ ISearchSuggestionProvider defaultSearchSuggestions(Ref ref) { return ref.watch(kagiAutosuggestServiceProvider.notifier); } -@Riverpod(keepAlive: true) +@Riverpod() class SearchSuggestions extends _$SearchSuggestions { late void Function(String query) _addQueryBinding; void addQuery(String query) => _addQueryBinding(query); @override - Raw>> build({ + Stream> build({ ISearchSuggestionProvider? suggestionsProvider, }) { final defaultProvider = ref.watch(defaultSearchSuggestionsProvider); diff --git a/app/lib/features/search/domain/providers/search_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart similarity index 88% rename from app/lib/features/search/domain/providers/search_suggestions.g.dart rename to app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart index cce5552a..000570e5 100644 --- a/app/lib/features/search/domain/providers/search_suggestions.g.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions.g.dart @@ -25,7 +25,7 @@ final defaultSearchSuggestionsProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef DefaultSearchSuggestionsRef = ProviderRef; -String _$searchSuggestionsHash() => r'ecc378aca325cc6759292a06d8d4d5ace910eb7f'; +String _$searchSuggestionsHash() => r'8d1fb72cab374491a53f7a814f87af6a26356f42'; /// Copied from Dart SDK class _SystemHash { @@ -49,10 +49,10 @@ class _SystemHash { } abstract class _$SearchSuggestions - extends BuildlessNotifier>>> { + extends BuildlessAutoDisposeStreamNotifier> { late final ISearchSuggestionProvider? suggestionsProvider; - Raw>> build({ + Stream> build({ ISearchSuggestionProvider? suggestionsProvider, }); } @@ -62,7 +62,7 @@ abstract class _$SearchSuggestions const searchSuggestionsProvider = SearchSuggestionsFamily(); /// See also [SearchSuggestions]. -class SearchSuggestionsFamily extends Family>>> { +class SearchSuggestionsFamily extends Family>> { /// See also [SearchSuggestions]. const SearchSuggestionsFamily(); @@ -100,8 +100,8 @@ class SearchSuggestionsFamily extends Family>>> { } /// See also [SearchSuggestions]. -class SearchSuggestionsProvider - extends NotifierProviderImpl>>> { +class SearchSuggestionsProvider extends AutoDisposeStreamNotifierProviderImpl< + SearchSuggestions, List> { /// See also [SearchSuggestions]. SearchSuggestionsProvider({ ISearchSuggestionProvider? suggestionsProvider, @@ -132,7 +132,7 @@ class SearchSuggestionsProvider final ISearchSuggestionProvider? suggestionsProvider; @override - Raw>> runNotifierBuild( + Stream> runNotifierBuild( covariant SearchSuggestions notifier, ) { return notifier.build( @@ -157,7 +157,7 @@ class SearchSuggestionsProvider } @override - NotifierProviderElement>>> + AutoDisposeStreamNotifierProviderElement> createElement() { return _SearchSuggestionsProviderElement(this); } @@ -179,13 +179,15 @@ class SearchSuggestionsProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin SearchSuggestionsRef on NotifierProviderRef>>> { +mixin SearchSuggestionsRef + on AutoDisposeStreamNotifierProviderRef> { /// The parameter `suggestionsProvider` of this provider. ISearchSuggestionProvider? get suggestionsProvider; } -class _SearchSuggestionsProviderElement extends NotifierProviderElement< - SearchSuggestions, Raw>>> with SearchSuggestionsRef { +class _SearchSuggestionsProviderElement + extends AutoDisposeStreamNotifierProviderElement> with SearchSuggestionsRef { _SearchSuggestionsProviderElement(super.provider); @override diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart new file mode 100644 index 00000000..f6b14878 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -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()); + + final searchTextController = + useTextEditingController(text: initialSearchText); + final searchFocusNode = useFocusNode(); + + final defaultSearchBang = ref.watch( + defaultSearchBangDataProvider.select((value) => value.valueOrNull), + ); + + final selectedBang = useState(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 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, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/search/presentation/widgets/bang_chips.dart b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart similarity index 95% rename from app/lib/features/search/presentation/widgets/bang_chips.dart rename to app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart index fd52b208..b9706ca4 100644 --- a/app/lib/features/search/presentation/widgets/bang_chips.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/bang_chips.dart @@ -4,7 +4,7 @@ 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/widgets/bang_icon.dart'; import 'package:lensai/presentation/hooks/listenable_callback.dart'; import 'package:lensai/presentation/widgets/selectable_chips.dart'; @@ -75,7 +75,8 @@ class BangChips extends HookConsumerWidget { Expanded( child: SelectableChips( itemId: (bang) => bang.trigger, - itemAvatar: (bang) => BangIcon(bang, iconSize: 20), + itemAvatar: (bang) => + UrlIcon(bang.getUrl(''), iconSize: 20), itemLabel: (bang) => Text(bang.websiteName), availableItems: availableBangs, selectedItem: activeBang, diff --git a/app/lib/features/search/presentation/widgets/search_field.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart similarity index 62% rename from app/lib/features/search/presentation/widgets/search_field.dart rename to app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart index 3e2336be..48ed2a9c 100644 --- a/app/lib/features/search/presentation/widgets/search_field.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_field.dart @@ -2,14 +2,19 @@ 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/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 void Function(String)? onFieldSubmitted; + final bool autofocus; + final void Function(String)? onSubmitted; + final bool showSuggestions; final BangData? activeBang; final bool showBangIcon; @@ -17,10 +22,12 @@ class SearchField extends HookConsumerWidget { const SearchField({ super.key, required this.textEditingController, - required this.onFieldSubmitted, + required this.onSubmitted, required this.activeBang, + required this.showSuggestions, this.focusNode, this.showBangIcon = true, + this.autofocus = false, }); @override @@ -32,20 +39,39 @@ class SearchField extends HookConsumerWidget { () => textEditingController.text.isNotEmpty, ); - return TextFormField( + final suggestion = useState(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: BangIcon(activeBang!, iconSize: 24.0), + child: UrlIcon(activeBang!.getUrl(''), iconSize: 24.0), ) : null, label: const Text('Search'), - hintText: 'Ask anything...', + // hintText: 'Ask anything...', floatingLabelBehavior: FloatingLabelBehavior.always, suffixIcon: hasText ? IconButton( @@ -65,7 +91,7 @@ class SearchField extends HookConsumerWidget { focusNode!.unfocus(); } : null, - onFieldSubmitted: onFieldSubmitted, + onSubmitted: onSubmitted, ); } } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart new file mode 100644 index 00000000..2ea52bf6 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -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(), + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart new file mode 100644 index 00000000..72d92091 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_suggestions.dart @@ -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 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, + ], + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart new file mode 100644 index 00000000..5c2fd794 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -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( + 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(); + } + }, + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.drift b/app/lib/features/geckoview/features/tabs/data/database/database.drift index a1392757..b03c5d1e 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/database.drift @@ -127,7 +127,8 @@ queryTabsBasic WITH TabQueryResult: t.id, highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, - bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank + bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank, + t.url AS clean_url FROM tab_fts fts INNER JOIN tab t ON t.rowid = fts.rowid @@ -157,7 +158,8 @@ queryTabsFullContent WITH TabQueryResult: ( bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight) - ) AS weighted_rank + ) AS weighted_rank, + t.url AS clean_url FROM tab_fts(:query) fts INNER JOIN tab t ON t.rowid = fts.rowid diff --git a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart index f32333de..9c91cbb7 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/database.g.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/database.g.dart @@ -1639,7 +1639,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { required String afterMatch, required String query}) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?3 OR fts.url LIKE ?3 ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank, t.url AS clean_url FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?3 OR fts.url LIKE ?3 ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [ Variable(beforeMatch), Variable(afterMatch), @@ -1652,6 +1652,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { id: row.read('id'), title: row.readNullable('title'), url: row.readNullable('url'), + cleanUrl: row.readNullable('clean_url'), weightedRank: row.read('weighted_rank'), )); } @@ -1663,7 +1664,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { required int snippetLength, required String query}) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank, t.url AS clean_url FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [ Variable(beforeMatch), Variable(afterMatch), @@ -1678,6 +1679,7 @@ abstract class _$TabDatabase extends GeneratedDatabase { id: row.read('id'), title: row.readNullable('title'), url: row.readNullable('url'), + cleanUrl: row.readNullable('clean_url'), extractedContent: row.readNullable('extracted_content'), fullContent: row.readNullable('full_content'), weightedRank: row.read('weighted_rank'), diff --git a/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart b/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart new file mode 100644 index 00000000..81969513 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/data/entities/container_filter.dart @@ -0,0 +1,23 @@ +import 'package:fast_equatable/fast_equatable.dart'; + +sealed class ContainerFilter with FastEquatable {} + +class ContainerFilterById extends ContainerFilter { + final String? containerId; + + ContainerFilterById({required this.containerId}); + + @override + bool get cacheHash => true; + + @override + List get hashParameters => [containerId]; +} + +class ContainerFilterDisabled extends ContainerFilter { + @override + bool get cacheHash => true; + + @override + List get hashParameters => [null]; +} diff --git a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart index d874a1f0..f896597f 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart @@ -2,8 +2,9 @@ class TabQueryResult { final String id; final String? title; - final String? url; + final String? cleanUrl; + final String? url; final String? extractedContent; final String? fullContent; @@ -13,6 +14,7 @@ class TabQueryResult { required this.id, required this.title, required this.url, + required this.cleanUrl, this.extractedContent, this.fullContent, required this.weightedRank, diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index 12354497..9c0f4ec7 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:collection/collection.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/data/providers.dart'; import 'package:lensai/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -37,7 +38,7 @@ Stream> containersWithCount( } @Riverpod() -AsyncValue> filteredContainersWithCount( +AsyncValue> matchSortedContainersWithCount( Ref ref, String? searchText, ) { @@ -48,7 +49,7 @@ AsyncValue> filteredContainersWithCount( } return value.whenData( - (cb) => TokenizedFilter( + (cb) => TokenizedFilter.sort( items: cb, toString: (item) => item.name, query: searchText!, @@ -61,8 +62,14 @@ AsyncValue> filteredContainersWithCount( @Riverpod() Stream> containerTabIds( Ref ref, - String? containerId, + ContainerFilter containerFilter, ) { final db = ref.watch(tabDatabaseProvider); - return db.tabDao.containerTabIds(containerId).watch(); + + switch (containerFilter) { + case ContainerFilterById(:final containerId): + return db.tabDao.containerTabIds(containerId).watch(); + case ContainerFilterDisabled(): + return db.tabDao.allTabIds().watch(); + } } diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index 2a31bd2c..faa169e9 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -45,8 +45,8 @@ final containersWithCountProvider = // ignore: unused_element typedef ContainersWithCountRef = AutoDisposeStreamProviderRef>; -String _$filteredContainersWithCountHash() => - r'3a7284df29182a32f9b7831867a3a0b3e171b382'; +String _$matchSortedContainersWithCountHash() => + r'0ed555f0cb72f4990a2841114d72f57d8178d5c3'; /// Copied from Dart SDK class _SystemHash { @@ -69,28 +69,29 @@ class _SystemHash { } } -/// See also [filteredContainersWithCount]. -@ProviderFor(filteredContainersWithCount) -const filteredContainersWithCountProvider = FilteredContainersWithCountFamily(); +/// See also [matchSortedContainersWithCount]. +@ProviderFor(matchSortedContainersWithCount) +const matchSortedContainersWithCountProvider = + MatchSortedContainersWithCountFamily(); -/// See also [filteredContainersWithCount]. -class FilteredContainersWithCountFamily +/// See also [matchSortedContainersWithCount]. +class MatchSortedContainersWithCountFamily extends Family>> { - /// See also [filteredContainersWithCount]. - const FilteredContainersWithCountFamily(); + /// See also [matchSortedContainersWithCount]. + const MatchSortedContainersWithCountFamily(); - /// See also [filteredContainersWithCount]. - FilteredContainersWithCountProvider call( + /// See also [matchSortedContainersWithCount]. + MatchSortedContainersWithCountProvider call( String? searchText, ) { - return FilteredContainersWithCountProvider( + return MatchSortedContainersWithCountProvider( searchText, ); } @override - FilteredContainersWithCountProvider getProviderOverride( - covariant FilteredContainersWithCountProvider provider, + MatchSortedContainersWithCountProvider getProviderOverride( + covariant MatchSortedContainersWithCountProvider provider, ) { return call( provider.searchText, @@ -109,33 +110,33 @@ class FilteredContainersWithCountFamily _allTransitiveDependencies; @override - String? get name => r'filteredContainersWithCountProvider'; + String? get name => r'matchSortedContainersWithCountProvider'; } -/// See also [filteredContainersWithCount]. -class FilteredContainersWithCountProvider +/// See also [matchSortedContainersWithCount]. +class MatchSortedContainersWithCountProvider extends AutoDisposeProvider>> { - /// See also [filteredContainersWithCount]. - FilteredContainersWithCountProvider( + /// See also [matchSortedContainersWithCount]. + MatchSortedContainersWithCountProvider( String? searchText, ) : this._internal( - (ref) => filteredContainersWithCount( - ref as FilteredContainersWithCountRef, + (ref) => matchSortedContainersWithCount( + ref as MatchSortedContainersWithCountRef, searchText, ), - from: filteredContainersWithCountProvider, - name: r'filteredContainersWithCountProvider', + from: matchSortedContainersWithCountProvider, + name: r'matchSortedContainersWithCountProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$filteredContainersWithCountHash, - dependencies: FilteredContainersWithCountFamily._dependencies, + : _$matchSortedContainersWithCountHash, + dependencies: MatchSortedContainersWithCountFamily._dependencies, allTransitiveDependencies: - FilteredContainersWithCountFamily._allTransitiveDependencies, + MatchSortedContainersWithCountFamily._allTransitiveDependencies, searchText: searchText, ); - FilteredContainersWithCountProvider._internal( + MatchSortedContainersWithCountProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -150,13 +151,13 @@ class FilteredContainersWithCountProvider @override Override overrideWith( AsyncValue> Function( - FilteredContainersWithCountRef provider) + MatchSortedContainersWithCountRef provider) create, ) { return ProviderOverride( origin: this, - override: FilteredContainersWithCountProvider._internal( - (ref) => create(ref as FilteredContainersWithCountRef), + override: MatchSortedContainersWithCountProvider._internal( + (ref) => create(ref as MatchSortedContainersWithCountRef), from: from, name: null, dependencies: null, @@ -170,12 +171,12 @@ class FilteredContainersWithCountProvider @override AutoDisposeProviderElement>> createElement() { - return _FilteredContainersWithCountProviderElement(this); + return _MatchSortedContainersWithCountProviderElement(this); } @override bool operator ==(Object other) { - return other is FilteredContainersWithCountProvider && + return other is MatchSortedContainersWithCountProvider && other.searchText == searchText; } @@ -190,23 +191,23 @@ class FilteredContainersWithCountProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin FilteredContainersWithCountRef +mixin MatchSortedContainersWithCountRef on AutoDisposeProviderRef>> { /// The parameter `searchText` of this provider. String? get searchText; } -class _FilteredContainersWithCountProviderElement +class _MatchSortedContainersWithCountProviderElement extends AutoDisposeProviderElement>> - with FilteredContainersWithCountRef { - _FilteredContainersWithCountProviderElement(super.provider); + with MatchSortedContainersWithCountRef { + _MatchSortedContainersWithCountProviderElement(super.provider); @override String? get searchText => - (origin as FilteredContainersWithCountProvider).searchText; + (origin as MatchSortedContainersWithCountProvider).searchText; } -String _$containerTabIdsHash() => r'7545a6c500b1832bf81c0838e257dfe5e051463d'; +String _$containerTabIdsHash() => r'6472a865c287d7f87ec49a401c8184bfe7528ab8'; /// See also [containerTabIds]. @ProviderFor(containerTabIds) @@ -219,10 +220,10 @@ class ContainerTabIdsFamily extends Family>> { /// See also [containerTabIds]. ContainerTabIdsProvider call( - String? containerId, + ContainerFilter containerFilter, ) { return ContainerTabIdsProvider( - containerId, + containerFilter, ); } @@ -231,7 +232,7 @@ class ContainerTabIdsFamily extends Family>> { covariant ContainerTabIdsProvider provider, ) { return call( - provider.containerId, + provider.containerFilter, ); } @@ -254,11 +255,11 @@ class ContainerTabIdsFamily extends Family>> { class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { /// See also [containerTabIds]. ContainerTabIdsProvider( - String? containerId, + ContainerFilter containerFilter, ) : this._internal( (ref) => containerTabIds( ref as ContainerTabIdsRef, - containerId, + containerFilter, ), from: containerTabIdsProvider, name: r'containerTabIdsProvider', @@ -269,7 +270,7 @@ class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { dependencies: ContainerTabIdsFamily._dependencies, allTransitiveDependencies: ContainerTabIdsFamily._allTransitiveDependencies, - containerId: containerId, + containerFilter: containerFilter, ); ContainerTabIdsProvider._internal( @@ -279,10 +280,10 @@ class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.containerId, + required this.containerFilter, }) : super.internal(); - final String? containerId; + final ContainerFilter containerFilter; @override Override overrideWith( @@ -297,7 +298,7 @@ class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - containerId: containerId, + containerFilter: containerFilter, ), ); } @@ -309,13 +310,14 @@ class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { @override bool operator ==(Object other) { - return other is ContainerTabIdsProvider && other.containerId == containerId; + return other is ContainerTabIdsProvider && + other.containerFilter == containerFilter; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, containerId.hashCode); + hash = _SystemHash.combine(hash, containerFilter.hashCode); return _SystemHash.finish(hash); } @@ -324,8 +326,8 @@ class ContainerTabIdsProvider extends AutoDisposeStreamProvider> { @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element mixin ContainerTabIdsRef on AutoDisposeStreamProviderRef> { - /// The parameter `containerId` of this provider. - String? get containerId; + /// The parameter `containerFilter` of this provider. + ContainerFilter get containerFilter; } class _ContainerTabIdsProviderElement @@ -334,7 +336,8 @@ class _ContainerTabIdsProviderElement _ContainerTabIdsProviderElement(super.provider); @override - String? get containerId => (origin as ContainerTabIdsProvider).containerId; + ContainerFilter get containerFilter => + (origin as ContainerTabIdsProvider).containerFilter; } // 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 diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart index 10132af2..55eb9882 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart @@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'tab_search.g.dart'; +enum TabSearchPartition { preview, search } + @Riverpod() class TabSearchRepository extends _$TabSearchRepository { Future addQuery( @@ -35,7 +37,7 @@ class TabSearchRepository extends _$TabSearchRepository { } @override - Future?> build() { + Future?> build(TabSearchPartition partition) { return Future.value(); } } diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart index 9f0cbbe9..55c50d18 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart @@ -7,21 +7,174 @@ part of 'tab_search.dart'; // ************************************************************************** String _$tabSearchRepositoryHash() => - r'57d130f50aa3feb7a31d27f9a5b54234e0e7064f'; + r'd442c384a5a867f4ff28465f524a6ef67521d5ae'; + +/// 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 _$TabSearchRepository + extends BuildlessAutoDisposeAsyncNotifier?> { + late final TabSearchPartition partition; + + FutureOr?> build( + TabSearchPartition partition, + ); +} /// See also [TabSearchRepository]. @ProviderFor(TabSearchRepository) -final tabSearchRepositoryProvider = AutoDisposeAsyncNotifierProvider< - TabSearchRepository, List?>.internal( - TabSearchRepository.new, - name: r'tabSearchRepositoryProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$tabSearchRepositoryHash, - dependencies: null, - allTransitiveDependencies: null, -); +const tabSearchRepositoryProvider = TabSearchRepositoryFamily(); -typedef _$TabSearchRepository = AutoDisposeAsyncNotifier?>; +/// See also [TabSearchRepository]. +class TabSearchRepositoryFamily + extends Family?>> { + /// See also [TabSearchRepository]. + const TabSearchRepositoryFamily(); + + /// See also [TabSearchRepository]. + TabSearchRepositoryProvider call( + TabSearchPartition partition, + ) { + return TabSearchRepositoryProvider( + partition, + ); + } + + @override + TabSearchRepositoryProvider getProviderOverride( + covariant TabSearchRepositoryProvider provider, + ) { + return call( + provider.partition, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'tabSearchRepositoryProvider'; +} + +/// See also [TabSearchRepository]. +class TabSearchRepositoryProvider extends AutoDisposeAsyncNotifierProviderImpl< + TabSearchRepository, List?> { + /// See also [TabSearchRepository]. + TabSearchRepositoryProvider( + TabSearchPartition partition, + ) : this._internal( + () => TabSearchRepository()..partition = partition, + from: tabSearchRepositoryProvider, + name: r'tabSearchRepositoryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabSearchRepositoryHash, + dependencies: TabSearchRepositoryFamily._dependencies, + allTransitiveDependencies: + TabSearchRepositoryFamily._allTransitiveDependencies, + partition: partition, + ); + + TabSearchRepositoryProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.partition, + }) : super.internal(); + + final TabSearchPartition partition; + + @override + FutureOr?> runNotifierBuild( + covariant TabSearchRepository notifier, + ) { + return notifier.build( + partition, + ); + } + + @override + Override overrideWith(TabSearchRepository Function() create) { + return ProviderOverride( + origin: this, + override: TabSearchRepositoryProvider._internal( + () => create()..partition = partition, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + partition: partition, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement?> createElement() { + return _TabSearchRepositoryProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TabSearchRepositoryProvider && other.partition == partition; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, partition.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TabSearchRepositoryRef + on AutoDisposeAsyncNotifierProviderRef?> { + /// The parameter `partition` of this provider. + TabSearchPartition get partition; +} + +class _TabSearchRepositoryProviderElement + extends AutoDisposeAsyncNotifierProviderElement?> with TabSearchRepositoryRef { + _TabSearchRepositoryProviderElement(super.provider); + + @override + TabSearchPartition get partition => + (origin as TabSearchRepositoryProvider).partition; +} // 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 diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 05588142..d472e993 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -18,6 +18,7 @@ class ContainerChips extends HookConsumerWidget { final bool displayMenu; final ContainerData? selectedContainer; + final bool Function(ContainerDataWithCount)? containerFilter; final void Function(ContainerDataWithCount)? onSelected; final void Function(ContainerDataWithCount)? onDeleted; @@ -27,6 +28,7 @@ class ContainerChips extends HookConsumerWidget { required this.selectedContainer, required this.onSelected, required this.onDeleted, + this.containerFilter, this.searchTextController, this.displayMenu = true, }); @@ -39,12 +41,16 @@ class ContainerChips extends HookConsumerWidget { ); final containersAsync = - ref.watch(filteredContainersWithCountProvider(searchText)); + ref.watch(matchSortedContainersWithCountProvider(searchText)); final dragTargetTabId = useValueNotifier(null); return containersAsync.when( - data: (availableContainers) { + data: (containers) { + final availableContainers = containerFilter + .mapNotNull((filter) => containers.where(filter).toList()) ?? + containers; + if (selectedContainer == null && availableContainers.isEmpty && !displayMenu) { diff --git a/app/lib/features/geckoview/utils/image_helper.dart b/app/lib/features/geckoview/utils/image_helper.dart index b8d69c59..00327ec3 100644 --- a/app/lib/features/geckoview/utils/image_helper.dart +++ b/app/lib/features/geckoview/utils/image_helper.dart @@ -1,12 +1,24 @@ import 'dart:typed_data'; import 'dart:ui'; +import 'package:lensai/utils/lru_cache.dart'; +import 'package:xxh3/xxh3.dart'; + +final _cache = LRUCache(100); + Future tryDecodeImage( Uint8List bytes, { int? targetWidth, int? targetHeight, bool allowUpscaling = true, }) async { + final digest = xxh3(bytes); + + final cached = _cache.get(digest); + if (cached != null) { + return cached; + } + try { final codec = await instantiateImageCodec( bytes, @@ -19,6 +31,7 @@ Future tryDecodeImage( final image = frameInfo.image; if (image.width > 0) { + _cache.set(digest, image); return image; } } catch (e) { diff --git a/app/lib/features/kagi/domain/repositories/search_suggestions.dart b/app/lib/features/kagi/domain/repositories/search_suggestions.dart index 99ca3f3e..c7debb87 100644 --- a/app/lib/features/kagi/domain/repositories/search_suggestions.dart +++ b/app/lib/features/kagi/domain/repositories/search_suggestions.dart @@ -16,7 +16,9 @@ class SearchSuggestionsRepository extends _$SearchSuggestionsRepository { SearchSuggestionsRepository() : _cache = LRUCache(100); void addQuery(String query) { - _queryStreamController.add(query); + if (!_queryStreamController.isClosed) { + _queryStreamController.add(query); + } } @override diff --git a/app/lib/features/kagi/domain/repositories/search_suggestions.g.dart b/app/lib/features/kagi/domain/repositories/search_suggestions.g.dart index aecec0b0..43f6535a 100644 --- a/app/lib/features/kagi/domain/repositories/search_suggestions.g.dart +++ b/app/lib/features/kagi/domain/repositories/search_suggestions.g.dart @@ -7,7 +7,7 @@ part of 'search_suggestions.dart'; // ************************************************************************** String _$searchSuggestionsRepositoryHash() => - r'bc6dc862ae7d887b404972abbbee3c1b27a64d2e'; + r'be75d075c3479c3d5e462211dec8bbd21b106820'; /// Copied from Dart SDK class _SystemHash { diff --git a/app/lib/features/search/presentation/screens/search.dart b/app/lib/features/search/presentation/screens/search.dart deleted file mode 100644 index 51853903..00000000 --- a/app/lib/features/search/presentation/screens/search.dart +++ /dev/null @@ -1,292 +0,0 @@ -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/data/models/equatable_iterable.dart'; -import 'package:lensai/extensions/nullable.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/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/geckoview/domain/providers/tab_state.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/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/features/search/presentation/widgets/search_suggestion_list.dart'; -import 'package:lensai/presentation/hooks/listenable_callback.dart'; -import 'package:lensai/presentation/widgets/failure_widget.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -class SearchScreen extends HookConsumerWidget { - static const _matchPrefix = '***'; - - final String? initialSearchText; - - const SearchScreen({required this.initialSearchText}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final formKey = useMemoized(() => GlobalKey()); - - final searchTextController = - useTextEditingController(text: initialSearchText); - - useListenableCallback( - searchTextController, - () async { - ref - .read(searchSuggestionsProvider().notifier) - .addQuery(searchTextController.text); - - await ref.read(tabSearchRepositoryProvider.notifier).addQuery( - searchTextController.text, - // ignore: avoid_redundant_argument_values - matchPrefix: _matchPrefix, - // ignore: avoid_redundant_argument_values - matchSuffix: _matchPrefix, - ); - }, - ); - - final globalSelectedContainer = ref.watch( - selectedContainerDataProvider.select((value) => value.valueOrNull), - ); - - final defaultSearchBang = ref.watch( - defaultSearchBangDataProvider.select((value) => value.valueOrNull), - ); - - final selectedBang = useState(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 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) { - context.pop(); - } - } - } - - final selectedContainer = useState(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( - 'Search Provider', - 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, - ), - ], - ), - ), - ), - SearchSuggestionList( - searchTextController: searchTextController, - submitSearch: submitSearch, - ), - 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) { - if (data == null) { - return const SliverToBoxAdapter( - child: SizedBox.shrink(), - ); - } - - return SliverList.builder( - itemCount: data.length, - itemBuilder: (context, index) { - final result = data[index]; - - final headHasMatch = - result.title?.contains(_matchPrefix) ?? false; - final bodyResult = - result.extractedContent ?? result.fullContent; - - return ListTile( - title: result.title.mapNotNull( - (title) => MarkdownBody(data: title), - ), - subtitle: (!headHasMatch && bodyResult != null) - ? MarkdownBody(data: bodyResult) - : null, - ); - }, - ); - }, - error: (error, stackTrace) { - return FailureWidget( - title: 'Could not load tabs', - exception: error, - ); - }, - loading: () => SliverList.builder( - itemCount: tabs.valueOrNull?.length ?? 3, - itemBuilder: (context, index) { - return ListTile( - title: Bone.text(), - ); - }, - ), - ), - ); - }, - ), - 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: RepaintBoundary( - child: RawImage( - image: tab.icon?.value, - height: 24, - width: 24, - ), - ), - title: Text(tab.title), - subtitle: Text( - tab.url.toString(), - softWrap: false, - overflow: TextOverflow.fade, - ), - ); - }, - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/features/search/presentation/widgets/search_suggestion_list.dart b/app/lib/features/search/presentation/widgets/search_suggestion_list.dart deleted file mode 100644 index fd27868f..00000000 --- a/app/lib/features/search/presentation/widgets/search_suggestion_list.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:collection/collection.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/domain/providers/bangs.dart'; -import 'package:lensai/features/bangs/domain/repositories/data.dart'; -import 'package:lensai/features/search/domain/providers/search_suggestions.dart'; - -class SearchSuggestionList extends HookConsumerWidget { - final TextEditingController searchTextController; - final Future Function(String query) submitSearch; - final bool showHistory; - - const SearchSuggestionList({ - required this.searchTextController, - required this.submitSearch, - this.showHistory = true, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - useListenableSelector( - searchTextController, - () => searchTextController.text.isNotEmpty, - ); - - final suggestions = useStream(ref.watch(searchSuggestionsProvider())); - - final searchHistory = ref.watch(searchHistoryProvider); - - final searchText = searchTextController.text; - - if (showHistory && - (!searchText.isNotEmpty || !suggestions.hasData) && - (searchHistory.value?.isNotEmpty ?? false)) { - final entries = searchHistory.value!; - - return 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), - ), - ); - }, - ); - } - - final prioritizedSuggestions = [ - if (searchText.isNotEmpty) searchText, - if (suggestions.data != null) - ...suggestions.data!.whereNot((suggestion) => suggestion == searchText), - ]; - - return 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); - }, - ); - }, - ); - } -} diff --git a/app/lib/features/search/util/tokenized_filter.dart b/app/lib/features/search/util/tokenized_filter.dart index bdbd1e1e..fc81839f 100644 --- a/app/lib/features/search/util/tokenized_filter.dart +++ b/app/lib/features/search/util/tokenized_filter.dart @@ -24,7 +24,83 @@ class TokenizedFilter { ); } - factory TokenizedFilter({ + static double _calculateScore( + List queryTokens, + List targetTokens, + ) { + if (queryTokens.isEmpty || targetTokens.isEmpty) return 0.0; + + var score = 0.0; + var exactMatches = 0; + var partialMatches = 0; + + for (final queryToken in queryTokens) { + var hasMatch = false; + + for (int i = 0; i < targetTokens.length; i++) { + final targetToken = targetTokens[i]; + + if (targetToken == queryToken) { + // Exact match gets 1.0 points + score += 1.0; + exactMatches++; + hasMatch = true; + break; + } else if (targetToken.contains(queryToken)) { + // Partial match gets points based on match length ratio + final ratio = queryToken.length / targetToken.length; + score += ratio * 0.8; // Partial matches worth 80% of exact matches + partialMatches++; + hasMatch = true; + break; + } + } + + if (!hasMatch) { + score -= 0.2; // Penalty for unmatched query tokens + } + } + + // Normalize score based on number of query tokens and matches + final matchRatio = + (exactMatches + partialMatches * 0.8) / queryTokens.length; + score = score / queryTokens.length; // Normalize by query length + + // Apply match ratio as a multiplier + score *= matchRatio; + + // Ensure score is between 0 and 1 + return score.clamp(0.0, 1.0); + } + + factory TokenizedFilter.sort({ + required Iterable items, + required ToString toString, + required String query, + }) { + final queryTokens = _tokenize(query); + + final scoredItems = items.map((item) { + final itemString = toString(item); + if (itemString == null || itemString.isEmpty) { + return (item, 0.0); + } + + final itemTokens = _tokenize(itemString); + final score = _calculateScore(queryTokens, itemTokens); + return (item, score); + }).toList(); + + // Sort by score in descending order + scoredItems.sort((a, b) => b.$2.compareTo(a.$2)); + + // Extract just the items in sorted order + final sorted = scoredItems.map((e) => e.$1).toList(); + + return TokenizedFilter._(items, sorted); + } + + factory TokenizedFilter.remove({ required Iterable items, required ToString toString, required String query, diff --git a/app/lib/presentation/hooks/cached_future.dart b/app/lib/presentation/hooks/cached_future.dart index 1375fb07..dc272281 100644 --- a/app/lib/presentation/hooks/cached_future.dart +++ b/app/lib/presentation/hooks/cached_future.dart @@ -2,11 +2,13 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; AsyncSnapshot useCachedFuture( - Future Function() valueBuilder, -) { + Future Function() valueBuilder, [ + List keys = const [], +]) { // ignore: discarded_futures final cachedFuture = useMemoized( valueBuilder, + keys, ); return useFuture(cachedFuture); } diff --git a/app/lib/presentation/hooks/listenable_callback.dart b/app/lib/presentation/hooks/listenable_callback.dart index d82a7a6f..022d70b8 100644 --- a/app/lib/presentation/hooks/listenable_callback.dart +++ b/app/lib/presentation/hooks/listenable_callback.dart @@ -1,8 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -void useListenableCallback(Listenable? listenable, void Function() callback, - [List? keys]) { +void useListenableCallback( + Listenable? listenable, + void Function() callback, [ + List? keys, +]) { useEffect( () { listenable?.addListener(callback); diff --git a/app/lib/presentation/widgets/auto_suggest_text_field.dart b/app/lib/presentation/widgets/auto_suggest_text_field.dart new file mode 100644 index 00000000..5f49acec --- /dev/null +++ b/app/lib/presentation/widgets/auto_suggest_text_field.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lensai/extensions/nullable.dart'; + +class AutoSuggestTextField extends HookWidget { + final TextEditingController controller; + final String? suggestion; + final TextStyle? style; + final TextStyle? labelStyle; + final InputDecoration? decoration; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final TextCapitalization textCapitalization; + final bool autofocus; + final bool obscureText; + final int? maxLines; + final int? minLines; + final int? maxLength; + final ValueChanged? onChanged; + final VoidCallback? onEditingComplete; + final ValueChanged? onSubmitted; + final List? inputFormatters; + final bool? enabled; + final FocusNode? focusNode; + final Color? cursorColor; + final Color? suggestionHighlightColor; + final bool? enableIMEPersonalizedLearning; + final TapRegionCallback? onTapOutside; + + const AutoSuggestTextField({ + super.key, + required this.controller, + this.suggestion, + this.style, + this.labelStyle, + this.decoration, + this.keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.autofocus = false, + this.obscureText = false, + this.maxLines = 1, + this.minLines, + this.maxLength, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.inputFormatters, + this.enabled, + this.focusNode, + this.cursorColor, + this.suggestionHighlightColor, + this.enableIMEPersonalizedLearning = true, + this.onTapOutside, + }); + + bool _suggestionHasMatch() => + suggestion != null && + controller.text.isNotEmpty && + suggestion!.startsWith(controller.text); + + @override + Widget build(BuildContext context) { + final baseDecoration = decoration ?? const InputDecoration(); + + return Stack( + children: [ + if (suggestion != null) + AbsorbPointer( + child: TextField( + decoration: baseDecoration.copyWith( + floatingLabelBehavior: FloatingLabelBehavior.never, + label: HookBuilder( + builder: (context) { + final text = useListenableSelector( + controller, + () => controller.text, + ); + + useEffect(() { + TextSelection? lastSelection; + + void handleSelectionChange() { + if (lastSelection != controller.selection) { + lastSelection = controller.selection; + if (lastSelection!.start != lastSelection!.end) { + if (_suggestionHasMatch()) { + controller.value = controller.value.copyWith( + text: suggestion, + selection: lastSelection!.expandTo( + TextPosition(offset: suggestion!.length), + ), + ); + } + } + } + } + + controller.addListener(handleSelectionChange); + return () => + controller.removeListener(handleSelectionChange); + }); + + if (!_suggestionHasMatch()) { + return const SizedBox.shrink(); + } + + return RichText( + text: TextSpan( + text: text, + style: (style ?? Theme.of(context).textTheme.bodyLarge) + ?.copyWith(color: Colors.transparent), + children: [ + TextSpan( + text: suggestion!.substring(text.length), + style: TextStyle( + height: 1.2, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + backgroundColor: suggestionHighlightColor ?? + Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.40), + ), + ), + ], + ), + ); + }, + ), + alignLabelWithHint: true, + ), + ), + ), + TextField( + controller: controller, + focusNode: focusNode, + decoration: baseDecoration.copyWith( + label: baseDecoration.label ?? const Text(''), + floatingLabelBehavior: baseDecoration.floatingLabelBehavior ?? + FloatingLabelBehavior.never, + ), + style: style, + keyboardType: keyboardType, + textInputAction: textInputAction, + textCapitalization: textCapitalization, + autofocus: autofocus, + obscureText: obscureText, + maxLines: maxLines, + minLines: minLines, + maxLength: maxLength, + onChanged: onChanged, + onEditingComplete: onEditingComplete, + onSubmitted: onSubmitted.mapNotNull( + (onSubmitted) => (value) { + if (_suggestionHasMatch()) { + onSubmitted(suggestion!); + } else { + onSubmitted(value); + } + }, + ), + inputFormatters: inputFormatters, + enabled: enabled, + cursorColor: cursorColor, + enableIMEPersonalizedLearning: enableIMEPersonalizedLearning ?? true, + onTapOutside: onTapOutside, + ), + ], + ); + } +} diff --git a/app/lib/presentation/widgets/bang_icon.dart b/app/lib/presentation/widgets/bang_icon.dart new file mode 100644 index 00000000..7e4f595b --- /dev/null +++ b/app/lib/presentation/widgets/bang_icon.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/domain/services/generic_website.dart'; +import 'package:lensai/presentation/hooks/cached_future.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class UrlIcon extends HookConsumerWidget { + final double iconSize; + final Uri url; + + const UrlIcon(this.url, {required this.iconSize, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final icon = useCachedFuture( + () async => + ref.read(genericWebsiteServiceProvider.notifier).getUrlIcon(url), + [url], + ); + + return Skeletonizer( + enabled: !icon.hasData, + child: SizedBox.square( + dimension: iconSize, + child: (icon.data != null) + ? RepaintBoundary( + child: RawImage( + image: icon.data?.image.value, + height: iconSize, + width: iconSize, + fit: BoxFit.fill, + ), + ) + : Icon( + MdiIcons.web, + size: iconSize, + ), + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d4b3e7bd..f0e9751d 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: rxdart: ^0.28.0 share_plus: ^10.1.4 skeletonizer: ^1.4.3 + sliver_tools: ^0.2.12 speech_to_text_google_dialog: git: url: https://github.com/FaFre/speech_to_text_google_dialog.git diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt index 7ebb544a..457ac67b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/api/GeckoSuggestionApiImpl.kt @@ -1,7 +1,9 @@ package eu.lensai.flutter_mozilla_components.api import eu.lensai.flutter_mozilla_components.GlobalComponents +import eu.lensai.flutter_mozilla_components.api.GeckoDeleteBrowsingDataControllerImpl.Companion import eu.lensai.flutter_mozilla_components.ext.toWebPBytes +import eu.lensai.flutter_mozilla_components.pigeons.AutocompleteResult import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestion import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionApi import eu.lensai.flutter_mozilla_components.pigeons.GeckoSuggestionEvents @@ -10,6 +12,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.engine.Engine import org.mozilla.gecko.util.ThreadUtils.runOnUiThread class GeckoSuggestionApiImpl( @@ -23,7 +27,28 @@ class GeckoSuggestionApiImpl( requireNotNull(GlobalComponents.components) { "Components not initialized" } } - override fun onInputChanged(text: String, providers: List) { + override fun getAutocompleteSuggestion( + query: String, + callback: (Result) -> Unit + ) { + coroutineScope.launch { + withContext(Dispatchers.Main) { + val suggestion = components.core.historyStorage.getAutocompleteSuggestion(query) + + callback(Result.success( suggestion?.let { AutocompleteResult( + input = it.input, + url = it.url, + text = it.text, + totalItems = it.totalItems.toLong(), + source = it.source + )} + + )) + } + } + } + + override fun querySuggestions(text: String, providers: List) { for(provider in providers) { coroutineScope.launch { val results = when(provider) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt index 73a560cf..dca835cf 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/lensai/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -1157,6 +1157,36 @@ data class GeckoEngineSettings ( ) } } + +/** Generated class from Pigeon that represents data sent in messages. */ +data class AutocompleteResult ( + val input: String, + val text: String, + val url: String, + val source: String, + val totalItems: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): AutocompleteResult { + val input = pigeonVar_list[0] as String + val text = pigeonVar_list[1] as String + val url = pigeonVar_list[2] as String + val source = pigeonVar_list[3] as String + val totalItems = pigeonVar_list[4] as Long + return AutocompleteResult(input, text, url, source, totalItems) + } + } + fun toList(): List { + return listOf( + input, + text, + url, + source, + totalItems, + ) + } +} private open class GeckoPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -1365,6 +1395,11 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { GeckoEngineSettings.fromList(it) } } + 170.toByte() -> { + return (readValue(buffer) as? List)?.let { + AutocompleteResult.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -1534,6 +1569,10 @@ private open class GeckoPigeonCodec : StandardMessageCodec() { stream.write(169) writeValue(stream, value.toList()) } + is AutocompleteResult -> { + stream.write(170) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -3129,7 +3168,8 @@ class GeckoAddonEvents(private val binaryMessenger: BinaryMessenger, private val } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface GeckoSuggestionApi { - fun onInputChanged(text: String, providers: List) + fun getAutocompleteSuggestion(query: String, callback: (Result) -> Unit) + fun querySuggestions(text: String, providers: List) companion object { /** The codec used by GeckoSuggestionApi. */ @@ -3141,14 +3181,34 @@ interface GeckoSuggestionApi { fun setUp(binaryMessenger: BinaryMessenger, api: GeckoSuggestionApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionApi.onInputChanged$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionApi.getAutocompleteSuggestion$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val queryArg = args[0] as String + api.getAutocompleteSuggestion(queryArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoSuggestionApi.querySuggestions$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { message, reply -> val args = message as List val textArg = args[0] as String val providersArg = args[1] as List val wrapped: List = try { - api.onInputChanged(textArg, providersArg) + api.querySuggestions(textArg, providersArg) listOf(null) } catch (exception: Throwable) { wrapError(exception) diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml index 012d8223..5f42e87b 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_details.xml @@ -3,154 +3,155 @@ - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:fitsSystemWindows="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:layout_marginBottom="6dp"> + + + android:layout_marginBottom="20dp" + tools:text="@tools:sample/lorem/random" /> + android:id="@+id/author_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/details" + android:text="@string/mozac_feature_addons_author" /> - - + android:id="@+id/author_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/details" + android:layout_alignParentEnd="true" + tools:text="@tools:sample/full_names" /> + android:id="@+id/author_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_below="@+id/author_label" + android:layout_marginTop="10dp" + android:layout_marginBottom="10dp" + android:background="@color/photonGrey40" + android:importantForAccessibility="no" /> + android:id="@+id/version_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/author_divider" + android:text="@string/mozac_feature_addons_version" /> + android:id="@+id/version_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/author_divider" + android:layout_alignParentEnd="true" + tools:text="1.2.3" /> + android:id="@+id/version_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_below="@+id/version_label" + android:layout_marginTop="10dp" + android:layout_marginBottom="10dp" + android:background="@color/photonGrey40" + android:importantForAccessibility="no" /> + android:id="@+id/last_updated_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/version_divider" + android:text="@string/mozac_feature_addons_last_updated" /> + android:id="@+id/last_updated_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/version_divider" + android:layout_alignParentEnd="true" + tools:text="Oct 16, 2019" /> + android:id="@+id/last_updated_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_below="@+id/last_updated_label" + android:layout_marginTop="10dp" + android:layout_marginBottom="10dp" + android:background="@color/photonGrey40" + android:importantForAccessibility="no" /> + android:id="@+id/home_page_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/last_updated_divider" + android:text="@string/mozac_feature_addons_home_page" /> - + + android:id="@+id/home_page_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_below="@+id/home_page_label" + android:layout_marginTop="10dp" + android:layout_marginBottom="10dp" + android:background="@color/photonGrey40" + android:importantForAccessibility="no" /> + android:id="@+id/rating_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/home_page_divider" + android:text="@string/mozac_feature_addons_rating" /> + android:id="@+id/rating_view" + style="@style/Widget.AppCompat.RatingBar.Small" + android:layout_width="wrap_content" + android:layout_height="20dp" + android:layout_below="@+id/home_page_divider" + android:layout_toStartOf="@+id/users_count" + android:isIndicator="true" + android:numStars="5" /> + android:id="@+id/users_count" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@+id/home_page_divider" + android:layout_alignParentEnd="true" + android:layout_marginStart="6dp" + tools:text="591,642" /> diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml index 05ab3e02..02a20ae5 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_main.xml @@ -7,5 +7,5 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:ignore="MergeRootFrame" /> - diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml index 1bc20285..1d509233 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_permissions.xml @@ -4,29 +4,30 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:fitsSystemWindows="true" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:context=".addons.PermissionsDetailsActivity"> + android:id="@+id/add_ons_permissions" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> + android:id="@+id/learn_more_label" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@+id/add_ons_permissions" + android:background="?attr/selectableItemBackground" + android:padding="16dp" + android:paddingStart="16dp" + android:paddingEnd="16dp" + android:text="@string/mozac_feature_addons_learn_more" + android:textColor="@android:color/black" + app:drawableEndCompat="@drawable/mozac_ic_link_24" + app:drawableTint="@android:color/black" /> diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml index f2a64eda..7fa6553f 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_add_on_settings.xml @@ -8,4 +8,5 @@ android:id="@+id/addonSettingsContainer" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="true" tools:ignore="MergeRootFrame" /> diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml index 2e4db542..7469a77e 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/activity_installed_add_on_details.xml @@ -1,9 +1,9 @@ - -