refactor container selection pattern and improve search UI
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* 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:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
|
||||||
|
sealed class TabContainerSelection {
|
||||||
|
const TabContainerSelection();
|
||||||
|
|
||||||
|
const factory TabContainerSelection.useSelected() =
|
||||||
|
UseSelectedContainerTabSelection;
|
||||||
|
|
||||||
|
const factory TabContainerSelection.unassigned() =
|
||||||
|
UnassignedContainerTabSelection;
|
||||||
|
|
||||||
|
const factory TabContainerSelection.specific(ContainerData container) =
|
||||||
|
SpecificContainerTabSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UseSelectedContainerTabSelection extends TabContainerSelection {
|
||||||
|
const UseSelectedContainerTabSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UnassignedContainerTabSelection extends TabContainerSelection {
|
||||||
|
const UnassignedContainerTabSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SpecificContainerTabSelection extends TabContainerSelection {
|
||||||
|
final ContainerData container;
|
||||||
|
|
||||||
|
const SpecificContainerTabSelection(this.container);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import 'package:nullability/nullability.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
|
||||||
@@ -87,20 +88,22 @@ class TabRepository extends _$TabRepository {
|
|||||||
required bool private,
|
required bool private,
|
||||||
HistoryMetadataKey? historyMetadata,
|
HistoryMetadataKey? historyMetadata,
|
||||||
Map<String, String>? additionalHeaders,
|
Map<String, String>? additionalHeaders,
|
||||||
Value<ContainerData?>? container,
|
TabContainerSelection containerSelection =
|
||||||
|
const TabContainerSelection.useSelected(),
|
||||||
bool launchedFromIntent = false,
|
bool launchedFromIntent = false,
|
||||||
}) async {
|
}) async {
|
||||||
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
||||||
|
|
||||||
final assingedContainer =
|
final assignedContainer = switch (containerSelection) {
|
||||||
container ??
|
UseSelectedContainerTabSelection() =>
|
||||||
Value<ContainerData?>(
|
await ref.read(selectedContainerProvider.notifier).fetchData(),
|
||||||
await ref.read(selectedContainerProvider.notifier).fetchData(),
|
UnassignedContainerTabSelection() => null,
|
||||||
);
|
SpecificContainerTabSelection(:final container) => container,
|
||||||
|
};
|
||||||
|
|
||||||
final validatedParentId = await _resolveParentIdForContext(
|
final validatedParentId = await _resolveParentIdForContext(
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
targetContextId: assingedContainer.value?.metadata.contextualIdentity,
|
targetContextId: assignedContainer?.metadata.contextualIdentity,
|
||||||
);
|
);
|
||||||
|
|
||||||
final newTabId = await tabDao.upsertTabTransactional(
|
final newTabId = await tabDao.upsertTabTransactional(
|
||||||
@@ -111,7 +114,7 @@ class TabRepository extends _$TabRepository {
|
|||||||
startLoading: startLoading,
|
startLoading: startLoading,
|
||||||
parentId: validatedParentId,
|
parentId: validatedParentId,
|
||||||
flags: flags,
|
flags: flags,
|
||||||
contextId: assingedContainer.value?.metadata.contextualIdentity,
|
contextId: assignedContainer?.metadata.contextualIdentity,
|
||||||
source: source,
|
source: source,
|
||||||
private: private,
|
private: private,
|
||||||
historyMetadata: historyMetadata,
|
historyMetadata: historyMetadata,
|
||||||
@@ -119,7 +122,7 @@ class TabRepository extends _$TabRepository {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
parentId: Value(validatedParentId),
|
parentId: Value(validatedParentId),
|
||||||
containerId: Value(assingedContainer.value?.id),
|
containerId: Value(assignedContainer?.id),
|
||||||
isPrivate: Value(private),
|
isPrivate: Value(private),
|
||||||
url: Value(url),
|
url: Value(url),
|
||||||
);
|
);
|
||||||
@@ -134,10 +137,17 @@ class TabRepository extends _$TabRepository {
|
|||||||
Future<List<String>> addMultipleTabs({
|
Future<List<String>> addMultipleTabs({
|
||||||
required List<AddTabParams> tabs,
|
required List<AddTabParams> tabs,
|
||||||
String? selectTabId,
|
String? selectTabId,
|
||||||
Value<ContainerData?>? container,
|
TabContainerSelection containerSelection =
|
||||||
|
const TabContainerSelection.unassigned(),
|
||||||
}) async {
|
}) async {
|
||||||
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
final tabDao = ref.read(tabDatabaseProvider).tabDao;
|
||||||
final db = ref.read(tabDatabaseProvider);
|
final db = ref.read(tabDatabaseProvider);
|
||||||
|
final assignedContainer = switch (containerSelection) {
|
||||||
|
UseSelectedContainerTabSelection() =>
|
||||||
|
await ref.read(selectedContainerProvider.notifier).fetchData(),
|
||||||
|
UnassignedContainerTabSelection() => null,
|
||||||
|
SpecificContainerTabSelection(:final container) => container,
|
||||||
|
};
|
||||||
|
|
||||||
return await db.transaction(() async {
|
return await db.transaction(() async {
|
||||||
final createdTabIds = await _tabsService.addMultipleTabs(
|
final createdTabIds = await _tabsService.addMultipleTabs(
|
||||||
@@ -177,7 +187,7 @@ class TabRepository extends _$TabRepository {
|
|||||||
tabId,
|
tabId,
|
||||||
parentId: Value(validatedParentId),
|
parentId: Value(validatedParentId),
|
||||||
source: TabSource.manual,
|
source: TabSource.manual,
|
||||||
containerId: Value(container?.value?.id),
|
containerId: Value(assignedContainer?.id),
|
||||||
isPrivate: Value(tab.private),
|
isPrivate: Value(tab.private),
|
||||||
url: Value(Uri.tryParse(tab.url)),
|
url: Value(Uri.tryParse(tab.url)),
|
||||||
);
|
);
|
||||||
@@ -466,7 +476,9 @@ class TabRepository extends _$TabRepository {
|
|||||||
await addTab(
|
await addTab(
|
||||||
url: uri,
|
url: uri,
|
||||||
private: tabState.isPrivate,
|
private: tabState.isPrivate,
|
||||||
container: Value(containerData),
|
containerSelection: TabContainerSelection.specific(
|
||||||
|
containerData,
|
||||||
|
),
|
||||||
parentId: tabState.id,
|
parentId: tabState.id,
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabRepositoryHash() => r'0a676c0513917e8853677ebd592954bde0f15f57';
|
String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c';
|
||||||
|
|
||||||
abstract class _$TabRepository extends $Notifier<void> {
|
abstract class _$TabRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' show Value;
|
|
||||||
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';
|
||||||
@@ -29,6 +28,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
@@ -329,7 +329,8 @@ class _ExtensionsSection extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: Uri.parse('https://addons.mozilla.org'),
|
url: Uri.parse('https://addons.mozilla.org'),
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
container: const Value(null),
|
containerSelection:
|
||||||
|
const TabContainerSelection.unassigned(),
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -30,6 +29,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/desktop_mode.dart';
|
import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
|
||||||
@@ -42,6 +42,7 @@ import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pw
|
|||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
|
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.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/entities/container_selection_result.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
@@ -239,7 +240,11 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: tabState.url,
|
url: tabState.url,
|
||||||
private: false,
|
private: false,
|
||||||
container: Value(containerData),
|
containerSelection: containerData == null
|
||||||
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(
|
||||||
|
containerData,
|
||||||
|
),
|
||||||
selectTab: false,
|
selectTab: false,
|
||||||
)
|
)
|
||||||
: await ref
|
: await ref
|
||||||
@@ -278,7 +283,11 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: tabState.url,
|
url: tabState.url,
|
||||||
private: true,
|
private: true,
|
||||||
container: Value(containerData),
|
containerSelection: containerData == null
|
||||||
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(
|
||||||
|
containerData,
|
||||||
|
),
|
||||||
selectTab: false,
|
selectTab: false,
|
||||||
)
|
)
|
||||||
: await ref
|
: await ref
|
||||||
@@ -313,25 +322,34 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline),
|
leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline),
|
||||||
child: const Text('Assign Container'),
|
child: const Text('Assign Container'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final targetContainerId =
|
final selection = await const ContainerSelectionRoute()
|
||||||
await const ContainerSelectionRoute().push<String?>(
|
.push<ContainerSelectionResult?>(context);
|
||||||
context,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetContainerId != null) {
|
switch (selection) {
|
||||||
final containerData = await ref
|
case ContainerSelectionSelected(:final containerId):
|
||||||
.read(containerRepositoryProvider.notifier)
|
final containerData = await ref
|
||||||
.getContainerData(targetContainerId);
|
.read(containerRepositoryProvider.notifier)
|
||||||
|
.getContainerData(containerId);
|
||||||
|
|
||||||
if (containerData != null) {
|
if (containerData != null) {
|
||||||
|
final tabState = ref.read(
|
||||||
|
tabStateProvider(selectedTabId),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.assignContainer(tabState.id, containerData);
|
||||||
|
}
|
||||||
|
case ContainerSelectionUnassigned():
|
||||||
final tabState = ref.read(
|
final tabState = ref.read(
|
||||||
tabStateProvider(selectedTabId),
|
tabStateProvider(selectedTabId),
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.assignContainer(tabState.id, containerData);
|
.unassignContainer(tabState.id);
|
||||||
}
|
case null:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -340,15 +358,15 @@ class TabMenu extends HookConsumerWidget {
|
|||||||
leadingIcon: const Icon(MdiIcons.webPlus),
|
leadingIcon: const Icon(MdiIcons.webPlus),
|
||||||
child: const Text('URL relation'),
|
child: const Text('URL relation'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final targetContainerId =
|
final selection = await const ContainerSelectionRoute()
|
||||||
await const ContainerSelectionRoute().push<String?>(
|
.push<ContainerSelectionResult?>(context);
|
||||||
context,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetContainerId != null) {
|
if (selection case ContainerSelectionSelected(
|
||||||
|
:final containerId,
|
||||||
|
)) {
|
||||||
final containerData = await ref
|
final containerData = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getContainerData(targetContainerId);
|
.getContainerData(containerId);
|
||||||
|
|
||||||
if (containerData != null) {
|
if (containerData != null) {
|
||||||
final tabState = ref.read(
|
final tabState = ref.read(
|
||||||
|
|||||||
+5
-4
@@ -20,7 +20,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
|
||||||
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';
|
||||||
@@ -28,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||||
@@ -662,9 +662,10 @@ class TabViewHeader extends HookConsumerWidget {
|
|||||||
.contextualIdentity,
|
.contextualIdentity,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
container: Value(
|
containerSelection:
|
||||||
selectedContainer,
|
TabContainerSelection.specific(
|
||||||
),
|
selectedContainer,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -19,7 +19,6 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' hide Column;
|
|
||||||
import 'package:fading_scroll/fading_scroll.dart';
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
@@ -29,6 +28,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:uuid/enums.dart';
|
import 'package:uuid/enums.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
|
||||||
@@ -103,7 +103,9 @@ class OpenInContainer extends HookConsumerWidget {
|
|||||||
parentId: currentTab?.id,
|
parentId: currentTab?.id,
|
||||||
selectTab: false,
|
selectTab: false,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
container: Value(selectedContainer),
|
containerSelection: TabContainerSelection.specific(
|
||||||
|
selectedContainer,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
+4
-2
@@ -19,7 +19,6 @@
|
|||||||
*/
|
*/
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' hide Column;
|
|
||||||
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';
|
||||||
@@ -28,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
|
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
|
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart';
|
||||||
@@ -167,7 +167,9 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: Uri.parse(textController.text),
|
url: Uri.parse(textController.text),
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
container: Value(selectedContainer.value),
|
containerSelection: selectedContainer.value == null
|
||||||
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(selectedContainer.value!),
|
||||||
launchedFromIntent: true,
|
launchedFromIntent: true,
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ AsyncValue<List<GeckoSuggestion>> engineHistorySuggestions(Ref ref) {
|
|||||||
) !=
|
) !=
|
||||||
null),
|
null),
|
||||||
)
|
)
|
||||||
|
.take(25)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -107,4 +107,4 @@ final class EngineHistorySuggestionsProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$engineHistorySuggestionsHash() =>
|
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 'dart:async';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' hide Column;
|
|
||||||
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/providers/search.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/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/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.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/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/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_field.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.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/history_suggestions.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.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/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/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||||
import 'package:weblibre/presentation/hooks/sampled_value_notifier.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/text_field_line_count.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||||
|
|
||||||
@@ -248,7 +246,9 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
: null,
|
: null,
|
||||||
launchedFromIntent: launchedFromIntent,
|
launchedFromIntent: launchedFromIntent,
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
container: Value(selectedContainer),
|
containerSelection: selectedContainer == null
|
||||||
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(selectedContainer),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,252 +264,213 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: formKey,
|
key: formKey,
|
||||||
child: FadingScroll(
|
child: CustomScrollView(
|
||||||
builder: (context, controller) {
|
slivers: [
|
||||||
return CustomScrollView(
|
SliverAppBar(
|
||||||
controller: controller,
|
floating: true,
|
||||||
slivers: [
|
pinned: true,
|
||||||
SliverAppBar(
|
automaticallyImplyLeading: false,
|
||||||
floating: true,
|
toolbarHeight: isEditMode ? 0 : kToolbarHeight,
|
||||||
pinned: true,
|
titleSpacing: 0.0,
|
||||||
automaticallyImplyLeading: false,
|
title: isEditMode
|
||||||
toolbarHeight: isEditMode ? 0 : kToolbarHeight + 56,
|
? null
|
||||||
titleSpacing: 0.0,
|
: Padding(
|
||||||
title: isEditMode
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
? null
|
child: Row(
|
||||||
: Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Expanded(
|
||||||
children: [
|
flex: 3,
|
||||||
Focus(
|
child: Align(
|
||||||
canRequestFocus: false,
|
alignment: Alignment.centerLeft,
|
||||||
child: SegmentedButton(
|
child: Focus(
|
||||||
showSelectedIcon: false,
|
canRequestFocus: false,
|
||||||
segments: [
|
child: AnimatedTabTypeSwitcher(
|
||||||
const ButtonSegment(
|
selected: selectedTabType.value,
|
||||||
value: TabType.regular,
|
onChanged: (value) {
|
||||||
label: Text('Regular'),
|
selectedTabType.value = value;
|
||||||
icon: Icon(MdiIcons.tab),
|
// Restore focus to search field after segment change
|
||||||
),
|
WidgetsBinding.instance
|
||||||
const ButtonSegment(
|
.addPostFrameCallback((_) {
|
||||||
value: TabType.private,
|
searchFocusNode.requestFocus();
|
||||||
label: Text('Private'),
|
});
|
||||||
icon: Icon(WebLibreIcons.privateTab),
|
},
|
||||||
),
|
showChildOption: createChildTabsOption,
|
||||||
if (createChildTabsOption)
|
selectedBackgroundColor:
|
||||||
const ButtonSegment(
|
switch (selectedTabType.value) {
|
||||||
value: TabType.child,
|
TabType.regular => null,
|
||||||
label: Text('Child'),
|
TabType.private =>
|
||||||
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:
|
|
||||||
appColors.privateSelectionOverlay,
|
appColors.privateSelectionOverlay,
|
||||||
),
|
TabType.child =>
|
||||||
TabType.child =>
|
(currentTabTabType ==
|
||||||
(currentTabTabType == TabType.private)
|
TabType.private)
|
||||||
? SegmentedButton.styleFrom(
|
? appColors
|
||||||
selectedBackgroundColor: appColors
|
.privateSelectionOverlay
|
||||||
.privateSelectionOverlay,
|
: null,
|
||||||
)
|
},
|
||||||
: 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();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
bottom: PreferredSize(
|
Flexible(
|
||||||
preferredSize: Size.fromHeight(preferredHeight.value),
|
flex: 2,
|
||||||
child: Padding(
|
child: Align(
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
alignment: Alignment.centerRight,
|
||||||
child: SearchField(
|
child: CompactContainerSelector(
|
||||||
textFieldKey: textFieldKey,
|
selectedContainer: selectedContainer,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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),
|
),
|
||||||
),
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(child: Divider()),
|
child: ClipboardFillLink(controller: searchTextController),
|
||||||
FullSearchTermSuggestions(
|
),
|
||||||
searchTextController: searchTextController,
|
FullSearchTermSuggestions(
|
||||||
activeBang: activeBang,
|
searchTextController: searchTextController,
|
||||||
submitSearch: submitSearch,
|
activeBang: activeBang,
|
||||||
domain: isEditMode ? existingTabState.url.host : null,
|
submitSearch: submitSearch,
|
||||||
),
|
domain: isEditMode ? existingTabState.url.host : null,
|
||||||
TabSearch(searchTextListenable: sampledSearchText),
|
),
|
||||||
FeedSearch(searchTextNotifier: sampledSearchText),
|
TabSearch(searchTextListenable: sampledSearchText),
|
||||||
HistorySuggestions(
|
FeedSearch(searchTextNotifier: sampledSearchText),
|
||||||
searchTextListenable: sampledSearchText,
|
HistorySuggestions(
|
||||||
onUriSelected: (uri) async {
|
searchTextListenable: sampledSearchText,
|
||||||
if (isEditMode) {
|
onUriSelected: (uri) async {
|
||||||
// Load into existing tab
|
if (isEditMode) {
|
||||||
await ref
|
// Load into existing tab
|
||||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
await ref
|
||||||
.loadUrl(url: uri);
|
.read(tabSessionProvider(tabId: tabId).notifier)
|
||||||
} else {
|
.loadUrl(url: uri);
|
||||||
// Create new tab
|
} else {
|
||||||
await ref
|
// Create new tab
|
||||||
.read(tabRepositoryProvider.notifier)
|
await ref
|
||||||
.addTab(
|
.read(tabRepositoryProvider.notifier)
|
||||||
url: uri,
|
.addTab(
|
||||||
private: privateTabMode,
|
url: uri,
|
||||||
parentId: (selectedTabType.value == TabType.child)
|
private: privateTabMode,
|
||||||
? ref.read(selectedTabProvider)
|
parentId: (selectedTabType.value == TabType.child)
|
||||||
: null,
|
? ref.read(selectedTabProvider)
|
||||||
launchedFromIntent: launchedFromIntent,
|
: null,
|
||||||
selectTab: true,
|
launchedFromIntent: launchedFromIntent,
|
||||||
container: Value(selectedContainer),
|
selectTab: true,
|
||||||
);
|
containerSelection: selectedContainer == null
|
||||||
}
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(
|
||||||
|
selectedContainer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ref
|
ref
|
||||||
.read(bottomSheetControllerProvider.notifier)
|
.read(bottomSheetControllerProvider.notifier)
|
||||||
.requestDismiss();
|
.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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
|
||||||
import 'package:weblibre/core/providers/format.dart';
|
import 'package:weblibre/core/providers/format.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/extensions/uri.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_article_query_result.dart';
|
||||||
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
|
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
|
||||||
import 'package:weblibre/features/web_feed/domain/providers.dart';
|
import 'package:weblibre/features/web_feed/domain/providers.dart';
|
||||||
@@ -49,6 +50,7 @@ class FeedSearch extends HookConsumerWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
final articlesAsync = ref.watch(articleSearchProvider(null));
|
final articlesAsync = ref.watch(articleSearchProvider(null));
|
||||||
|
final totalResults = articlesAsync.value?.length ?? 0;
|
||||||
|
|
||||||
useOnListenableChange(searchTextNotifier, () async {
|
useOnListenableChange(searchTextNotifier, () async {
|
||||||
await ref
|
await ref
|
||||||
@@ -66,153 +68,159 @@ class FeedSearch extends HookConsumerWidget {
|
|||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
return MultiSliver(
|
return SearchModuleSection(
|
||||||
children: [
|
title: 'Articles',
|
||||||
const SliverToBoxAdapter(child: Divider()),
|
moduleType: SearchModuleType.articles,
|
||||||
SliverToBoxAdapter(
|
totalCount: totalResults,
|
||||||
child: Padding(
|
contentSliverBuilder:
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
({required bool isCollapsed, required int visibleCount}) => [
|
||||||
child: Column(
|
SliverSkeletonizer(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
enabled: articlesAsync.isLoading,
|
||||||
children: [
|
child: articlesAsync.when(
|
||||||
Text('Articles', style: Theme.of(context).textTheme.labelSmall),
|
skipLoadingOnReload: true,
|
||||||
],
|
data: (articles) {
|
||||||
),
|
return SliverList.builder(
|
||||||
),
|
itemCount: visibleCount,
|
||||||
),
|
itemBuilder: (context, index) {
|
||||||
SliverSkeletonizer(
|
final article = articles[index];
|
||||||
enabled: articlesAsync.isLoading,
|
|
||||||
child: articlesAsync.when(
|
|
||||||
skipLoadingOnReload: true,
|
|
||||||
data: (articles) => SliverList.builder(
|
|
||||||
itemCount: articles.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final article = articles[index];
|
|
||||||
|
|
||||||
final titleHighlight = switch (article) {
|
final titleHighlight = switch (article) {
|
||||||
final FeedArticleQueryResult result =>
|
final FeedArticleQueryResult result =>
|
||||||
result.titleHighlight.whenNotEmpty,
|
result.titleHighlight.whenNotEmpty,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
final searchSnippet = switch (article) {
|
final searchSnippet = switch (article) {
|
||||||
final FeedArticleQueryResult result =>
|
final FeedArticleQueryResult result =>
|
||||||
result.summarySnippet.whenNotEmpty ??
|
result.summarySnippet.whenNotEmpty ??
|
||||||
result.contentSnippet.whenNotEmpty,
|
result.contentSnippet.whenNotEmpty,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
final articleDate = article.updated ?? article.created;
|
final articleDate = article.updated ?? article.created;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: RepaintBoundary(
|
leading: RepaintBoundary(
|
||||||
child: UrlIcon([
|
child: UrlIcon([
|
||||||
article.icon ??
|
article.icon ??
|
||||||
article.links
|
article.links
|
||||||
?.getRelation(FeedLinkRelation.alternate)
|
?.getRelation(FeedLinkRelation.alternate)
|
||||||
?.uri ??
|
?.uri ??
|
||||||
article.siteLink ??
|
article.siteLink ??
|
||||||
article.feedId.base,
|
article.feedId.base,
|
||||||
], iconSize: 24.0),
|
], 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,
|
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
title: (titleHighlight.isNotEmpty)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
? Text.rich(
|
||||||
children: [
|
buildHighlightedText(
|
||||||
if (searchSnippet.isNotEmpty)
|
titleHighlight!,
|
||||||
Text.rich(
|
Theme.of(
|
||||||
buildHighlightedText(
|
context,
|
||||||
searchSnippet!,
|
).textTheme.titleMedium?.copyWith(
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
color: Theme.of(
|
||||||
color: Theme.of(
|
context,
|
||||||
context,
|
).colorScheme.onSurface,
|
||||||
).colorScheme.onSurfaceVariant,
|
),
|
||||||
),
|
Theme.of(
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
context,
|
||||||
color: Theme.of(
|
).textTheme.titleMedium?.copyWith(
|
||||||
context,
|
color: Theme.of(
|
||||||
).colorScheme.onSurfaceVariant,
|
context,
|
||||||
fontWeight: FontWeight.bold,
|
).colorScheme.onSurface,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
_matchPrefix,
|
),
|
||||||
_matchSuffix,
|
_matchPrefix,
|
||||||
normalizeWhitespaces: true,
|
_matchSuffix,
|
||||||
),
|
),
|
||||||
maxLines: 3,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
)
|
)
|
||||||
else
|
: Text(
|
||||||
(article.summaryPlain != null)
|
article.displayTitle,
|
||||||
? Text(
|
style: theme.textTheme.titleMedium,
|
||||||
article.summaryPlain!,
|
maxLines: 2,
|
||||||
style: theme.textTheme.bodySmall,
|
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,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
else
|
||||||
if (articleDate != null)
|
(article.summaryPlain != null)
|
||||||
Align(
|
? Text(
|
||||||
alignment: Alignment.topRight,
|
article.summaryPlain!,
|
||||||
child: Text(
|
style: theme.textTheme.bodySmall,
|
||||||
ref
|
maxLines: 3,
|
||||||
.read(formatProvider.notifier)
|
overflow: TextOverflow.ellipsis,
|
||||||
.fullDateTime(articleDate),
|
)
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
: const SizedBox.shrink(),
|
||||||
fontStyle: FontStyle.italic,
|
if (articleDate != null)
|
||||||
),
|
Align(
|
||||||
maxLines: 1,
|
alignment: Alignment.topRight,
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
),
|
ref
|
||||||
|
.read(formatProvider.notifier)
|
||||||
|
.fullDateTime(articleDate),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
onTap: () {
|
||||||
),
|
FeedArticleRoute(
|
||||||
onTap: () {
|
articleId: article.id,
|
||||||
FeedArticleRoute(
|
).pushReplacement(context);
|
||||||
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;
|
searchTextController.text = query;
|
||||||
},
|
},
|
||||||
child: InputChip(
|
child: InputChip(
|
||||||
// avatar: const Icon(Icons.search),
|
avatar: const Icon(Icons.search),
|
||||||
label: Text(query),
|
label: Text(query),
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value) {
|
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/providers/bangs.dart';
|
||||||
import 'package:weblibre/features/bangs/domain/repositories/data.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.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';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
|
||||||
|
|
||||||
class FullSearchTermSuggestions extends HookConsumerWidget {
|
class FullSearchTermSuggestions extends HookConsumerWidget {
|
||||||
@@ -55,8 +56,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
final searchSuggestions = ref.watch(searchSuggestionsProvider());
|
||||||
|
|
||||||
final searchHistory = ref.watch(searchHistoryProvider);
|
final searchHistory = ref.watch(searchHistoryProvider);
|
||||||
|
final expanded = ref.watch(searchSuggestionsExpandedProvider);
|
||||||
|
|
||||||
useOnListenableChange(searchTextController, () {
|
useOnListenableChange(searchTextController, () {
|
||||||
ref
|
ref
|
||||||
@@ -64,46 +65,50 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
|||||||
.addQuery(searchTextController.text);
|
.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)) {
|
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
|
||||||
final entries = searchHistory.value!;
|
final entries = searchHistory.value!;
|
||||||
|
|
||||||
listSliver = MultiSliver(
|
suggestionChips = entries.map((entry) {
|
||||||
children: [
|
final query = entry.searchQuery;
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 8.0,
|
|
||||||
children: entries.map((entry) {
|
|
||||||
final query = entry.searchQuery;
|
|
||||||
|
|
||||||
return InkWell(
|
return buildSuggestionChip(
|
||||||
onLongPress: () {
|
query,
|
||||||
searchTextController.text = query;
|
avatar: const Icon(Icons.history),
|
||||||
},
|
onDelete: () async {
|
||||||
child: InputChip(
|
await ref
|
||||||
avatar: const Icon(Icons.history),
|
.read(bangDataRepositoryProvider.notifier)
|
||||||
label: Text(query),
|
.removeSearchEntry(query);
|
||||||
onSelected: (value) async {
|
},
|
||||||
if (value) {
|
);
|
||||||
await submitSearch(query);
|
}).toList();
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeleted: () async {
|
|
||||||
await ref
|
|
||||||
.read(bangDataRepositoryProvider.notifier)
|
|
||||||
.removeSearchEntry(query);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
final prioritizedSuggestions = [
|
final prioritizedSuggestions = [
|
||||||
if (searchTextIsNotEmpty) searchTextController.text,
|
if (searchTextIsNotEmpty) searchTextController.text,
|
||||||
@@ -113,59 +118,20 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
listSliver = MultiSliver(
|
suggestionChips = prioritizedSuggestions.map((query) {
|
||||||
children: [
|
return buildSuggestionChip(query);
|
||||||
SliverToBoxAdapter(
|
}).toList();
|
||||||
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(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return MultiSliver(
|
final toggleButton = IconButton(
|
||||||
children: [
|
onPressed: () {
|
||||||
SliverToBoxAdapter(
|
ref.read(searchSuggestionsExpandedProvider.notifier).toggle();
|
||||||
child: Padding(
|
},
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
|
||||||
child: Column(
|
);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
final suggestionsContent = expanded
|
||||||
if (domain == null)
|
? Padding(
|
||||||
Text(
|
|
||||||
'Search Provider',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
SmartBangSelector(
|
|
||||||
domain: domain,
|
|
||||||
searchTextController: searchTextController,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 150),
|
constraints: const BoxConstraints(maxHeight: 150),
|
||||||
@@ -175,11 +141,60 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
|
|||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
controller: controller,
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.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/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/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/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
@@ -45,6 +46,7 @@ class HistorySuggestions extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider);
|
final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider);
|
||||||
|
final totalResults = historySuggestionsAsync.value?.length ?? 0;
|
||||||
|
|
||||||
useOnListenableChange(searchTextListenable, () async {
|
useOnListenableChange(searchTextListenable, () async {
|
||||||
if (ref.exists(engineSuggestionsProvider)) {
|
if (ref.exists(engineSuggestionsProvider)) {
|
||||||
@@ -59,90 +61,89 @@ class HistorySuggestions extends HookConsumerWidget {
|
|||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
return MultiSliver(
|
return SearchModuleSection(
|
||||||
children: [
|
title: 'History',
|
||||||
const SliverToBoxAdapter(child: Divider()),
|
moduleType: SearchModuleType.history,
|
||||||
SliverToBoxAdapter(
|
totalCount: totalResults,
|
||||||
child: Padding(
|
contentSliverBuilder:
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
({required bool isCollapsed, required int visibleCount}) => [
|
||||||
child: Text(
|
SliverSkeletonizer(
|
||||||
'History',
|
enabled: historySuggestionsAsync.isLoading,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
child: historySuggestionsAsync.when(
|
||||||
),
|
skipLoadingOnReload: true,
|
||||||
),
|
data: (historySuggestions) {
|
||||||
),
|
return SliverList.builder(
|
||||||
SliverSkeletonizer(
|
itemCount: visibleCount,
|
||||||
enabled: historySuggestionsAsync.isLoading,
|
itemBuilder: (context, index) {
|
||||||
child: historySuggestionsAsync.when(
|
final suggestion = historySuggestions[index];
|
||||||
skipLoadingOnReload: true,
|
final uri = suggestion.description.mapNotNull(
|
||||||
data: (historySuggestions) {
|
Uri.tryParse,
|
||||||
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 ListTile(
|
return HookBuilder(
|
||||||
leading: RepaintBoundary(
|
key: ValueKey(suggestion.id),
|
||||||
child: SafeRawImage(
|
builder: (context) {
|
||||||
image: icon.data,
|
final icon = useCachedFuture(
|
||||||
height: 24,
|
() async =>
|
||||||
width: 24,
|
suggestion.icon.mapNotNull(tryDecodeImage),
|
||||||
fallback: const Icon(MdiIcons.web, size: 24),
|
[suggestion.description, suggestion.icon],
|
||||||
),
|
);
|
||||||
),
|
|
||||||
title: suggestion.title.mapNotNull(
|
return ListTile(
|
||||||
(title) => Text(
|
leading: RepaintBoundary(
|
||||||
title,
|
child: SafeRawImage(
|
||||||
maxLines: 2,
|
image: icon.data,
|
||||||
overflow: TextOverflow.ellipsis,
|
height: 24,
|
||||||
),
|
width: 24,
|
||||||
),
|
fallback: const Icon(MdiIcons.web, size: 24),
|
||||||
subtitle:
|
),
|
||||||
uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ??
|
),
|
||||||
suggestion.description.mapNotNull(
|
title: suggestion.title.mapNotNull(
|
||||||
(description) => Text(
|
(title) => Text(
|
||||||
description,
|
title,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
subtitle:
|
||||||
if (uri != null) {
|
uri.mapNotNull(
|
||||||
onUriSelected(uri);
|
(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(
|
||||||
error: (error, stackTrace) {
|
child: FailureWidget(
|
||||||
return SliverToBoxAdapter(
|
title: 'Could not load history',
|
||||||
child: FailureWidget(
|
exception: error,
|
||||||
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:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/browser/domain/providers.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/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/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/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';
|
||||||
@@ -128,126 +129,137 @@ class TabSearch extends HookConsumerWidget {
|
|||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
}
|
}
|
||||||
|
|
||||||
return MultiSliver(
|
final filteredResultCount = filteredTabs.length;
|
||||||
children: [
|
|
||||||
const SliverToBoxAdapter(child: Divider()),
|
return SearchModuleSection(
|
||||||
SliverToBoxAdapter(
|
title: 'Tabs',
|
||||||
child: Padding(
|
moduleType: SearchModuleType.tabs,
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
totalCount: filteredResultCount,
|
||||||
child: Column(
|
contentSliverBuilder: ({
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
required bool isCollapsed,
|
||||||
children: [
|
required int visibleCount,
|
||||||
Text('Tabs', style: Theme.of(context).textTheme.labelSmall),
|
}) => [
|
||||||
ContainerChips(
|
if (!isCollapsed)
|
||||||
displayMenu: false,
|
SliverToBoxAdapter(
|
||||||
selectedContainer: selectedContainer.value,
|
child: Padding(
|
||||||
showUnassignedChip: containerIdsWithResults.value.containsKey(
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
null,
|
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (!isCollapsed)
|
||||||
SliverList.builder(
|
SliverList.builder(
|
||||||
itemCount: filteredTabs.length,
|
itemCount: visibleCount,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final result = filteredTabs[index];
|
final result = filteredTabs[index];
|
||||||
|
|
||||||
final content =
|
final content =
|
||||||
(result.extractedContent?.contains(_matchPrefix) == true)
|
(result.extractedContent?.contains(_matchPrefix) == true)
|
||||||
? result.extractedContent
|
? result.extractedContent
|
||||||
: result.fullContent;
|
: result.fullContent;
|
||||||
|
|
||||||
final titleHasMatch = result.title.contains(_matchPrefix);
|
final titleHasMatch = result.title.contains(_matchPrefix);
|
||||||
final urlHasMatch =
|
final urlHasMatch =
|
||||||
result.highlightedUrl?.contains(_matchPrefix) ?? false;
|
result.highlightedUrl?.contains(_matchPrefix) ?? false;
|
||||||
final bodyHasMatch = content?.contains(_matchPrefix) ?? false;
|
final bodyHasMatch = content?.contains(_matchPrefix) ?? false;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: RepaintBoundary(
|
leading: RepaintBoundary(
|
||||||
child:
|
child:
|
||||||
result.icon.mapNotNull(
|
result.icon.mapNotNull(
|
||||||
(icon) => SafeRawImage(
|
(icon) => SafeRawImage(
|
||||||
image: icon,
|
image: icon,
|
||||||
height: 24,
|
height: 24,
|
||||||
width: 24,
|
width: 24,
|
||||||
fallback: UrlIcon([result.url], iconSize: 24),
|
fallback: UrlIcon([result.url], iconSize: 24),
|
||||||
),
|
),
|
||||||
) ??
|
) ??
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
title: result.title.mapNotNull(
|
||||||
subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch))
|
(title) => Text.rich(
|
||||||
? Text.rich(
|
buildHighlightedText(
|
||||||
buildHighlightedText(
|
title,
|
||||||
(bodyHasMatch ? content! : result.highlightedUrl!),
|
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
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,
|
Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
overflow: TextOverflow.ellipsis,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
)
|
fontWeight: FontWeight.bold,
|
||||||
: UriBreadcrumb(uri: result.url),
|
),
|
||||||
onTap: () async {
|
_matchPrefix,
|
||||||
await ref
|
_matchSuffix,
|
||||||
.read(tabRepositoryProvider.notifier)
|
),
|
||||||
.selectTab(result.id);
|
maxLines: 2,
|
||||||
if (result.sourceSearchQuery.isNotEmpty &&
|
overflow: TextOverflow.ellipsis,
|
||||||
ref.read(findInPageControllerProvider(result.id)) ==
|
),
|
||||||
FindInPageState.hidden()) {
|
),
|
||||||
|
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
|
await ref
|
||||||
.read(findInPageControllerProvider(result.id).notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.findAll(text: result.sourceSearchQuery!);
|
.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) {
|
if (context.mounted) {
|
||||||
ref
|
ref
|
||||||
.read(bottomSheetControllerProvider.notifier)
|
.read(bottomSheetControllerProvider.notifier)
|
||||||
.requestDismiss();
|
.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.
|
/// The actual chip list with selection handling.
|
||||||
class _BangChipsList extends HookConsumerWidget {
|
class _BangChipsList extends HookConsumerWidget {
|
||||||
/// The domain for this list's selection provider.
|
/// The domain for this list's selection provider.
|
||||||
@@ -253,17 +282,10 @@ class _BangChipsList extends HookConsumerWidget {
|
|||||||
onDeleted: (bang) => _handleDeletion(context, ref, bang),
|
onDeleted: (bang) => _handleDeletion(context, ref, bang),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (displayMenu)
|
else if (displayMenu) ...[
|
||||||
Expanded(
|
const _DefaultSearchProviderChip(),
|
||||||
child: Text(
|
const Spacer(),
|
||||||
"Press '>' to search Bangs.",
|
] else
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).hintColor,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (displayMenu)
|
if (displayMenu)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
required String ellipsis,
|
required String ellipsis,
|
||||||
required int snippetLength,
|
required int snippetLength,
|
||||||
required String searchString,
|
required String searchString,
|
||||||
|
int limit = 25,
|
||||||
}) {
|
}) {
|
||||||
final ftsQuery = db.buildFtsQuery(searchString);
|
final ftsQuery = db.buildFtsQuery(searchString);
|
||||||
|
|
||||||
@@ -380,10 +381,12 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
beforeMatch: matchPrefix,
|
beforeMatch: matchPrefix,
|
||||||
afterMatch: matchSuffix,
|
afterMatch: matchSuffix,
|
||||||
ellipsis: ellipsis,
|
ellipsis: ellipsis,
|
||||||
|
limit: limit,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return db.definitionsDrift.queryTabsBasic(
|
return db.definitionsDrift.queryTabsBasic(
|
||||||
query: db.buildLikeQuery(searchString),
|
query: db.buildLikeQuery(searchString),
|
||||||
|
limit: limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ queryTabsBasic WITH TabQueryResult:
|
|||||||
fts.title LIKE :query OR
|
fts.title LIKE :query OR
|
||||||
fts.url LIKE :query
|
fts.url LIKE :query
|
||||||
ORDER BY
|
ORDER BY
|
||||||
weighted_rank ASC,
|
weighted_rank ASC,
|
||||||
t.timestamp DESC;
|
t.timestamp DESC
|
||||||
|
LIMIT :limit;
|
||||||
|
|
||||||
queryTabsFullContent WITH TabQueryResult:
|
queryTabsFullContent WITH TabQueryResult:
|
||||||
WITH weights AS (
|
WITH weights AS (
|
||||||
@@ -194,7 +195,8 @@ queryTabsFullContent WITH TabQueryResult:
|
|||||||
CROSS JOIN weights
|
CROSS JOIN weights
|
||||||
ORDER BY
|
ORDER BY
|
||||||
weighted_rank ASC,
|
weighted_rank ASC,
|
||||||
t.timestamp DESC;
|
t.timestamp DESC
|
||||||
|
LIMIT :limit;
|
||||||
|
|
||||||
tabTrees:
|
tabTrees:
|
||||||
WITH RECURSIVE descendants AS (
|
WITH RECURSIVE descendants AS (
|
||||||
|
|||||||
@@ -2284,10 +2284,13 @@ class DefinitionsDrift extends i8.ModularAccessor {
|
|||||||
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
).map((i0.QueryRow row) => row.read<String>('_c0'));
|
||||||
}
|
}
|
||||||
|
|
||||||
i0.Selectable<i9.TabQueryResult> queryTabsBasic({required String query}) {
|
i0.Selectable<i9.TabQueryResult> queryTabsBasic({
|
||||||
|
required String query,
|
||||||
|
required int limit,
|
||||||
|
}) {
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.is_private, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC',
|
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.is_private, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?2',
|
||||||
variables: [i0.Variable<String>(query)],
|
variables: [i0.Variable<String>(query), i0.Variable<int>(limit)],
|
||||||
readsFrom: {tab, tabFts},
|
readsFrom: {tab, tabFts},
|
||||||
).map(
|
).map(
|
||||||
(i0.QueryRow row) => i9.TabQueryResult(
|
(i0.QueryRow row) => i9.TabQueryResult(
|
||||||
@@ -2310,15 +2313,17 @@ class DefinitionsDrift extends i8.ModularAccessor {
|
|||||||
required String ellipsis,
|
required String ellipsis,
|
||||||
required int snippetLength,
|
required int snippetLength,
|
||||||
required String query,
|
required String query,
|
||||||
|
required int limit,
|
||||||
}) {
|
}) {
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, t.container_id, t.is_private, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC',
|
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, t.container_id, t.is_private, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC LIMIT ?6',
|
||||||
variables: [
|
variables: [
|
||||||
i0.Variable<String>(beforeMatch),
|
i0.Variable<String>(beforeMatch),
|
||||||
i0.Variable<String>(afterMatch),
|
i0.Variable<String>(afterMatch),
|
||||||
i0.Variable<String>(ellipsis),
|
i0.Variable<String>(ellipsis),
|
||||||
i0.Variable<int>(snippetLength),
|
i0.Variable<int>(snippetLength),
|
||||||
i0.Variable<String>(query),
|
i0.Variable<String>(query),
|
||||||
|
i0.Variable<int>(limit),
|
||||||
],
|
],
|
||||||
readsFrom: {tab, tabFts},
|
readsFrom: {tab, tabFts},
|
||||||
).map(
|
).map(
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sealed class ContainerSelectionResult {
|
||||||
|
const ContainerSelectionResult();
|
||||||
|
|
||||||
|
const factory ContainerSelectionResult.selected(String containerId) =
|
||||||
|
ContainerSelectionSelected;
|
||||||
|
|
||||||
|
const factory ContainerSelectionResult.unassigned() =
|
||||||
|
ContainerSelectionUnassigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ContainerSelectionSelected extends ContainerSelectionResult {
|
||||||
|
final String containerId;
|
||||||
|
|
||||||
|
const ContainerSelectionSelected(this.containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ContainerSelectionUnassigned extends ContainerSelectionResult {
|
||||||
|
const ContainerSelectionUnassigned();
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
* 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:drift/drift.dart';
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.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/selected_tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
|
||||||
@@ -57,7 +57,9 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: tabState.url,
|
url: tabState.url,
|
||||||
private: tabState.isPrivate,
|
private: tabState.isPrivate,
|
||||||
container: Value(targetContainer),
|
containerSelection: TabContainerSelection.specific(
|
||||||
|
targetContainer,
|
||||||
|
),
|
||||||
// parentId defaults to null - breaks parent chain when changing contextual identity
|
// parentId defaults to null - breaks parent chain when changing contextual identity
|
||||||
selectTab: selectedTabId == tabState.id,
|
selectTab: selectedTabId == tabState.id,
|
||||||
);
|
);
|
||||||
@@ -85,7 +87,7 @@ class TabDataRepository extends _$TabDataRepository {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: tabState.url,
|
url: tabState.url,
|
||||||
private: tabState.isPrivate,
|
private: tabState.isPrivate,
|
||||||
container: const Value(null),
|
containerSelection: const TabContainerSelection.unassigned(),
|
||||||
// parentId defaults to null - breaks parent chain when removing contextual identity
|
// parentId defaults to null - breaks parent chain when removing contextual identity
|
||||||
selectTab: selectedTabId == tabState.id,
|
selectTab: selectedTabId == tabState.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabDataRepositoryHash() => r'c872509a70e4c0c029b468457e15c5e5bff10c65';
|
String _$tabDataRepositoryHash() => r'c1bdfbb576379a7d2ac7b2fe310dbec090e7fd4d';
|
||||||
|
|
||||||
abstract class _$TabDataRepository extends $Notifier<void> {
|
abstract class _$TabDataRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class TabSearchRepository extends _$TabSearchRepository {
|
|||||||
Future<void> addQuery(
|
Future<void> addQuery(
|
||||||
String input, {
|
String input, {
|
||||||
int snippetLength = 120,
|
int snippetLength = 120,
|
||||||
|
int maxResults = 25,
|
||||||
String matchPrefix = '***',
|
String matchPrefix = '***',
|
||||||
String matchSuffix = '***',
|
String matchSuffix = '***',
|
||||||
String ellipsis = '…',
|
String ellipsis = '…',
|
||||||
@@ -49,6 +50,7 @@ class TabSearchRepository extends _$TabSearchRepository {
|
|||||||
ellipsis: ellipsis,
|
ellipsis: ellipsis,
|
||||||
snippetLength: snippetLength,
|
snippetLength: snippetLength,
|
||||||
searchString: input,
|
searchString: input,
|
||||||
|
limit: maxResults,
|
||||||
)
|
)
|
||||||
.get(),
|
.get(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ final class TabSearchRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$tabSearchRepositoryHash() =>
|
String _$tabSearchRepositoryHash() =>
|
||||||
r'ac2381c692b9caf93f26f302d15e0160c098917a';
|
r'473057d6a9f1e76d6b7fec13f710189d758bc924';
|
||||||
|
|
||||||
final class TabSearchRepositoryFamily extends $Family
|
final class TabSearchRepositoryFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
+37
-4
@@ -21,13 +21,16 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:fading_scroll/fading_scroll.dart';
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:uuid/enums.dart';
|
import 'package:uuid/enums.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.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/entities/container_selection_result.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.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/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
@@ -39,6 +42,8 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final containersAsync = ref.watch(watchContainersWithCountProvider);
|
final containersAsync = ref.watch(watchContainersWithCountProvider);
|
||||||
|
final selectedContainerId = ref.watch(selectedContainerProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Select Container')),
|
appBar: AppBar(title: const Text('Select Container')),
|
||||||
body: Skeletonizer(
|
body: Skeletonizer(
|
||||||
@@ -53,14 +58,42 @@ class ContainerSelectionScreen extends HookConsumerWidget {
|
|||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: floatingActionButtonBottomInset(context),
|
bottom: floatingActionButtonBottomInset(context),
|
||||||
),
|
),
|
||||||
itemCount: containers.length,
|
itemCount: containers.length + 1,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final container = containers[index];
|
if (index == 0) {
|
||||||
|
return ListTileTheme(
|
||||||
|
selectedColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimaryContainer,
|
||||||
|
selectedTileColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primaryContainer,
|
||||||
|
child: ListTile(
|
||||||
|
selected: selectedContainerId == null,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
|
child: const Icon(MdiIcons.folderHidden),
|
||||||
|
),
|
||||||
|
title: const Text('Unassigned'),
|
||||||
|
onTap: () {
|
||||||
|
context.pop<ContainerSelectionResult>(
|
||||||
|
const ContainerSelectionResult.unassigned(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final container = containers[index - 1];
|
||||||
return ContainerListTile(
|
return ContainerListTile(
|
||||||
container,
|
container,
|
||||||
isSelected: false,
|
isSelected: container.id == selectedContainerId,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pop<String?>(container.id);
|
context.pop<ContainerSelectionResult>(
|
||||||
|
ContainerSelectionResult.selected(container.id),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* 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:flutter/material.dart';
|
||||||
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||||
|
|
||||||
|
/// A compact container selector that displays only the currently selected
|
||||||
|
/// container (or "unassigned" if none selected) without counts.
|
||||||
|
/// Tapping opens the container selection screen.
|
||||||
|
/// Long pressing opens the edit screen for the selected container.
|
||||||
|
class CompactContainerSelector extends ConsumerWidget {
|
||||||
|
final ContainerData? selectedContainer;
|
||||||
|
|
||||||
|
const CompactContainerSelector({super.key, this.selectedContainer});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isSelected = selectedContainer != null;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: isSelected
|
||||||
|
? () async {
|
||||||
|
await ContainerEditRoute(
|
||||||
|
containerData: jsonEncode(selectedContainer!.toJson()),
|
||||||
|
).push(context);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: ActionChip(
|
||||||
|
avatar: isSelected ? null : const Icon(MdiIcons.folderHidden),
|
||||||
|
label: isSelected
|
||||||
|
? ContainerTitle(container: selectedContainer!)
|
||||||
|
: const Text('Unassigned'),
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? ContainerColors.forChip(selectedContainer!.color)
|
||||||
|
: null,
|
||||||
|
side: isSelected ? BorderSide(color: theme.colorScheme.primary) : null,
|
||||||
|
onPressed: () async {
|
||||||
|
final selection = await const ContainerSelectionRoute()
|
||||||
|
.push<ContainerSelectionResult?>(context);
|
||||||
|
|
||||||
|
switch (selection) {
|
||||||
|
case ContainerSelectionSelected(:final containerId):
|
||||||
|
await ref
|
||||||
|
.read(selectedContainerProvider.notifier)
|
||||||
|
.setContainerId(containerId);
|
||||||
|
case ContainerSelectionUnassigned():
|
||||||
|
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,6 @@ class ContainerListTile extends HookWidget {
|
|||||||
),
|
),
|
||||||
title: ContainerTitle(container: container),
|
title: ContainerTitle(container: container),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ class ContainerTitle extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
if (container.name.isNotEmpty) {
|
if (container.name.isNotEmpty) {
|
||||||
return Text(container.name!);
|
return Text(
|
||||||
|
container.name!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final topicAsync = ref.watch(containerTopicProvider(container.id));
|
final topicAsync = ref.watch(containerTopicProvider(container.id));
|
||||||
@@ -59,6 +63,8 @@ class ContainerTitle extends HookConsumerWidget {
|
|||||||
const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)),
|
const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
),
|
),
|
||||||
) ??
|
) ??
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
* 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:drift/drift.dart' show Value;
|
|
||||||
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
||||||
import 'package:weblibre/core/providers/defaults.dart';
|
import 'package:weblibre/core/providers/defaults.dart';
|
||||||
import 'package:weblibre/core/providers/router.dart';
|
import 'package:weblibre/core/providers/router.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
|
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
|
||||||
import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart';
|
import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart';
|
||||||
@@ -148,7 +148,8 @@ class OnboardingScreen extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: ref.read(docsUriProvider),
|
url: ref.read(docsUriProvider),
|
||||||
private: false,
|
private: false,
|
||||||
container: const Value(null),
|
containerSelection:
|
||||||
|
const TabContainerSelection.unassigned(),
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||||
@@ -282,7 +282,9 @@ class SyncRepository extends _$SyncRepository {
|
|||||||
url: uri,
|
url: uri,
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
private: false,
|
private: false,
|
||||||
container: Value(assignedContainer),
|
containerSelection: assignedContainer == null
|
||||||
|
? const TabContainerSelection.unassigned()
|
||||||
|
: TabContainerSelection.specific(assignedContainer),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ final class SyncRepositoryProvider
|
|||||||
SyncRepository create() => SyncRepository();
|
SyncRepository create() => SyncRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312';
|
String _$syncRepositoryHash() => r'5bfe6deacfea9b14981a0b9769857f23b7dd478c';
|
||||||
|
|
||||||
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
|
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
|
||||||
FutureOr<SyncRepositoryState> build();
|
FutureOr<SyncRepositoryState> build();
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with $ArticleDaoMixin {
|
|||||||
required int snippetLength,
|
required int snippetLength,
|
||||||
required String searchString,
|
required String searchString,
|
||||||
required Uri? feedId,
|
required Uri? feedId,
|
||||||
|
int limit = 25,
|
||||||
}) {
|
}) {
|
||||||
final ftsQuery = db.buildFtsQuery(searchString);
|
final ftsQuery = db.buildFtsQuery(searchString);
|
||||||
|
|
||||||
@@ -159,11 +160,13 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with $ArticleDaoMixin {
|
|||||||
beforeMatch: matchPrefix,
|
beforeMatch: matchPrefix,
|
||||||
afterMatch: matchSuffix,
|
afterMatch: matchSuffix,
|
||||||
ellipsis: ellipsis,
|
ellipsis: ellipsis,
|
||||||
|
limit: limit,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return db.definitionsDrift.queryArticlesBasic(
|
return db.definitionsDrift.queryArticlesBasic(
|
||||||
feedId: feedId?.toString(),
|
feedId: feedId?.toString(),
|
||||||
query: db.buildLikeQuery(searchString),
|
query: db.buildLikeQuery(searchString),
|
||||||
|
limit: limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
|
|||||||
(:feed_id IS NULL OR a.feed_id = :feed_id)
|
(:feed_id IS NULL OR a.feed_id = :feed_id)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
weighted_rank ASC,
|
weighted_rank ASC,
|
||||||
a.created DESC NULLS LAST;
|
a.created DESC NULLS LAST
|
||||||
|
LIMIT :limit;
|
||||||
|
|
||||||
queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
|
queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
|
||||||
WITH weights AS (
|
WITH weights AS (
|
||||||
@@ -132,4 +133,5 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
|
|||||||
:feed_id IS NULL OR a.feed_id = :feed_id
|
:feed_id IS NULL OR a.feed_id = :feed_id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
weighted_rank ASC,
|
weighted_rank ASC,
|
||||||
a.created DESC NULLS LAST;
|
a.created DESC NULLS LAST
|
||||||
|
LIMIT :limit;
|
||||||
|
|||||||
@@ -2644,10 +2644,15 @@ class DefinitionsDrift extends i10.ModularAccessor {
|
|||||||
i0.Selectable<i11.FeedArticleQueryResult> queryArticlesBasic({
|
i0.Selectable<i11.FeedArticleQueryResult> queryArticlesBasic({
|
||||||
required String query,
|
required String query,
|
||||||
String? feedId,
|
String? feedId,
|
||||||
|
required int limit,
|
||||||
}) {
|
}) {
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST',
|
'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST LIMIT ?3',
|
||||||
variables: [i0.Variable<String>(query), i0.Variable<String>(feedId)],
|
variables: [
|
||||||
|
i0.Variable<String>(query),
|
||||||
|
i0.Variable<String>(feedId),
|
||||||
|
i0.Variable<int>(limit),
|
||||||
|
],
|
||||||
readsFrom: {feed, articleFts, article},
|
readsFrom: {feed, articleFts, article},
|
||||||
).map(
|
).map(
|
||||||
(i0.QueryRow row) => i11.FeedArticleQueryResult(
|
(i0.QueryRow row) => i11.FeedArticleQueryResult(
|
||||||
@@ -2691,9 +2696,10 @@ class DefinitionsDrift extends i10.ModularAccessor {
|
|||||||
required int snippetLength,
|
required int snippetLength,
|
||||||
required String query,
|
required String query,
|
||||||
String? feedId,
|
String? feedId,
|
||||||
|
required int limit,
|
||||||
}) {
|
}) {
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST',
|
'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST LIMIT ?7',
|
||||||
variables: [
|
variables: [
|
||||||
i0.Variable<String>(beforeMatch),
|
i0.Variable<String>(beforeMatch),
|
||||||
i0.Variable<String>(afterMatch),
|
i0.Variable<String>(afterMatch),
|
||||||
@@ -2701,6 +2707,7 @@ class DefinitionsDrift extends i10.ModularAccessor {
|
|||||||
i0.Variable<int>(snippetLength),
|
i0.Variable<int>(snippetLength),
|
||||||
i0.Variable<String>(query),
|
i0.Variable<String>(query),
|
||||||
i0.Variable<String>(feedId),
|
i0.Variable<String>(feedId),
|
||||||
|
i0.Variable<int>(limit),
|
||||||
],
|
],
|
||||||
readsFrom: {feed, articleFts, article},
|
readsFrom: {feed, articleFts, article},
|
||||||
).map(
|
).map(
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ArticleSearch extends _$ArticleSearch {
|
|||||||
Future<void> search(
|
Future<void> search(
|
||||||
String input, {
|
String input, {
|
||||||
int snippetLength = 120,
|
int snippetLength = 120,
|
||||||
|
int maxResults = 25,
|
||||||
String matchPrefix = '***',
|
String matchPrefix = '***',
|
||||||
String matchSuffix = '***',
|
String matchSuffix = '***',
|
||||||
String ellipsis = '…',
|
String ellipsis = '…',
|
||||||
@@ -54,6 +55,7 @@ class ArticleSearch extends _$ArticleSearch {
|
|||||||
snippetLength: snippetLength,
|
snippetLength: snippetLength,
|
||||||
searchString: input,
|
searchString: input,
|
||||||
feedId: feedId,
|
feedId: feedId,
|
||||||
|
limit: maxResults,
|
||||||
)
|
)
|
||||||
.get()
|
.get()
|
||||||
.then((value) {
|
.then((value) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class ArticleSearchProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$articleSearchHash() => r'cc59a5d3c4b926db9f03ed96db592965ff925b04';
|
String _$articleSearchHash() => r'48ed3baa560c0e731626f79a8f6ff3dab1e9bc95';
|
||||||
|
|
||||||
final class ArticleSearchFamily extends $Family
|
final class ArticleSearchFamily extends $Family
|
||||||
with
|
with
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
* 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:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
|
||||||
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_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
@@ -26,6 +25,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:weblibre/core/providers/format.dart';
|
import 'package:weblibre/core/providers/format.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
|
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
|
||||||
@@ -206,7 +206,8 @@ class FeedArticleScreen extends HookConsumerWidget {
|
|||||||
.addTab(
|
.addTab(
|
||||||
url: articleLink.uri,
|
url: articleLink.uri,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
container: const Value(null),
|
containerSelection:
|
||||||
|
const TabContainerSelection.unassigned(),
|
||||||
selectTab: true,
|
selectTab: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user