improved search ui

This commit is contained in:
Fabian Freund
2025-12-28 19:25:00 +01:00
parent 3aa1843a5e
commit eb6ad1f517
12 changed files with 395 additions and 306 deletions
@@ -45,7 +45,6 @@ class SiteSearch extends HookConsumerWidget {
final List<BangData> availableBangs; final List<BangData> availableBangs;
final TextEditingController? controller; final TextEditingController? controller;
final bool searchInNewTab; final bool searchInNewTab;
final Widget? label;
const SiteSearch({ const SiteSearch({
required this.domain, required this.domain,
@@ -53,7 +52,6 @@ class SiteSearch extends HookConsumerWidget {
super.key, super.key,
this.controller, this.controller,
this.searchInNewTab = false, this.searchInNewTab = false,
this.label,
}); });
@override @override
@@ -119,6 +117,7 @@ class SiteSearch extends HookConsumerWidget {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Visibility( Visibility(
visible: availableBangs.isNotEmpty, visible: availableBangs.isNotEmpty,
@@ -169,7 +168,9 @@ class SiteSearch extends HookConsumerWidget {
maxLines: null, maxLines: null,
activeBang: selectedBang, activeBang: selectedBang,
showSuggestions: true, showSuggestions: true,
label: label, label: (selectedBang != null)
? const Text('Search')
: const Text('Address / Search'),
onTap: () { onTap: () {
if (!searchFocusNode.hasFocus) { if (!searchFocusNode.hasFocus) {
// Select all text when the field is tapped // Select all text when the field is tapped
@@ -188,6 +189,7 @@ class SiteSearch extends HookConsumerWidget {
searchTextController: searchTextController, searchTextController: searchTextController,
activeBang: selectedBang, activeBang: selectedBang,
submitSearch: submitSearch, submitSearch: submitSearch,
limit: 25,
), ),
], ],
); );
@@ -289,7 +289,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
} }
@Riverpod() @Riverpod()
EquatableValue<List<TabPreview>> seamlessFilteredTabPreviews( EquatableValue<List<TabPreview>> filteredTabPreviews(
Ref ref, Ref ref,
TabSearchPartition searchPartition, TabSearchPartition searchPartition,
ContainerFilter containerFilter, ContainerFilter containerFilter,
@@ -307,21 +307,7 @@ EquatableValue<List<TabPreview>> seamlessFilteredTabPreviews(
); );
if (tabSearchResults == null) { if (tabSearchResults == null) {
return EquatableValue( return EquatableValue([]);
availableTabStates.value.values
.map(
(state) => TabPreview(
id: state.id,
title: state.title,
icon: state.icon,
url: state.url,
highlightedUrl: null,
content: null,
sourceSearchQuery: null,
),
)
.toList(),
);
} }
return EquatableValue( return EquatableValue(
@@ -330,6 +316,7 @@ EquatableValue<List<TabPreview>> seamlessFilteredTabPreviews(
.map((tab) { .map((tab) {
return TabPreview( return TabPreview(
id: tab.id, id: tab.id,
containerId: tab.containerId,
title: tab.title ?? availableTabStates.value[tab.id]!.title, title: tab.title ?? availableTabStates.value[tab.id]!.title,
icon: null, icon: null,
url: tab.cleanUrl ?? availableTabStates.value[tab.id]!.url, url: tab.cleanUrl ?? availableTabStates.value[tab.id]!.url,
@@ -640,11 +640,10 @@ final class SeamlessFilteredTabEntitiesFamily extends $Family
String toString() => r'seamlessFilteredTabEntitiesProvider'; String toString() => r'seamlessFilteredTabEntitiesProvider';
} }
@ProviderFor(seamlessFilteredTabPreviews) @ProviderFor(filteredTabPreviews)
const seamlessFilteredTabPreviewsProvider = const filteredTabPreviewsProvider = FilteredTabPreviewsFamily._();
SeamlessFilteredTabPreviewsFamily._();
final class SeamlessFilteredTabPreviewsProvider final class FilteredTabPreviewsProvider
extends extends
$FunctionalProvider< $FunctionalProvider<
EquatableValue<List<TabPreview>>, EquatableValue<List<TabPreview>>,
@@ -652,23 +651,23 @@ final class SeamlessFilteredTabPreviewsProvider
EquatableValue<List<TabPreview>> EquatableValue<List<TabPreview>>
> >
with $Provider<EquatableValue<List<TabPreview>>> { with $Provider<EquatableValue<List<TabPreview>>> {
const SeamlessFilteredTabPreviewsProvider._({ const FilteredTabPreviewsProvider._({
required SeamlessFilteredTabPreviewsFamily super.from, required FilteredTabPreviewsFamily super.from,
required (TabSearchPartition, ContainerFilter) super.argument, required (TabSearchPartition, ContainerFilter) super.argument,
}) : super( }) : super(
retry: null, retry: null,
name: r'seamlessFilteredTabPreviewsProvider', name: r'filteredTabPreviewsProvider',
isAutoDispose: true, isAutoDispose: true,
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
); );
@override @override
String debugGetCreateSourceHash() => _$seamlessFilteredTabPreviewsHash(); String debugGetCreateSourceHash() => _$filteredTabPreviewsHash();
@override @override
String toString() { String toString() {
return r'seamlessFilteredTabPreviewsProvider' return r'filteredTabPreviewsProvider'
'' ''
'$argument'; '$argument';
} }
@@ -682,7 +681,7 @@ final class SeamlessFilteredTabPreviewsProvider
@override @override
EquatableValue<List<TabPreview>> create(Ref ref) { EquatableValue<List<TabPreview>> create(Ref ref) {
final argument = this.argument as (TabSearchPartition, ContainerFilter); final argument = this.argument as (TabSearchPartition, ContainerFilter);
return seamlessFilteredTabPreviews(ref, argument.$1, argument.$2); return filteredTabPreviews(ref, argument.$1, argument.$2);
} }
/// {@macro riverpod.override_with_value} /// {@macro riverpod.override_with_value}
@@ -697,8 +696,7 @@ final class SeamlessFilteredTabPreviewsProvider
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is SeamlessFilteredTabPreviewsProvider && return other is FilteredTabPreviewsProvider && other.argument == argument;
other.argument == argument;
} }
@override @override
@@ -707,32 +705,32 @@ final class SeamlessFilteredTabPreviewsProvider
} }
} }
String _$seamlessFilteredTabPreviewsHash() => String _$filteredTabPreviewsHash() =>
r'ee1d441ca5f5822e201de8fc0b264ea7b8db0776'; r'f2958369b4270f4c8165526c0ffad22f41ec0491';
final class SeamlessFilteredTabPreviewsFamily extends $Family final class FilteredTabPreviewsFamily extends $Family
with with
$FunctionalFamilyOverride< $FunctionalFamilyOverride<
EquatableValue<List<TabPreview>>, EquatableValue<List<TabPreview>>,
(TabSearchPartition, ContainerFilter) (TabSearchPartition, ContainerFilter)
> { > {
const SeamlessFilteredTabPreviewsFamily._() const FilteredTabPreviewsFamily._()
: super( : super(
retry: null, retry: null,
name: r'seamlessFilteredTabPreviewsProvider', name: r'filteredTabPreviewsProvider',
dependencies: null, dependencies: null,
$allTransitiveDependencies: null, $allTransitiveDependencies: null,
isAutoDispose: true, isAutoDispose: true,
); );
SeamlessFilteredTabPreviewsProvider call( FilteredTabPreviewsProvider call(
TabSearchPartition searchPartition, TabSearchPartition searchPartition,
ContainerFilter containerFilter, ContainerFilter containerFilter,
) => SeamlessFilteredTabPreviewsProvider._( ) => FilteredTabPreviewsProvider._(
argument: (searchPartition, containerFilter), argument: (searchPartition, containerFilter),
from: this, from: this,
); );
@override @override
String toString() => r'seamlessFilteredTabPreviewsProvider'; String toString() => r'filteredTabPreviewsProvider';
} }
@@ -203,7 +203,6 @@ class ViewTabSheetWidget extends HookConsumerWidget {
domain: initialTabState.url.host, domain: initialTabState.url.host,
availableBangs: availableBangs, availableBangs: availableBangs,
controller: searchTextController, controller: searchTextController,
label: const Text('Address'),
), ),
), ),
const Divider(), const Divider(),
@@ -266,7 +266,7 @@ class ListTabPreview extends HookConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: tabState.isPrivate ? const Color(0xFF25003E) : null, color: tabState.isPrivate ? const Color(0xFF25003E) : null,
border: isActive ? Border.all(color: colorScheme.primary) : null, border: isActive ? Border.all(color: colorScheme.primary) : null,
// borderRadius: const BorderRadius.all(Radius.circular(16.0)), borderRadius: const BorderRadius.all(Radius.circular(4.0)),
), ),
child: Row( child: Row(
children: [ children: [
@@ -22,6 +22,7 @@ import 'package:weblibre/domain/entities/equatable_image.dart';
class TabPreview with FastEquatable { class TabPreview with FastEquatable {
final String id; final String id;
final String? containerId;
final String title; final String title;
final EquatableImage? icon; final EquatableImage? icon;
@@ -35,6 +36,7 @@ class TabPreview with FastEquatable {
TabPreview({ TabPreview({
required this.id, required this.id,
required this.containerId,
required this.title, required this.title,
required this.icon, required this.icon,
required this.url, required this.url,
@@ -46,6 +48,7 @@ class TabPreview with FastEquatable {
@override @override
List<Object?> get hashParameters => [ List<Object?> get hashParameters => [
id, id,
containerId,
title, title,
icon, icon,
url, url,
@@ -17,6 +17,7 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
@@ -125,142 +126,152 @@ class SearchScreen extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
body: Form( body: SafeArea(
key: formKey, child: Form(
child: CustomScrollView( key: formKey,
slivers: [ child: FadingScroll(
SliverAppBar( builder: (context, controller) {
floating: true, return CustomScrollView(
pinned: true, controller: controller,
automaticallyImplyLeading: false, slivers: [
title: Align( SliverAppBar(
child: Focus( floating: true,
canRequestFocus: false, pinned: true,
child: SegmentedButton( automaticallyImplyLeading: false,
showSelectedIcon: false, title: Align(
segments: [ child: Focus(
const ButtonSegment( canRequestFocus: false,
value: TabType.regular, child: SegmentedButton(
label: Text('Regular'), showSelectedIcon: false,
icon: Icon(MdiIcons.tab), segments: [
), const ButtonSegment(
const ButtonSegment( value: TabType.regular,
value: TabType.private, label: Text('Regular'),
label: Text('Private'), icon: Icon(MdiIcons.tab),
icon: Icon(MdiIcons.tabUnselected), ),
), const ButtonSegment(
if (createChildTabsOption) value: TabType.private,
const ButtonSegment( label: Text('Private'),
value: TabType.child, icon: Icon(MdiIcons.tabUnselected),
label: Text('Child'), ),
icon: Icon(MdiIcons.fileTree), if (createChildTabsOption)
const ButtonSegment(
value: TabType.child,
label: Text('Child'),
icon: Icon(MdiIcons.fileTree),
),
],
selected: {selectedTabType.value},
onSelectionChanged: (value) {
selectedTabType.value = value.first;
// Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
},
style: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor: const Color(0x648000D7),
),
TabType.child =>
(currentTabTabType == TabType.private)
? SegmentedButton.styleFrom(
selectedBackgroundColor: const Color(
0x648000D7,
),
)
: null,
},
), ),
],
selected: {selectedTabType.value},
onSelectionChanged: (value) {
selectedTabType.value = value.first;
// Restore focus to search field after segment change
WidgetsBinding.instance.addPostFrameCallback((_) {
searchFocusNode.requestFocus();
});
},
style: switch (selectedTabType.value) {
TabType.regular => null,
TabType.private => SegmentedButton.styleFrom(
selectedBackgroundColor: const Color(0x648000D7),
), ),
TabType.child => ),
(currentTabTabType == TabType.private) bottom: PreferredSize(
? SegmentedButton.styleFrom( preferredSize: const Size.fromHeight(kToolbarHeight),
selectedBackgroundColor: const Color( child: Padding(
0x648000D7, padding: const EdgeInsets.only(left: 16.0),
), child: SearchField(
) showBangIcon: showBangIcon.value,
: null, textEditingController: searchTextController,
}, focusNode: searchFocusNode,
), autofocus: true,
), label: const Text('Address / Search'),
), onSubmitted: (value) async {
bottom: PreferredSize( if (value.isNotEmpty) {
preferredSize: const Size.fromHeight(kToolbarHeight), var newUrl = uri_parser.tryParseUrl(
child: Padding( value,
padding: const EdgeInsets.only(left: 16.0), eagerParsing: true,
child: SearchField(
showBangIcon: showBangIcon.value,
textEditingController: searchTextController,
focusNode: searchFocusNode,
autofocus: true,
label: const Text('Address / Search'),
onSubmitted: (value) async {
if (value.isNotEmpty) {
var newUrl = uri_parser.tryParseUrl(
value,
eagerParsing: true,
);
if (newUrl == null) {
final bang =
ref.read(selectedBangDataProvider()) ??
await ref.read(
defaultSearchBangDataProvider.future,
); );
if (bang != null) { if (newUrl == null) {
newUrl = bang.getTemplateUrl(value); final bang =
ref.read(selectedBangDataProvider()) ??
await ref.read(
defaultSearchBangDataProvider.future,
);
if (!privateTabMode) { if (bang != null) {
await ref newUrl = bang.getTemplateUrl(value);
.read(bangSearchProvider.notifier)
.triggerBangSearch(bang, value); if (!privateTabMode) {
await ref
.read(bangSearchProvider.notifier)
.triggerBangSearch(bang, value);
}
}
}
if (newUrl != null) {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: newUrl,
private: privateTabMode,
parentId:
(selectedTabType.value ==
TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
);
if (context.mounted) {
ref
.read(
bottomSheetControllerProvider.notifier,
)
.requestDismiss();
const BrowserRoute().go(context);
}
}
} }
} },
} activeBang: activeBang,
showSuggestions: true,
if (newUrl != null) { ),
await ref ),
.read(tabRepositoryProvider.notifier) ),
.addTab(
url: newUrl,
private: privateTabMode,
parentId:
(selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
);
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
const BrowserRoute().go(context);
}
}
}
},
activeBang: activeBang,
showSuggestions: true,
), ),
), SliverToBoxAdapter(
), child: ClipboardFillLink(controller: searchTextController),
), ),
SliverToBoxAdapter( const SliverToBoxAdapter(child: Divider()),
child: ClipboardFillLink(controller: searchTextController), FullSearchTermSuggestions(
), searchTextController: searchTextController,
const SliverToBoxAdapter(child: Divider()), activeBang: activeBang,
FullSearchTermSuggestions( submitSearch: submitSearch,
searchTextController: searchTextController, ),
activeBang: activeBang, TabSearch(searchTextListenable: sampledSearchText),
submitSearch: submitSearch, FeedSearch(searchTextNotifier: sampledSearchText),
), HistorySuggestions(
TabSearch(searchTextListenable: sampledSearchText), isPrivate: privateTabMode,
FeedSearch(searchTextNotifier: sampledSearchText), searchTextListenable: sampledSearchText,
HistorySuggestions( ),
isPrivate: privateTabMode, ],
searchTextListenable: sampledSearchText, );
), },
], ),
), ),
), ),
); );
@@ -32,13 +32,13 @@ class FixedSearchTermSuggestions extends HookConsumerWidget {
final TextEditingController searchTextController; final TextEditingController searchTextController;
final Future<void> Function(String query) submitSearch; final Future<void> Function(String query) submitSearch;
final BangData? activeBang; final BangData? activeBang;
final int count; final int limit;
const FixedSearchTermSuggestions({ const FixedSearchTermSuggestions({
required this.searchTextController, required this.searchTextController,
required this.submitSearch, required this.submitSearch,
required this.activeBang, required this.activeBang,
this.count = 3, required this.limit,
super.key, super.key,
}); });
@@ -76,27 +76,28 @@ class FixedSearchTermSuggestions extends HookConsumerWidget {
.whereNot( .whereNot(
(suggestion) => suggestion == searchTextController.text, (suggestion) => suggestion == searchTextController.text,
) )
.take(count), .take(limit),
] ]
: <String>[]; : <String>[];
return Column( return Wrap(
mainAxisSize: MainAxisSize.min, spacing: 8.0,
children: prioritizedSuggestions children: prioritizedSuggestions.map((query) {
.map( return InkWell(
(suggestion) => ListTile( onLongPress: () {
contentPadding: EdgeInsets.zero, searchTextController.text = query;
leading: const Icon(Icons.search), },
title: Text(suggestion), child: InputChip(
onLongPress: () { // avatar: const Icon(Icons.search),
searchTextController.text = suggestion; label: Text(query),
}, onSelected: (value) async {
onTap: () async { if (value) {
await submitSearch(suggestion); await submitSearch(query);
}, }
), },
) ),
.toList(), );
}).toList(),
); );
} }
} }
@@ -61,35 +61,45 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
.addQuery(searchTextController.text); .addQuery(searchTextController.text);
}); });
final Widget listSliver; final MultiSliver listSliver;
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) { if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
final entries = searchHistory.value!; final entries = searchHistory.value!;
listSliver = SliverList.builder( listSliver = MultiSliver(
itemCount: entries.length, children: [
itemBuilder: (context, index) { SliverToBoxAdapter(
final query = entries[index].searchQuery; child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Wrap(
spacing: 8.0,
children: entries.map((entry) {
final query = entry.searchQuery;
return ListTile( return InkWell(
leading: const Icon(Icons.history), onLongPress: () {
title: Text(query), searchTextController.text = query;
onLongPress: () { },
searchTextController.text = query; child: InputChip(
}, avatar: const Icon(Icons.history),
onTap: () async { label: Text(query),
await submitSearch(query); onSelected: (value) async {
}, if (value) {
trailing: IconButton( await submitSearch(query);
onPressed: () async { }
await ref },
.read(bangDataRepositoryProvider.notifier) onDeleted: () async {
.removeSearchEntry(query); await ref
}, .read(bangDataRepositoryProvider.notifier)
icon: const Icon(Icons.close), .removeSearchEntry(query);
},
),
);
}).toList(),
),
), ),
); ),
}, ],
); );
} else { } else {
final prioritizedSuggestions = [ final prioritizedSuggestions = [
@@ -100,22 +110,33 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
), ),
]; ];
listSliver = SliverList.builder( listSliver = MultiSliver(
itemCount: prioritizedSuggestions.length, children: [
itemBuilder: (context, index) { SliverToBoxAdapter(
final suggestion = prioritizedSuggestions[index]; child: Padding(
padding: const EdgeInsets.only(left: 16.0),
return ListTile( child: Wrap(
leading: const Icon(Icons.search), spacing: 8.0,
title: Text(suggestion), children: prioritizedSuggestions.map((query) {
onLongPress: () { return InkWell(
searchTextController.text = suggestion; onLongPress: () {
}, searchTextController.text = query;
onTap: () async { },
await submitSearch(suggestion); child: InputChip(
}, // avatar: const Icon(Icons.search),
); label: Text(query),
}, onSelected: (value) async {
if (value) {
await submitSearch(query);
}
},
),
);
}).toList(),
),
),
),
],
); );
} }
@@ -167,17 +188,20 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: ConstrainedBox( child: Padding(
constraints: const BoxConstraints(maxHeight: 150), padding: const EdgeInsets.only(top: 8.0),
child: FadingScroll( child: ConstrainedBox(
fadingSize: 25, constraints: const BoxConstraints(maxHeight: 150),
builder: (context, controller) { child: FadingScroll(
return CustomScrollView( fadingSize: 25,
shrinkWrap: true, builder: (context, controller) {
controller: controller, return CustomScrollView(
slivers: [listSliver], shrinkWrap: true,
); controller: controller,
}, slivers: [listSliver],
);
},
),
), ),
), ),
), ),
@@ -33,6 +33,7 @@ import 'package:weblibre/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
class HistorySuggestions extends HookConsumerWidget { class HistorySuggestions extends HookConsumerWidget {
final bool isPrivate; final bool isPrivate;
@@ -82,6 +83,7 @@ class HistorySuggestions extends HookConsumerWidget {
itemCount: historySuggestions.length, itemCount: historySuggestions.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final suggestion = historySuggestions[index]; final suggestion = historySuggestions[index];
final uri = suggestion.description.mapNotNull(Uri.tryParse);
return HookBuilder( return HookBuilder(
key: ValueKey(suggestion.id), key: ValueKey(suggestion.id),
@@ -107,30 +109,27 @@ class HistorySuggestions extends HookConsumerWidget {
title: suggestion.title.mapNotNull( title: suggestion.title.mapNotNull(
(title) => Text(title), (title) => Text(title),
), ),
subtitle: suggestion.description.mapNotNull( subtitle:
(description) => Text( uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ??
description, suggestion.description.mapNotNull(
maxLines: 2, (description) => Text(
overflow: TextOverflow.ellipsis, description,
), maxLines: 2,
), overflow: TextOverflow.ellipsis,
),
),
onTap: () async { onTap: () async {
if (suggestion.description != null) { if (uri != null) {
if (Uri.tryParse(suggestion.description!) await ref
case final Uri url) { .read(tabRepositoryProvider.notifier)
await ref .addTab(url: uri, private: isPrivate);
.read(tabRepositoryProvider.notifier)
.addTab(url: url, private: isPrivate);
if (context.mounted) { if (context.mounted) {
ref ref
.read( .read(bottomSheetControllerProvider.notifier)
bottomSheetControllerProvider.notifier, .requestDismiss();
)
.requestDismiss();
const BrowserRoute().go(context); const BrowserRoute().go(context);
}
} }
} }
}, },
@@ -17,6 +17,10 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -33,6 +37,7 @@ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/c
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart';
@@ -53,15 +58,53 @@ class TabSearch extends HookConsumerWidget {
ref.read(selectedContainerDataProvider.select((value) => value.value)), ref.read(selectedContainerDataProvider.select((value) => value.value)),
); );
final tabs = ref final tabSearchResults = ref.watch(
.watch( filteredTabPreviewsProvider(
seamlessFilteredTabPreviewsProvider( TabSearchPartition.search,
TabSearchPartition.search, // ignore: provider_parameters
// ignore: provider_parameters ContainerFilterDisabled(),
ContainerFilterById(containerId: selectedContainer.value?.id), ),
), );
)
.value; final containerIdsWithResults = useMemoized(
() => EquatableValue(
tabSearchResults.value
.map((result) => result.containerId)
.fold(
<String?, int>{},
(map, id) =>
map..update(id, (value) => ++value, ifAbsent: () => 1),
),
),
[tabSearchResults],
);
final filteredTabs = useMemoized(
() => tabSearchResults.value
.where((result) => result.containerId == selectedContainer.value?.id)
.toList(),
[tabSearchResults, selectedContainer.value?.id],
);
unawaited(
useValueChanged(containerIdsWithResults, (_, _) async {
if (containerIdsWithResults.value.isNotEmpty &&
!containerIdsWithResults.value.containsKey(
selectedContainer.value?.id,
)) {
selectedContainer.value = await ref
.read(containerRepositoryProvider.notifier)
.getAllContainersWithCount()
.then((containers) {
return containers.firstWhereOrNull(
(container) =>
!container.metadata.authSettings.authenticationRequired &&
containerIdsWithResults.value.containsKey(container.id),
);
});
}
}),
);
useListenableCallback(searchTextListenable, () async { useListenableCallback(searchTextListenable, () async {
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.search))) { if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.search))) {
@@ -79,6 +122,10 @@ class TabSearch extends HookConsumerWidget {
} }
}); });
if (tabSearchResults.value.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return MultiSliver( return MultiSliver(
children: [ children: [
const SliverToBoxAdapter(child: Divider()), const SliverToBoxAdapter(child: Divider()),
@@ -92,6 +139,9 @@ class TabSearch extends HookConsumerWidget {
ContainerChips( ContainerChips(
displayMenu: false, displayMenu: false,
selectedContainer: selectedContainer.value, selectedContainer: selectedContainer.value,
showUnassignedChip: containerIdsWithResults.value.containsKey(
null,
),
onSelected: (container) async { onSelected: (container) async {
if (container != null) { if (container != null) {
if (await ref if (await ref
@@ -106,7 +156,10 @@ class TabSearch extends HookConsumerWidget {
onDeleted: (container) { onDeleted: (container) {
selectedContainer.value = null; selectedContainer.value = null;
}, },
containerFilter: (container) => (container.tabCount ?? 0) > 0, containerFilter: (container) =>
containerIdsWithResults.value.containsKey(container.id),
containerBadgeCount: (container) =>
containerIdsWithResults.value[container?.id] ?? 0,
searchTextListenable: searchTextListenable, searchTextListenable: searchTextListenable,
), ),
], ],
@@ -114,10 +167,11 @@ class TabSearch extends HookConsumerWidget {
), ),
), ),
SliverList.builder( SliverList.builder(
itemCount: tabs.length, itemCount: filteredTabs.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final result = tabs[index]; final result = filteredTabs[index];
final titleHasMatch = result.title.contains(_matchPrefix);
final urlHasMatch = final urlHasMatch =
result.highlightedUrl?.contains(_matchPrefix) ?? false; result.highlightedUrl?.contains(_matchPrefix) ?? false;
final bodyHasMatch = final bodyHasMatch =
@@ -142,11 +196,13 @@ class TabSearch extends HookConsumerWidget {
), ),
), ),
), ),
subtitle: (bodyHasMatch || urlHasMatch) subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch))
? MarkdownBody( ? MarkdownBody(
data: bodyHasMatch data:
? result.content! (bodyHasMatch
: result.highlightedUrl!, ? result.content!
: result.highlightedUrl!)
.replaceAll(RegExp(r'\s+'), ' '),
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: Theme.of(context).textTheme.bodyMedium?.copyWith( p: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
@@ -38,10 +38,12 @@ import 'package:weblibre/presentation/widgets/selectable_chips.dart';
class ContainerChips extends HookConsumerWidget { class ContainerChips extends HookConsumerWidget {
final bool displayMenu; final bool displayMenu;
final bool showUnassignedChip;
final bool showGroupSuggestions; final bool showGroupSuggestions;
final ContainerData? selectedContainer; final ContainerData? selectedContainer;
final bool Function(ContainerDataWithCount)? containerFilter; final bool Function(ContainerDataWithCount)? containerFilter;
final int Function(ContainerDataWithCount?)? containerBadgeCount;
final void Function(ContainerDataWithCount?)? onSelected; final void Function(ContainerDataWithCount?)? onSelected;
final void Function(ContainerDataWithCount)? onDeleted; final void Function(ContainerDataWithCount)? onDeleted;
final void Function(ContainerDataWithCount)? onLongPress; final void Function(ContainerDataWithCount)? onLongPress;
@@ -54,8 +56,10 @@ class ContainerChips extends HookConsumerWidget {
required this.onDeleted, required this.onDeleted,
this.onLongPress, this.onLongPress,
this.containerFilter, this.containerFilter,
this.containerBadgeCount,
this.searchTextListenable, this.searchTextListenable,
this.displayMenu = true, this.displayMenu = true,
this.showUnassignedChip = true,
this.showGroupSuggestions = false, this.showGroupSuggestions = false,
}); });
@@ -97,7 +101,9 @@ class ContainerChips extends HookConsumerWidget {
container.color.withValues(alpha: 0.33), container.color.withValues(alpha: 0.33),
itemLabel: (container) => itemLabel: (container) =>
ContainerTitle(container: container), ContainerTitle(container: container),
itemBadgeCount: (container) => container.tabCount, itemBadgeCount: (container) =>
containerBadgeCount?.call(container) ??
container.tabCount,
itemWrap: (child, container) { itemWrap: (child, container) {
return TabDragContainerTarget( return TabDragContainerTarget(
container: container, container: container,
@@ -105,36 +111,39 @@ class ContainerChips extends HookConsumerWidget {
); );
}, },
prefixListItems: [ prefixListItems: [
TabDragContainerTarget( if (showUnassignedChip)
container: null, TabDragContainerTarget(
child: Consumer( container: null,
builder: (context, ref, child) { child: Consumer(
final tabCount = ref.watch( builder: (context, ref, child) {
containerTabCountProvider( final int tabCount =
// ignore: provider_parameters containerBadgeCount?.call(null) ??
ContainerFilterById(containerId: null), ref.watch(
).select((value) => value.value ?? 0), containerTabCountProvider(
); // ignore: provider_parameters
ContainerFilterById(containerId: null),
).select((value) => value.value ?? 0),
);
return FilterChip( return FilterChip(
avatar: const Icon(MdiIcons.folderHidden), avatar: const Icon(MdiIcons.folderHidden),
labelPadding: (tabCount > 0) labelPadding: (tabCount > 0)
? null ? null
: const EdgeInsets.only(right: 2.0), : const EdgeInsets.only(right: 2.0),
label: (tabCount > 0) label: (tabCount > 0)
? Text(tabCount.toString()) ? Text(tabCount.toString())
: const SizedBox.shrink(), : const SizedBox.shrink(),
selected: selectedContainer == null, selected: selectedContainer == null,
showCheckmark: false, showCheckmark: false,
onSelected: (value) { onSelected: (value) {
if (value) { if (value) {
onSelected?.call(null); onSelected?.call(null);
} }
}, },
); );
}, },
),
), ),
),
if (showGroupSuggestions) if (showGroupSuggestions)
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {