refactor container selection pattern and improve search UI
This commit is contained in:
@@ -75,6 +75,7 @@ AsyncValue<List<GeckoSuggestion>> engineHistorySuggestions(Ref ref) {
|
||||
) !=
|
||||
null),
|
||||
)
|
||||
.take(25)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -107,4 +107,4 @@ final class EngineHistorySuggestionsProvider
|
||||
}
|
||||
|
||||
String _$engineHistorySuggestionsHash() =>
|
||||
r'7e6c17e6a2df98098ca1a683a438d1c261ccabdf';
|
||||
r'e78f87f67da9184115991ee93a25da8039e36293';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'search_modules_view.g.dart';
|
||||
|
||||
enum SearchModuleType { tabs, articles, history }
|
||||
|
||||
enum SearchModuleDisplayState { preview, expanded, collapsed }
|
||||
|
||||
@Riverpod()
|
||||
class SearchModuleDisplayStateController
|
||||
extends _$SearchModuleDisplayStateController {
|
||||
void cycle() {
|
||||
state = switch (state) {
|
||||
SearchModuleDisplayState.preview => SearchModuleDisplayState.expanded,
|
||||
SearchModuleDisplayState.expanded => SearchModuleDisplayState.collapsed,
|
||||
SearchModuleDisplayState.collapsed => SearchModuleDisplayState.preview,
|
||||
};
|
||||
}
|
||||
|
||||
void toggleCollapse() {
|
||||
state = switch (state) {
|
||||
SearchModuleDisplayState.collapsed => SearchModuleDisplayState.preview,
|
||||
_ => SearchModuleDisplayState.collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
void toggleExpansion() {
|
||||
state = switch (state) {
|
||||
SearchModuleDisplayState.preview => SearchModuleDisplayState.expanded,
|
||||
SearchModuleDisplayState.expanded => SearchModuleDisplayState.preview,
|
||||
_ => state,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
SearchModuleDisplayState build(SearchModuleType module) {
|
||||
return SearchModuleDisplayState.preview;
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_modules_view.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SearchModuleDisplayStateController)
|
||||
final searchModuleDisplayStateControllerProvider =
|
||||
SearchModuleDisplayStateControllerFamily._();
|
||||
|
||||
final class SearchModuleDisplayStateControllerProvider
|
||||
extends
|
||||
$NotifierProvider<
|
||||
SearchModuleDisplayStateController,
|
||||
SearchModuleDisplayState
|
||||
> {
|
||||
SearchModuleDisplayStateControllerProvider._({
|
||||
required SearchModuleDisplayStateControllerFamily super.from,
|
||||
required SearchModuleType super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'searchModuleDisplayStateControllerProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() =>
|
||||
_$searchModuleDisplayStateControllerHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'searchModuleDisplayStateControllerProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchModuleDisplayStateController create() =>
|
||||
SearchModuleDisplayStateController();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SearchModuleDisplayState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SearchModuleDisplayState>(value),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SearchModuleDisplayStateControllerProvider &&
|
||||
other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchModuleDisplayStateControllerHash() =>
|
||||
r'c3f1c93b618eec76e86c1c9cf0bf8fbdfdcb1430';
|
||||
|
||||
final class SearchModuleDisplayStateControllerFamily extends $Family
|
||||
with
|
||||
$ClassFamilyOverride<
|
||||
SearchModuleDisplayStateController,
|
||||
SearchModuleDisplayState,
|
||||
SearchModuleDisplayState,
|
||||
SearchModuleDisplayState,
|
||||
SearchModuleType
|
||||
> {
|
||||
SearchModuleDisplayStateControllerFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'searchModuleDisplayStateControllerProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
SearchModuleDisplayStateControllerProvider call(SearchModuleType module) =>
|
||||
SearchModuleDisplayStateControllerProvider._(
|
||||
argument: module,
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'searchModuleDisplayStateControllerProvider';
|
||||
}
|
||||
|
||||
abstract class _$SearchModuleDisplayStateController
|
||||
extends $Notifier<SearchModuleDisplayState> {
|
||||
late final _$args = ref.$arg as SearchModuleType;
|
||||
SearchModuleType get module => _$args;
|
||||
|
||||
SearchModuleDisplayState build(SearchModuleType module);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<SearchModuleDisplayState, SearchModuleDisplayState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<SearchModuleDisplayState, SearchModuleDisplayState>,
|
||||
SearchModuleDisplayState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, () => build(_$args));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:riverpod/experimental/persist.dart';
|
||||
import 'package:riverpod_annotation/experimental/persist.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
part 'search_suggestions_view.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class SearchSuggestionsExpanded extends _$SearchSuggestionsExpanded {
|
||||
void toggle() {
|
||||
state = !state;
|
||||
}
|
||||
|
||||
@override
|
||||
bool build() {
|
||||
persist(
|
||||
ref.watch(riverpodDatabaseStorageProvider),
|
||||
key: 'SearchSuggestionsExpanded',
|
||||
encode: (state) => jsonEncode([state]),
|
||||
decode: (encoded) => (jsonDecode(encoded) as List<dynamic>).first as bool,
|
||||
);
|
||||
|
||||
return stateOrNull ?? true;
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'search_suggestions_view.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(SearchSuggestionsExpanded)
|
||||
final searchSuggestionsExpandedProvider = SearchSuggestionsExpandedProvider._();
|
||||
|
||||
final class SearchSuggestionsExpandedProvider
|
||||
extends $NotifierProvider<SearchSuggestionsExpanded, bool> {
|
||||
SearchSuggestionsExpandedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'searchSuggestionsExpandedProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchSuggestionsExpandedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SearchSuggestionsExpanded create() => SearchSuggestionsExpanded();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchSuggestionsExpandedHash() =>
|
||||
r'520bcdb9ef624c2ebe5a570d4dab034de5b4f475';
|
||||
|
||||
abstract class _$SearchSuggestionsExpanded extends $Notifier<bool> {
|
||||
bool build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<bool, bool>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<bool, bool>,
|
||||
bool,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -19,22 +19,21 @@
|
||||
*/
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart' hide Column;
|
||||
import 'package:fading_scroll/fading_scroll.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/core/design/app_colors.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/search.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
|
||||
@@ -42,11 +41,10 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
|
||||
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
|
||||
import 'package:weblibre/utils/text_field_line_count.dart';
|
||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||
|
||||
@@ -248,7 +246,9 @@ class SearchScreen extends HookConsumerWidget {
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
container: Value(selectedContainer),
|
||||
containerSelection: selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(selectedContainer),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,252 +264,213 @@ class SearchScreen extends HookConsumerWidget {
|
||||
body: SafeArea(
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: FadingScroll(
|
||||
builder: (context, controller) {
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: isEditMode ? 0 : kToolbarHeight + 56,
|
||||
titleSpacing: 0.0,
|
||||
title: isEditMode
|
||||
? null
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Focus(
|
||||
canRequestFocus: false,
|
||||
child: SegmentedButton(
|
||||
showSelectedIcon: false,
|
||||
segments: [
|
||||
const ButtonSegment(
|
||||
value: TabType.regular,
|
||||
label: Text('Regular'),
|
||||
icon: Icon(MdiIcons.tab),
|
||||
),
|
||||
const ButtonSegment(
|
||||
value: TabType.private,
|
||||
label: Text('Private'),
|
||||
icon: Icon(WebLibreIcons.privateTab),
|
||||
),
|
||||
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:
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: isEditMode ? 0 : kToolbarHeight,
|
||||
titleSpacing: 0.0,
|
||||
title: isEditMode
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
child: AnimatedTabTypeSwitcher(
|
||||
selected: selectedTabType.value,
|
||||
onChanged: (value) {
|
||||
selectedTabType.value = value;
|
||||
// Restore focus to search field after segment change
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
searchFocusNode.requestFocus();
|
||||
});
|
||||
},
|
||||
showChildOption: createChildTabsOption,
|
||||
selectedBackgroundColor:
|
||||
switch (selectedTabType.value) {
|
||||
TabType.regular => null,
|
||||
TabType.private =>
|
||||
appColors.privateSelectionOverlay,
|
||||
),
|
||||
TabType.child =>
|
||||
(currentTabTabType == TabType.private)
|
||||
? SegmentedButton.styleFrom(
|
||||
selectedBackgroundColor: appColors
|
||||
.privateSelectionOverlay,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ContainerChips(
|
||||
selectedContainer: selectedContainer,
|
||||
onSelected: (container) async {
|
||||
if (container != null) {
|
||||
await ref
|
||||
.read(
|
||||
selectedContainerProvider
|
||||
.notifier,
|
||||
)
|
||||
.setContainerId(container.id);
|
||||
} else {
|
||||
ref
|
||||
.read(
|
||||
selectedContainerProvider
|
||||
.notifier,
|
||||
)
|
||||
.clearContainer();
|
||||
}
|
||||
},
|
||||
onDeleted: (container) {
|
||||
ref
|
||||
.read(
|
||||
selectedContainerProvider.notifier,
|
||||
)
|
||||
.clearContainer();
|
||||
},
|
||||
TabType.child =>
|
||||
(currentTabTabType ==
|
||||
TabType.private)
|
||||
? appColors
|
||||
.privateSelectionOverlay
|
||||
: null,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(preferredHeight.value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: SearchField(
|
||||
textFieldKey: textFieldKey,
|
||||
showBangIcon: showBangIcon,
|
||||
textEditingController: searchTextController,
|
||||
focusNode: searchFocusNode,
|
||||
maxLines: isEditMode ? 3 : 1,
|
||||
autofocus: true,
|
||||
label: (activeBang != null)
|
||||
? const Text('Search')
|
||||
: const Text('Address / Search'),
|
||||
unfocusOnTapOutside: false,
|
||||
onSubmitted: (value) async {
|
||||
if (value.isNotEmpty) {
|
||||
var newUrl = uri_parser.tryParseUrl(
|
||||
value,
|
||||
eagerParsing: true,
|
||||
);
|
||||
|
||||
if (newUrl == null) {
|
||||
// Read from both providers - use site if set, otherwise global
|
||||
final siteBang = isEditMode
|
||||
? ref.read(
|
||||
selectedBangDataProvider(
|
||||
domain: existingTabState.url.host,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
final globalBang = ref.read(
|
||||
selectedBangDataProvider(),
|
||||
);
|
||||
final bang =
|
||||
siteBang ??
|
||||
globalBang ??
|
||||
await ref.read(
|
||||
defaultSearchBangDataProvider.future,
|
||||
);
|
||||
|
||||
if (bang != null) {
|
||||
newUrl = bang.getTemplateUrl(value);
|
||||
|
||||
if (!privateTabMode) {
|
||||
await ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.triggerBangSearch(bang, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newUrl != null) {
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(
|
||||
tabSessionProvider(
|
||||
tabId: tabId,
|
||||
).notifier,
|
||||
)
|
||||
.loadUrl(url: newUrl);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: newUrl,
|
||||
private: privateTabMode,
|
||||
parentId:
|
||||
(selectedTabType.value ==
|
||||
TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
container: Value(selectedContainer),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(
|
||||
bottomSheetControllerProvider.notifier,
|
||||
)
|
||||
.requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
activeBang: activeBang,
|
||||
showSuggestions: true,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: CompactContainerSelector(
|
||||
selectedContainer: selectedContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size.fromHeight(preferredHeight.value),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: SearchField(
|
||||
textFieldKey: textFieldKey,
|
||||
showBangIcon: showBangIcon,
|
||||
textEditingController: searchTextController,
|
||||
focusNode: searchFocusNode,
|
||||
maxLines: isEditMode ? 3 : 1,
|
||||
autofocus: true,
|
||||
label: (activeBang != null)
|
||||
? const Text('Search')
|
||||
: const Text('Address / Search'),
|
||||
unfocusOnTapOutside: false,
|
||||
onSubmitted: (value) async {
|
||||
if (value.isNotEmpty) {
|
||||
var newUrl = uri_parser.tryParseUrl(
|
||||
value,
|
||||
eagerParsing: true,
|
||||
);
|
||||
|
||||
if (newUrl == null) {
|
||||
// Read from both providers - use site if set, otherwise global
|
||||
final siteBang = isEditMode
|
||||
? ref.read(
|
||||
selectedBangDataProvider(
|
||||
domain: existingTabState.url.host,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
final globalBang = ref.read(
|
||||
selectedBangDataProvider(),
|
||||
);
|
||||
final bang =
|
||||
siteBang ??
|
||||
globalBang ??
|
||||
await ref.read(
|
||||
defaultSearchBangDataProvider.future,
|
||||
);
|
||||
|
||||
if (bang != null) {
|
||||
newUrl = bang.getTemplateUrl(value);
|
||||
|
||||
if (!privateTabMode) {
|
||||
await ref
|
||||
.read(bangSearchProvider.notifier)
|
||||
.triggerBangSearch(bang, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newUrl != null) {
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(
|
||||
tabSessionProvider(tabId: tabId).notifier,
|
||||
)
|
||||
.loadUrl(url: newUrl);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: newUrl,
|
||||
private: privateTabMode,
|
||||
parentId:
|
||||
(selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
containerSelection:
|
||||
selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(
|
||||
selectedContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
activeBang: activeBang,
|
||||
showSuggestions: true,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ClipboardFillLink(controller: searchTextController),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider()),
|
||||
FullSearchTermSuggestions(
|
||||
searchTextController: searchTextController,
|
||||
activeBang: activeBang,
|
||||
submitSearch: submitSearch,
|
||||
domain: isEditMode ? existingTabState.url.host : null,
|
||||
),
|
||||
TabSearch(searchTextListenable: sampledSearchText),
|
||||
FeedSearch(searchTextNotifier: sampledSearchText),
|
||||
HistorySuggestions(
|
||||
searchTextListenable: sampledSearchText,
|
||||
onUriSelected: (uri) async {
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.loadUrl(url: uri);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: uri,
|
||||
private: privateTabMode,
|
||||
parentId: (selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
container: Value(selectedContainer),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ClipboardFillLink(controller: searchTextController),
|
||||
),
|
||||
FullSearchTermSuggestions(
|
||||
searchTextController: searchTextController,
|
||||
activeBang: activeBang,
|
||||
submitSearch: submitSearch,
|
||||
domain: isEditMode ? existingTabState.url.host : null,
|
||||
),
|
||||
TabSearch(searchTextListenable: sampledSearchText),
|
||||
FeedSearch(searchTextNotifier: sampledSearchText),
|
||||
HistorySuggestions(
|
||||
searchTextListenable: sampledSearchText,
|
||||
onUriSelected: (uri) async {
|
||||
if (isEditMode) {
|
||||
// Load into existing tab
|
||||
await ref
|
||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||
.loadUrl(url: uri);
|
||||
} else {
|
||||
// Create new tab
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: uri,
|
||||
private: privateTabMode,
|
||||
parentId: (selectedTabType.value == TabType.child)
|
||||
? ref.read(selectedTabProvider)
|
||||
: null,
|
||||
launchedFromIntent: launchedFromIntent,
|
||||
selectTab: true,
|
||||
containerSelection: selectedContainer == null
|
||||
? const TabContainerSelection.unassigned()
|
||||
: TabContainerSelection.specific(
|
||||
selectedContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
|
||||
|
||||
/// An animated tab type switcher that only shows the label for the currently
|
||||
/// active option. Inactive options collapse to show only their icon.
|
||||
class AnimatedTabTypeSwitcher extends StatelessWidget {
|
||||
final TabType selected;
|
||||
final ValueChanged<TabType> onChanged;
|
||||
final bool showChildOption;
|
||||
final Color? selectedBackgroundColor;
|
||||
|
||||
const AnimatedTabTypeSwitcher({
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
this.showChildOption = false,
|
||||
this.selectedBackgroundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final borderColor = colorScheme.outline;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(23),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Segment(
|
||||
tabType: TabType.regular,
|
||||
icon: MdiIcons.tab,
|
||||
label: 'Regular',
|
||||
isSelected: selected == TabType.regular,
|
||||
selectedBackgroundColor: selectedBackgroundColor,
|
||||
onTap: () => onChanged(TabType.regular),
|
||||
),
|
||||
_divider(borderColor),
|
||||
_Segment(
|
||||
tabType: TabType.private,
|
||||
icon: WebLibreIcons.privateTab,
|
||||
label: 'Private',
|
||||
isSelected: selected == TabType.private,
|
||||
selectedBackgroundColor: selectedBackgroundColor,
|
||||
onTap: () => onChanged(TabType.private),
|
||||
),
|
||||
if (showChildOption) ...[
|
||||
_divider(borderColor),
|
||||
_Segment(
|
||||
tabType: TabType.child,
|
||||
icon: MdiIcons.fileTree,
|
||||
label: 'Child',
|
||||
isSelected: selected == TabType.child,
|
||||
selectedBackgroundColor: selectedBackgroundColor,
|
||||
onTap: () => onChanged(TabType.child),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _divider(Color color) {
|
||||
return VerticalDivider(width: 1, thickness: 1, color: color);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
final TabType tabType;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final Color? selectedBackgroundColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Segment({
|
||||
required this.tabType,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.selectedBackgroundColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final bgColor = isSelected
|
||||
? (selectedBackgroundColor ?? colorScheme.secondaryContainer)
|
||||
: Colors.transparent;
|
||||
final fgColor = isSelected
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurfaceVariant;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
color: bgColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: fgColor, size: 18),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: isSelected
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: fgColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+144
-136
@@ -23,10 +23,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:weblibre/core/providers/format.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/extensions/uri.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart';
|
||||
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
|
||||
import 'package:weblibre/features/web_feed/domain/providers.dart';
|
||||
@@ -49,6 +50,7 @@ class FeedSearch extends HookConsumerWidget {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final articlesAsync = ref.watch(articleSearchProvider(null));
|
||||
final totalResults = articlesAsync.value?.length ?? 0;
|
||||
|
||||
useOnListenableChange(searchTextNotifier, () async {
|
||||
await ref
|
||||
@@ -66,153 +68,159 @@ class FeedSearch extends HookConsumerWidget {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
const SliverToBoxAdapter(child: Divider()),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Articles', style: Theme.of(context).textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverSkeletonizer(
|
||||
enabled: articlesAsync.isLoading,
|
||||
child: articlesAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (articles) => SliverList.builder(
|
||||
itemCount: articles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final article = articles[index];
|
||||
return SearchModuleSection(
|
||||
title: 'Articles',
|
||||
moduleType: SearchModuleType.articles,
|
||||
totalCount: totalResults,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverSkeletonizer(
|
||||
enabled: articlesAsync.isLoading,
|
||||
child: articlesAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (articles) {
|
||||
return SliverList.builder(
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final article = articles[index];
|
||||
|
||||
final titleHighlight = switch (article) {
|
||||
final FeedArticleQueryResult result =>
|
||||
result.titleHighlight.whenNotEmpty,
|
||||
_ => null,
|
||||
};
|
||||
final titleHighlight = switch (article) {
|
||||
final FeedArticleQueryResult result =>
|
||||
result.titleHighlight.whenNotEmpty,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final searchSnippet = switch (article) {
|
||||
final FeedArticleQueryResult result =>
|
||||
result.summarySnippet.whenNotEmpty ??
|
||||
result.contentSnippet.whenNotEmpty,
|
||||
_ => null,
|
||||
};
|
||||
final searchSnippet = switch (article) {
|
||||
final FeedArticleQueryResult result =>
|
||||
result.summarySnippet.whenNotEmpty ??
|
||||
result.contentSnippet.whenNotEmpty,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final articleDate = article.updated ?? article.created;
|
||||
final articleDate = article.updated ?? article.created;
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: UrlIcon([
|
||||
article.icon ??
|
||||
article.links
|
||||
?.getRelation(FeedLinkRelation.alternate)
|
||||
?.uri ??
|
||||
article.siteLink ??
|
||||
article.feedId.base,
|
||||
], iconSize: 24.0),
|
||||
),
|
||||
title: (titleHighlight.isNotEmpty)
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
titleHighlight!,
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
article.displayTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: UrlIcon([
|
||||
article.icon ??
|
||||
article.links
|
||||
?.getRelation(FeedLinkRelation.alternate)
|
||||
?.uri ??
|
||||
article.siteLink ??
|
||||
article.feedId.base,
|
||||
], iconSize: 24.0),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (searchSnippet.isNotEmpty)
|
||||
Text.rich(
|
||||
buildHighlightedText(
|
||||
searchSnippet!,
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
else
|
||||
(article.summaryPlain != null)
|
||||
? Text(
|
||||
article.summaryPlain!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
title: (titleHighlight.isNotEmpty)
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
titleHighlight!,
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
article.displayTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (searchSnippet.isNotEmpty)
|
||||
Text.rich(
|
||||
buildHighlightedText(
|
||||
searchSnippet!,
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
if (articleDate != null)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Text(
|
||||
ref
|
||||
.read(formatProvider.notifier)
|
||||
.fullDateTime(articleDate),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
else
|
||||
(article.summaryPlain != null)
|
||||
? Text(
|
||||
article.summaryPlain!,
|
||||
style: theme.textTheme.bodySmall,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
if (articleDate != null)
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Text(
|
||||
ref
|
||||
.read(formatProvider.notifier)
|
||||
.fullDateTime(articleDate),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
FeedArticleRoute(
|
||||
articleId: article.id,
|
||||
).pushReplacement(context);
|
||||
onTap: () {
|
||||
FeedArticleRoute(
|
||||
articleId: article.id,
|
||||
).pushReplacement(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
return SliverToBoxAdapter(
|
||||
child: FailureWidget(
|
||||
title: 'Failed searching Articles',
|
||||
exception: error,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: isCollapsed ? 0 : previewItemsPerModule,
|
||||
itemBuilder: (context, index) {
|
||||
return const ListTile(title: Bone.text());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
error: (error, stackTrace) {
|
||||
return SliverToBoxAdapter(
|
||||
child: FailureWidget(
|
||||
title: 'Failed searching Articles',
|
||||
exception: error,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: articlesAsync.value?.length ?? 3,
|
||||
itemBuilder: (context, index) {
|
||||
return const ListTile(title: Bone.text());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ class FixedSearchTermSuggestions extends HookConsumerWidget {
|
||||
searchTextController.text = query;
|
||||
},
|
||||
child: InputChip(
|
||||
// avatar: const Icon(Icons.search),
|
||||
avatar: const Icon(Icons.search),
|
||||
label: Text(query),
|
||||
onSelected: (value) async {
|
||||
if (value) {
|
||||
|
||||
+103
-88
@@ -28,6 +28,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart';
|
||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||
import 'package:weblibre/features/bangs/domain/repositories/data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
|
||||
|
||||
class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
@@ -55,8 +56,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
||||
|
||||
final searchHistory = ref.watch(searchHistoryProvider);
|
||||
final expanded = ref.watch(searchSuggestionsExpandedProvider);
|
||||
|
||||
useOnListenableChange(searchTextController, () {
|
||||
ref
|
||||
@@ -64,46 +65,50 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
.addQuery(searchTextController.text);
|
||||
});
|
||||
|
||||
final MultiSliver listSliver;
|
||||
Widget buildSuggestionChip(
|
||||
String query, {
|
||||
Widget? avatar,
|
||||
Future<void> Function()? onDelete,
|
||||
}) {
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
searchTextController.text = query;
|
||||
},
|
||||
child: InputChip(
|
||||
avatar: avatar ?? const Icon(Icons.search),
|
||||
label: Text(query),
|
||||
onSelected: (value) async {
|
||||
if (value) {
|
||||
await submitSearch(query);
|
||||
}
|
||||
},
|
||||
onDeleted: onDelete == null
|
||||
? null
|
||||
: () async {
|
||||
await onDelete();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<Widget> suggestionChips;
|
||||
|
||||
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
|
||||
final entries = searchHistory.value!;
|
||||
|
||||
listSliver = MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
children: entries.map((entry) {
|
||||
final query = entry.searchQuery;
|
||||
suggestionChips = entries.map((entry) {
|
||||
final query = entry.searchQuery;
|
||||
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
searchTextController.text = query;
|
||||
},
|
||||
child: InputChip(
|
||||
avatar: const Icon(Icons.history),
|
||||
label: Text(query),
|
||||
onSelected: (value) async {
|
||||
if (value) {
|
||||
await submitSearch(query);
|
||||
}
|
||||
},
|
||||
onDeleted: () async {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.removeSearchEntry(query);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return buildSuggestionChip(
|
||||
query,
|
||||
avatar: const Icon(Icons.history),
|
||||
onDelete: () async {
|
||||
await ref
|
||||
.read(bangDataRepositoryProvider.notifier)
|
||||
.removeSearchEntry(query);
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
} else {
|
||||
final prioritizedSuggestions = [
|
||||
if (searchTextIsNotEmpty) searchTextController.text,
|
||||
@@ -113,59 +118,20 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
),
|
||||
];
|
||||
|
||||
listSliver = MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
children: prioritizedSuggestions.map((query) {
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
searchTextController.text = query;
|
||||
},
|
||||
child: InputChip(
|
||||
// avatar: const Icon(Icons.search),
|
||||
label: Text(query),
|
||||
onSelected: (value) async {
|
||||
if (value) {
|
||||
await submitSearch(query);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
suggestionChips = prioritizedSuggestions.map((query) {
|
||||
return buildSuggestionChip(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (domain == null)
|
||||
Text(
|
||||
'Search Provider',
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
SmartBangSelector(
|
||||
domain: domain,
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
final toggleButton = IconButton(
|
||||
onPressed: () {
|
||||
ref.read(searchSuggestionsExpandedProvider.notifier).toggle();
|
||||
},
|
||||
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||
);
|
||||
|
||||
final suggestionsContent = expanded
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
@@ -175,11 +141,60 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||
return CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
controller: controller,
|
||||
slivers: [listSliver],
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Wrap(spacing: 8.0, children: suggestionChips),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: FadingScroll(
|
||||
fadingSize: 25,
|
||||
builder: (context, controller) {
|
||||
return ListView.separated(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: suggestionChips.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) => suggestionChips[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: SmartBangSelector(
|
||||
domain: domain,
|
||||
searchTextController: searchTextController,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: suggestionsContent),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
child: toggleButton,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+73
-72
@@ -24,8 +24,9 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
|
||||
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||
import 'package:weblibre/presentation/hooks/cached_future.dart';
|
||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
@@ -45,6 +46,7 @@ class HistorySuggestions extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider);
|
||||
final totalResults = historySuggestionsAsync.value?.length ?? 0;
|
||||
|
||||
useOnListenableChange(searchTextListenable, () async {
|
||||
if (ref.exists(engineSuggestionsProvider)) {
|
||||
@@ -59,90 +61,89 @@ class HistorySuggestions extends HookConsumerWidget {
|
||||
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: historySuggestionsAsync.isLoading,
|
||||
child: historySuggestionsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (historySuggestions) {
|
||||
return SliverList.builder(
|
||||
itemCount: historySuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = historySuggestions[index];
|
||||
final uri = suggestion.description.mapNotNull(Uri.tryParse);
|
||||
|
||||
return HookBuilder(
|
||||
key: ValueKey(suggestion.id),
|
||||
builder: (context) {
|
||||
final icon = useCachedFuture(
|
||||
() async => suggestion.icon.mapNotNull(tryDecodeImage),
|
||||
[suggestion.description, suggestion.icon],
|
||||
return SearchModuleSection(
|
||||
title: 'History',
|
||||
moduleType: SearchModuleType.history,
|
||||
totalCount: totalResults,
|
||||
contentSliverBuilder:
|
||||
({required bool isCollapsed, required int visibleCount}) => [
|
||||
SliverSkeletonizer(
|
||||
enabled: historySuggestionsAsync.isLoading,
|
||||
child: historySuggestionsAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (historySuggestions) {
|
||||
return SliverList.builder(
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = historySuggestions[index];
|
||||
final uri = suggestion.description.mapNotNull(
|
||||
Uri.tryParse,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: SafeRawImage(
|
||||
image: icon.data,
|
||||
height: 24,
|
||||
width: 24,
|
||||
fallback: const Icon(MdiIcons.web, size: 24),
|
||||
),
|
||||
),
|
||||
title: suggestion.title.mapNotNull(
|
||||
(title) => Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle:
|
||||
uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ??
|
||||
suggestion.description.mapNotNull(
|
||||
(description) => Text(
|
||||
description,
|
||||
return HookBuilder(
|
||||
key: ValueKey(suggestion.id),
|
||||
builder: (context) {
|
||||
final icon = useCachedFuture(
|
||||
() async =>
|
||||
suggestion.icon.mapNotNull(tryDecodeImage),
|
||||
[suggestion.description, suggestion.icon],
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child: SafeRawImage(
|
||||
image: icon.data,
|
||||
height: 24,
|
||||
width: 24,
|
||||
fallback: const Icon(MdiIcons.web, size: 24),
|
||||
),
|
||||
),
|
||||
title: suggestion.title.mapNotNull(
|
||||
(title) => Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (uri != null) {
|
||||
onUriSelected(uri);
|
||||
}
|
||||
subtitle:
|
||||
uri.mapNotNull(
|
||||
(uri) => UriBreadcrumb(uri: uri),
|
||||
) ??
|
||||
suggestion.description.mapNotNull(
|
||||
(description) => Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (uri != null) {
|
||||
onUriSelected(uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
return SliverToBoxAdapter(
|
||||
child: FailureWidget(
|
||||
title: 'Could not load history',
|
||||
exception: error,
|
||||
error: (error, stackTrace) {
|
||||
return SliverToBoxAdapter(
|
||||
child: FailureWidget(
|
||||
title: 'Could not load history',
|
||||
exception: error,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: isCollapsed ? 0 : previewItemsPerModule,
|
||||
itemBuilder: (context, index) {
|
||||
return const ListTile(title: Bone.text());
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => SliverList.builder(
|
||||
itemCount: historySuggestionsAsync.value?.length ?? 3,
|
||||
itemBuilder: (context, index) {
|
||||
return const ListTile(title: Bone.text());
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
|
||||
/// A reusable header widget for search modules that displays a collapse/expand
|
||||
/// chevron on the left, the section title, and a "Show all N" / "Show less"
|
||||
/// button on the right.
|
||||
class SearchModuleHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final int totalCount;
|
||||
final SearchModuleDisplayState displayState;
|
||||
final VoidCallback onToggleCollapse;
|
||||
final VoidCallback onToggleExpansion;
|
||||
|
||||
/// The maximum number of items shown in preview mode.
|
||||
/// The trailing button is hidden when totalCount <= this value.
|
||||
final int previewLimit;
|
||||
|
||||
const SearchModuleHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.totalCount,
|
||||
required this.displayState,
|
||||
required this.onToggleCollapse,
|
||||
required this.onToggleExpansion,
|
||||
this.previewLimit = 3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
|
||||
final isExpanded = displayState == SearchModuleDisplayState.expanded;
|
||||
final showTrailing = !isCollapsed && totalCount > previewLimit;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: onToggleCollapse,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
AnimatedRotation(
|
||||
turns: isCollapsed ? -0.25 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(
|
||||
Icons.expand_more,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showTrailing)
|
||||
TextButton(
|
||||
onPressed: onToggleExpansion,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isExpanded
|
||||
? 'Show less'
|
||||
: 'Show all $totalCount',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 Fabian Freund.
|
||||
*
|
||||
* This file is part of WebLibre
|
||||
* (see https://weblibre.eu).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* 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/>.
|
||||
*/
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart';
|
||||
|
||||
const previewItemsPerModule = 3;
|
||||
|
||||
/// A reusable wrapper for search module sections that handles:
|
||||
/// - Display state management (preview/expanded/collapsed)
|
||||
/// - Pinned header with collapse/expand and show-all/show-less controls
|
||||
/// - Visible item count calculation
|
||||
class SearchModuleSection extends ConsumerWidget {
|
||||
final String title;
|
||||
final SearchModuleType moduleType;
|
||||
final int totalCount;
|
||||
|
||||
/// Builds the content slivers for this module.
|
||||
///
|
||||
/// [isCollapsed] indicates whether the section is fully collapsed (no items).
|
||||
/// [visibleCount] is the number of items to display (0 when collapsed,
|
||||
/// limited in preview, or all when expanded).
|
||||
final List<Widget> Function({
|
||||
required bool isCollapsed,
|
||||
required int visibleCount,
|
||||
})
|
||||
contentSliverBuilder;
|
||||
|
||||
const SearchModuleSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.moduleType,
|
||||
required this.totalCount,
|
||||
required this.contentSliverBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final displayState = ref.watch(
|
||||
searchModuleDisplayStateControllerProvider(moduleType),
|
||||
);
|
||||
|
||||
final isCollapsed = displayState == SearchModuleDisplayState.collapsed;
|
||||
final showAllItems =
|
||||
displayState == SearchModuleDisplayState.expanded ||
|
||||
totalCount <= previewItemsPerModule;
|
||||
final visibleCount = isCollapsed
|
||||
? 0
|
||||
: (showAllItems ? totalCount : previewItemsPerModule);
|
||||
|
||||
return MultiSliver(
|
||||
pushPinnedChildren: true,
|
||||
children: [
|
||||
const SliverToBoxAdapter(child: Divider()),
|
||||
SliverPinnedHeader(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: SearchModuleHeader(
|
||||
title: title,
|
||||
totalCount: totalCount,
|
||||
displayState: displayState,
|
||||
onToggleCollapse: () => ref
|
||||
.read(
|
||||
searchModuleDisplayStateControllerProvider(
|
||||
moduleType,
|
||||
).notifier,
|
||||
)
|
||||
.toggleCollapse(),
|
||||
onToggleExpansion: () => ref
|
||||
.read(
|
||||
searchModuleDisplayStateControllerProvider(
|
||||
moduleType,
|
||||
).notifier,
|
||||
)
|
||||
.toggleExpansion(),
|
||||
),
|
||||
),
|
||||
),
|
||||
...contentSliverBuilder(
|
||||
isCollapsed: isCollapsed,
|
||||
visibleCount: visibleCount,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
-110
@@ -26,13 +26,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.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/domain/providers/selected_container.dart';
|
||||
@@ -128,126 +129,137 @@ class TabSearch extends HookConsumerWidget {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
return MultiSliver(
|
||||
children: [
|
||||
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,
|
||||
showUnassignedChip: containerIdsWithResults.value.containsKey(
|
||||
null,
|
||||
final filteredResultCount = filteredTabs.length;
|
||||
|
||||
return SearchModuleSection(
|
||||
title: 'Tabs',
|
||||
moduleType: SearchModuleType.tabs,
|
||||
totalCount: filteredResultCount,
|
||||
contentSliverBuilder: ({
|
||||
required bool isCollapsed,
|
||||
required int visibleCount,
|
||||
}) => [
|
||||
if (!isCollapsed)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ContainerChips(
|
||||
displayMenu: false,
|
||||
selectedContainer: selectedContainer.value,
|
||||
showUnassignedChip: containerIdsWithResults.value
|
||||
.containsKey(null),
|
||||
onSelected: (container) {
|
||||
selectedContainer.value = container;
|
||||
},
|
||||
onDeleted: (container) {
|
||||
selectedContainer.value = null;
|
||||
},
|
||||
containerFilter: (container) =>
|
||||
containerIdsWithResults.value.containsKey(container.id),
|
||||
containerBadgeCount: (container) =>
|
||||
containerIdsWithResults.value[container?.id] ?? 0,
|
||||
searchTextListenable: searchTextListenable,
|
||||
),
|
||||
onSelected: (container) {
|
||||
selectedContainer.value = container;
|
||||
},
|
||||
onDeleted: (container) {
|
||||
selectedContainer.value = null;
|
||||
},
|
||||
containerFilter: (container) =>
|
||||
containerIdsWithResults.value.containsKey(container.id),
|
||||
containerBadgeCount: (container) =>
|
||||
containerIdsWithResults.value[container?.id] ?? 0,
|
||||
searchTextListenable: searchTextListenable,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: filteredTabs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final result = filteredTabs[index];
|
||||
if (!isCollapsed)
|
||||
SliverList.builder(
|
||||
itemCount: visibleCount,
|
||||
itemBuilder: (context, index) {
|
||||
final result = filteredTabs[index];
|
||||
|
||||
final content =
|
||||
(result.extractedContent?.contains(_matchPrefix) == true)
|
||||
? result.extractedContent
|
||||
: result.fullContent;
|
||||
final content =
|
||||
(result.extractedContent?.contains(_matchPrefix) == true)
|
||||
? result.extractedContent
|
||||
: result.fullContent;
|
||||
|
||||
final titleHasMatch = result.title.contains(_matchPrefix);
|
||||
final urlHasMatch =
|
||||
result.highlightedUrl?.contains(_matchPrefix) ?? false;
|
||||
final bodyHasMatch = content?.contains(_matchPrefix) ?? false;
|
||||
final titleHasMatch = result.title.contains(_matchPrefix);
|
||||
final urlHasMatch =
|
||||
result.highlightedUrl?.contains(_matchPrefix) ?? false;
|
||||
final bodyHasMatch = content?.contains(_matchPrefix) ?? false;
|
||||
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child:
|
||||
result.icon.mapNotNull(
|
||||
(icon) => SafeRawImage(
|
||||
image: icon,
|
||||
height: 24,
|
||||
width: 24,
|
||||
fallback: UrlIcon([result.url], iconSize: 24),
|
||||
),
|
||||
) ??
|
||||
UrlIcon([result.url], iconSize: 24),
|
||||
),
|
||||
title: result.title.mapNotNull(
|
||||
(title) => Text.rich(
|
||||
buildHighlightedText(
|
||||
title,
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return ListTile(
|
||||
leading: RepaintBoundary(
|
||||
child:
|
||||
result.icon.mapNotNull(
|
||||
(icon) => SafeRawImage(
|
||||
image: icon,
|
||||
height: 24,
|
||||
width: 24,
|
||||
fallback: UrlIcon([result.url], iconSize: 24),
|
||||
),
|
||||
) ??
|
||||
UrlIcon([result.url], iconSize: 24),
|
||||
),
|
||||
),
|
||||
subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch))
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
(bodyHasMatch ? content! : result.highlightedUrl!),
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
title: result.title.mapNotNull(
|
||||
(title) => Text.rich(
|
||||
buildHighlightedText(
|
||||
title,
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: UriBreadcrumb(uri: result.url),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.selectTab(result.id);
|
||||
if (result.sourceSearchQuery.isNotEmpty &&
|
||||
ref.read(findInPageControllerProvider(result.id)) ==
|
||||
FindInPageState.hidden()) {
|
||||
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch))
|
||||
? Text.rich(
|
||||
buildHighlightedText(
|
||||
(bodyHasMatch ? content! : result.highlightedUrl!),
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_matchPrefix,
|
||||
_matchSuffix,
|
||||
normalizeWhitespaces: true,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: UriBreadcrumb(uri: result.url),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(findInPageControllerProvider(result.id).notifier)
|
||||
.findAll(text: result.sourceSearchQuery!);
|
||||
}
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.selectTab(result.id);
|
||||
if (result.sourceSearchQuery.isNotEmpty &&
|
||||
ref.read(findInPageControllerProvider(result.id)) ==
|
||||
FindInPageState.hidden()) {
|
||||
await ref
|
||||
.read(findInPageControllerProvider(result.id).notifier)
|
||||
.findAll(text: result.sourceSearchQuery!);
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
if (context.mounted) {
|
||||
ref
|
||||
.read(bottomSheetControllerProvider.notifier)
|
||||
.requestDismiss();
|
||||
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+33
-11
@@ -206,6 +206,35 @@ class _TabbedBangSelector extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the default search provider as a chip.
|
||||
class _DefaultSearchProviderChip extends ConsumerWidget {
|
||||
const _DefaultSearchProviderChip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final defaultBang = ref.watch(defaultSearchBangDataProvider);
|
||||
|
||||
return defaultBang.when(
|
||||
data: (bang) {
|
||||
if (bang == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ActionChip(
|
||||
avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20),
|
||||
label: Text(bang.websiteName),
|
||||
onPressed: () async {
|
||||
// Open bang search when tapped
|
||||
await const BangSearchRoute().push(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The actual chip list with selection handling.
|
||||
class _BangChipsList extends HookConsumerWidget {
|
||||
/// The domain for this list's selection provider.
|
||||
@@ -253,17 +282,10 @@ class _BangChipsList extends HookConsumerWidget {
|
||||
onDeleted: (bang) => _handleDeletion(context, ref, bang),
|
||||
),
|
||||
)
|
||||
else if (displayMenu)
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Press '>' to search Bangs.",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).hintColor,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
else if (displayMenu) ...[
|
||||
const _DefaultSearchProviderChip(),
|
||||
const Spacer(),
|
||||
] else
|
||||
const Spacer(),
|
||||
if (displayMenu)
|
||||
IconButton(
|
||||
|
||||
Reference in New Issue
Block a user