feat: tab suggestions

This commit is contained in:
Fabian Freund
2025-07-24 23:47:34 +02:00
parent f0d17e78d7
commit 47151a2ba7
30 changed files with 1293 additions and 180 deletions
@@ -16,7 +16,7 @@ import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/domain/repositories/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
part 'tab_state.g.dart';
@@ -43,7 +43,7 @@ class TabStates extends _$TabStates {
if (newState.isFinishedLoading) {
ref
.read(containerTopicRepositoryProvider.notifier)
.read(geckoInferenceRepositoryProvider.notifier)
.markInitialLoadComplete();
}
}
@@ -193,7 +193,7 @@ final selectedTabContainerIdProvider = Provider<AsyncValue<String?>>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef SelectedTabContainerIdRef = ProviderRef<AsyncValue<String?>>;
String _$tabStatesHash() => r'ec7e0905d77b2f82491c5b5af0d2ce1351a564f4';
String _$tabStatesHash() => r'66cff6a7b36328ee23b89fdd53046987b346bfcd';
/// See also [TabStates].
@ProviderFor(TabStates)
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
@@ -11,6 +12,7 @@ import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
part 'providers.g.dart';
@@ -88,6 +90,37 @@ EquatableValue<Map<String, TabState>> availableTabStates(
});
}
@Riverpod()
EquatableValue<List<TabEntity>> suggestedTabEntities(
Ref ref,
String? containerId,
) {
final excludedTabIds = ref.watch(
containerTabIdsProvider(
// ignore: provider_parameters
ContainerFilterById(containerId: containerId),
).select((value) => EquatableValue(value.valueOrNull)),
);
final suggestions = ref.watch(
containerTabSuggestionsProvider(containerId).select(
(value) => EquatableValue(
value.valueOrNull.mapNotNull(
(result) => result
.whereNot(
(tabId) => excludedTabIds.value?.contains(tabId) ?? false,
)
.map((tabId) => DefaultTabEntity(tabId: tabId))
.toList(),
) ??
const [],
),
),
);
return suggestions;
}
@Riverpod()
EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
Ref ref, {
@@ -282,6 +282,134 @@ class _AvailableTabStatesProviderElement
(origin as AvailableTabStatesProvider).containerFilter;
}
String _$suggestedTabEntitiesHash() =>
r'f8c41ea3136ae0761e558323afb9d33bf5b8fd21';
/// See also [suggestedTabEntities].
@ProviderFor(suggestedTabEntities)
const suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily();
/// See also [suggestedTabEntities].
class SuggestedTabEntitiesFamily
extends Family<EquatableValue<List<TabEntity>>> {
/// See also [suggestedTabEntities].
const SuggestedTabEntitiesFamily();
/// See also [suggestedTabEntities].
SuggestedTabEntitiesProvider call(String? containerId) {
return SuggestedTabEntitiesProvider(containerId);
}
@override
SuggestedTabEntitiesProvider getProviderOverride(
covariant SuggestedTabEntitiesProvider provider,
) {
return call(provider.containerId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'suggestedTabEntitiesProvider';
}
/// See also [suggestedTabEntities].
class SuggestedTabEntitiesProvider
extends AutoDisposeProvider<EquatableValue<List<TabEntity>>> {
/// See also [suggestedTabEntities].
SuggestedTabEntitiesProvider(String? containerId)
: this._internal(
(ref) =>
suggestedTabEntities(ref as SuggestedTabEntitiesRef, containerId),
from: suggestedTabEntitiesProvider,
name: r'suggestedTabEntitiesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$suggestedTabEntitiesHash,
dependencies: SuggestedTabEntitiesFamily._dependencies,
allTransitiveDependencies:
SuggestedTabEntitiesFamily._allTransitiveDependencies,
containerId: containerId,
);
SuggestedTabEntitiesProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.containerId,
}) : super.internal();
final String? containerId;
@override
Override overrideWith(
EquatableValue<List<TabEntity>> Function(SuggestedTabEntitiesRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: SuggestedTabEntitiesProvider._internal(
(ref) => create(ref as SuggestedTabEntitiesRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
containerId: containerId,
),
);
}
@override
AutoDisposeProviderElement<EquatableValue<List<TabEntity>>> createElement() {
return _SuggestedTabEntitiesProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SuggestedTabEntitiesProvider &&
other.containerId == containerId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, containerId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SuggestedTabEntitiesRef
on AutoDisposeProviderRef<EquatableValue<List<TabEntity>>> {
/// The parameter `containerId` of this provider.
String? get containerId;
}
class _SuggestedTabEntitiesProviderElement
extends AutoDisposeProviderElement<EquatableValue<List<TabEntity>>>
with SuggestedTabEntitiesRef {
_SuggestedTabEntitiesProviderElement(super.provider);
@override
String? get containerId =>
(origin as SuggestedTabEntitiesProvider).containerId;
}
String _$seamlessFilteredTabEntitiesHash() =>
r'2cd698d4e7c9d9cda9e17abbf9a99e512425e4cd';
@@ -0,0 +1,21 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'tab_suggestions.g.dart';
@Riverpod(keepAlive: true)
class TabSuggestionsController extends _$TabSuggestionsController {
void toggle() {
state = !state;
}
void hide() {
if (state) {
state = false;
}
}
@override
bool build() {
return false;
}
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab_suggestions.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$tabSuggestionsControllerHash() =>
r'5ce7f385b8aba14d432912cf0acb06aad04433fc';
/// See also [TabSuggestionsController].
@ProviderFor(TabSuggestionsController)
final tabSuggestionsControllerProvider =
NotifierProvider<TabSuggestionsController, bool>.internal(
TabSuggestionsController.new,
name: r'tabSuggestionsControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$tabSuggestionsControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$TabSuggestionsController = Notifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -13,6 +13,7 @@ import 'package:weblibre/data/models/drag_data.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/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
@@ -29,9 +30,14 @@ import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String? suggestedContainerId;
final VoidCallback onClose;
const _TabDraggable({required this.entity, required this.onClose});
const _TabDraggable({
required this.entity,
required this.onClose,
this.suggestedContainerId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -49,16 +55,22 @@ class _TabDraggable extends HookConsumerWidget {
}),
);
final tab = SingleTabPreview(
tabId: entity.tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(),
},
);
final tab = (suggestedContainerId != null)
? SuggestedSingleTabPreview(
tabId: entity.tabId,
activeTabId: activeTab,
containerId: suggestedContainerId!,
)
: SingleTabPreview(
tabId: entity.tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(),
},
);
return switch (dragData) {
ContainerDropData() => Opacity(
@@ -132,6 +144,10 @@ class _TabSheetHeader extends HookConsumerWidget {
searchTextFocus.requestFocus();
},
),
const SizedBox(
height: 32,
child: VerticalDivider(indent: 4, endIndent: 4),
),
IconButton(
icon: const Icon(MdiIcons.familyTree),
selectedIcon: const Icon(MdiIcons.table),
@@ -144,6 +160,27 @@ class _TabSheetHeader extends HookConsumerWidget {
.toggle();
},
),
Consumer(
builder: (context, ref, child) {
final tabSuggestionsEnabled = ref.watch(
tabSuggestionsControllerProvider,
);
return IconButton.filledTonal(
icon: const Icon(MdiIcons.imageAutoAdjust),
isSelected: tabSuggestionsEnabled,
iconSize: 18,
padding: EdgeInsets.zero,
onPressed: () {
ref
.read(
tabSuggestionsControllerProvider.notifier,
)
.toggle();
},
);
},
),
],
),
TextButton.icon(
@@ -298,7 +335,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
builder: (context, ref, child) {
final gridViewKey = useMemoized(() => GlobalKey());
final container = ref.watch(selectedContainerProvider);
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
@@ -306,35 +343,36 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(
containerId: container,
containerId: containerId,
),
groupTrees: false,
),
);
final tabSuggestionsEnabled = ref.watch(
tabSuggestionsControllerProvider,
);
final suggestedTabEntities = ref.watch(
suggestedTabEntitiesProvider(
tabSuggestionsEnabled ? containerId : null,
),
);
final itemCount =
filteredTabEntities.value.length +
suggestedTabEntities.value.length;
final activeTab = ref.watch(selectedTabProvider);
final crossAxisCount = useMemoized(
() {
final calculatedCount = _calculateCrossAxisItemCount(
screenWidth: MediaQuery.of(context).size.width,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
final crossAxisCount = useMemoized(() {
final calculatedCount = _calculateCrossAxisItemCount(
screenWidth: MediaQuery.of(context).size.width,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(
math.min(
calculatedCount,
filteredTabEntities.value.length,
),
2,
);
},
[
MediaQuery.of(context).size.width,
filteredTabEntities.value.length,
],
);
return math.max(math.min(calculatedCount, itemCount), 2);
}, [MediaQuery.of(context).size.width, itemCount]);
final itemHeight = useMemoized(
() => _calculateItemHeight(
@@ -390,20 +428,29 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
);
final tabs = useMemoized(() {
return filteredTabEntities.value
.where((entity) => entity is! TabTreeEntity)
.map(
(entity) => CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(
entity: entity,
onClose: onClose,
),
return [
...filteredTabEntities.value.map(
(entity) => CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(
entity: entity,
onClose: onClose,
),
)
.toList();
}, [filteredTabEntities]);
),
),
...suggestedTabEntities.value.map(
(entity) => CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
onClose: onClose,
suggestedContainerId: containerId,
),
),
),
];
}, [filteredTabEntities, suggestedTabEntities]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
@@ -415,7 +462,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
//Rebuild when cross axis count changes
key: ValueKey(crossAxisCount),
scrollController: controller,
itemCount: tabs.length,
itemCount: itemCount,
onDragStarted: (index) {
ref.read(willAcceptDropProvider.notifier).clear();
},
@@ -432,6 +479,11 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
containerRepositoryProvider.notifier,
);
//Suggestions are at the end and not reorderable, so skip
if (oldIndex >= filteredTabEntities.value.length) {
return;
}
final tabId =
filteredTabEntities.value[oldIndex].tabId;
final containerId = await ref
@@ -482,7 +534,7 @@ class ViewTabsSheetWidget extends HookConsumerWidget {
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: tabs.length,
itemCount: itemCount,
itemBuilder: (context, index) =>
itemBuilder(tabs[index], index),
);
@@ -5,6 +5,7 @@ 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:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
@@ -14,15 +15,21 @@ import 'package:weblibre/features/geckoview/features/find_in_page/domain/entitie
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.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/tab.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
class _TabBox extends StatelessWidget {
class TabContainer extends StatelessWidget {
final bool isActive;
final bool isPrivate;
final Widget? child;
const _TabBox({required this.isActive, required this.isPrivate, this.child});
const TabContainer({
required this.isActive,
required this.isPrivate,
this.child,
super.key,
});
@override
Widget build(BuildContext context) {
@@ -47,6 +54,37 @@ class _TabBox extends StatelessWidget {
}
}
class TabMiniPreview extends HookConsumerWidget {
final String tabId;
const TabMiniPreview({super.key, required this.tabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final thumbnail = ref.watch(
tabStateProvider(tabId).select((value) => value?.thumbnail),
);
return TabContainer(
isActive: false,
isPrivate: false,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(14.0)),
child: Skeleton.replace(
replace: thumbnail == null,
replacement: const Bone.square(size: double.infinity),
child: SizedBox(
width: double.infinity,
child: RepaintBoundary(
child: RawImage(image: thumbnail?.value, fit: BoxFit.fitWidth),
),
),
),
),
);
}
}
class TabPreview extends HookWidget {
final TabState tab;
final bool isActive;
@@ -57,6 +95,8 @@ class TabPreview extends HookWidget {
final VoidCallback? onDelete;
final void Function(String host)? onDeleteAll;
final Widget? trailingChild;
const TabPreview({
required this.tab,
required this.isActive,
@@ -65,13 +105,14 @@ class TabPreview extends HookWidget {
this.onLongPress,
this.onDelete,
this.onDeleteAll,
this.trailingChild,
super.key,
});
@override
Widget build(BuildContext context) {
final extendedDeleteMenuController = useMenuController();
return _TabBox(
return TabContainer(
isActive: isActive,
isPrivate: tab.isPrivate,
child: InkWell(
@@ -130,6 +171,7 @@ class TabPreview extends HookWidget {
icon: const Icon(Icons.close),
),
),
?trailingChild,
],
),
Row(
@@ -259,6 +301,51 @@ class SingleTabPreview extends HookConsumerWidget {
}
}
class SuggestedSingleTabPreview extends HookConsumerWidget {
final String tabId;
final String containerId;
final String? activeTabId;
SuggestedSingleTabPreview({
required this.tabId,
required this.containerId,
required this.activeTabId,
}) : super(key: ValueKey(tabId));
@override
Widget build(BuildContext context, WidgetRef ref) {
final tab = ref.watch(tabStateProvider(tabId));
if (tab == null) {
return const SizedBox.shrink();
}
return Opacity(
opacity: 0.5,
child: TabPreview(
tab: tab,
isActive: tabId == activeTabId,
onTap: () async {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(containerId);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(tabId, containerData);
}
},
trailingChild: const IconButton(
icon: Icon(MdiIcons.creation),
onPressed: null,
),
),
);
}
}
class TabTreePreview extends HookConsumerWidget {
final TabTreeEntity entity;
final String? activeTabId;
@@ -303,7 +390,7 @@ class TabTreePreview extends HookConsumerWidget {
_addPadding(
index,
stackCount,
_TabBox(
TabContainer(
isActive: entity.tabId == activeTabId,
isPrivate: tab.isPrivate,
),
@@ -73,11 +73,17 @@ Stream<Map<String, String?>> tabDescendants(Ref ref, String tabId) {
}
@Riverpod()
Stream<List<TabData>> containerTabsData(Ref ref, String containerId) {
Stream<List<TabData>> containerTabsData(Ref ref, String? containerId) {
final db = ref.watch(tabDatabaseProvider);
return db.containerDao.getContainerTabsData(containerId).watch();
}
@Riverpod()
Stream<ContainerData?> containerData(Ref ref, String containerId) {
final db = ref.watch(tabDatabaseProvider);
return db.containerDao.getContainerData(containerId).watchSingleOrNull();
}
@Riverpod()
Stream<String?> watchContainerTabId(Ref ref, String tabId) {
return ref
@@ -565,7 +565,7 @@ class _TabDescendantsProviderElement
String get tabId => (origin as TabDescendantsProvider).tabId;
}
String _$containerTabsDataHash() => r'1987b2d69f2ba663a7343e93572aad3c31a29be3';
String _$containerTabsDataHash() => r'2961c89fc9e16c8e6005342356ecf677fb2ff63c';
/// See also [containerTabsData].
@ProviderFor(containerTabsData)
@@ -577,7 +577,7 @@ class ContainerTabsDataFamily extends Family<AsyncValue<List<TabData>>> {
const ContainerTabsDataFamily();
/// See also [containerTabsData].
ContainerTabsDataProvider call(String containerId) {
ContainerTabsDataProvider call(String? containerId) {
return ContainerTabsDataProvider(containerId);
}
@@ -607,7 +607,7 @@ class ContainerTabsDataFamily extends Family<AsyncValue<List<TabData>>> {
class ContainerTabsDataProvider
extends AutoDisposeStreamProvider<List<TabData>> {
/// See also [containerTabsData].
ContainerTabsDataProvider(String containerId)
ContainerTabsDataProvider(String? containerId)
: this._internal(
(ref) => containerTabsData(ref as ContainerTabsDataRef, containerId),
from: containerTabsDataProvider,
@@ -631,7 +631,7 @@ class ContainerTabsDataProvider
required this.containerId,
}) : super.internal();
final String containerId;
final String? containerId;
@override
Override overrideWith(
@@ -675,7 +675,7 @@ class ContainerTabsDataProvider
// ignore: unused_element
mixin ContainerTabsDataRef on AutoDisposeStreamProviderRef<List<TabData>> {
/// The parameter `containerId` of this provider.
String get containerId;
String? get containerId;
}
class _ContainerTabsDataProviderElement
@@ -684,7 +684,127 @@ class _ContainerTabsDataProviderElement
_ContainerTabsDataProviderElement(super.provider);
@override
String get containerId => (origin as ContainerTabsDataProvider).containerId;
String? get containerId => (origin as ContainerTabsDataProvider).containerId;
}
String _$containerDataHash() => r'6adbec128876069189954832af35109b0779148c';
/// See also [containerData].
@ProviderFor(containerData)
const containerDataProvider = ContainerDataFamily();
/// See also [containerData].
class ContainerDataFamily extends Family<AsyncValue<ContainerData?>> {
/// See also [containerData].
const ContainerDataFamily();
/// See also [containerData].
ContainerDataProvider call(String containerId) {
return ContainerDataProvider(containerId);
}
@override
ContainerDataProvider getProviderOverride(
covariant ContainerDataProvider provider,
) {
return call(provider.containerId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'containerDataProvider';
}
/// See also [containerData].
class ContainerDataProvider extends AutoDisposeStreamProvider<ContainerData?> {
/// See also [containerData].
ContainerDataProvider(String containerId)
: this._internal(
(ref) => containerData(ref as ContainerDataRef, containerId),
from: containerDataProvider,
name: r'containerDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerDataHash,
dependencies: ContainerDataFamily._dependencies,
allTransitiveDependencies:
ContainerDataFamily._allTransitiveDependencies,
containerId: containerId,
);
ContainerDataProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.containerId,
}) : super.internal();
final String containerId;
@override
Override overrideWith(
Stream<ContainerData?> Function(ContainerDataRef provider) create,
) {
return ProviderOverride(
origin: this,
override: ContainerDataProvider._internal(
(ref) => create(ref as ContainerDataRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
containerId: containerId,
),
);
}
@override
AutoDisposeStreamProviderElement<ContainerData?> createElement() {
return _ContainerDataProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ContainerDataProvider && other.containerId == containerId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, containerId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ContainerDataRef on AutoDisposeStreamProviderRef<ContainerData?> {
/// The parameter `containerId` of this provider.
String get containerId;
}
class _ContainerDataProviderElement
extends AutoDisposeStreamProviderElement<ContainerData?>
with ContainerDataRef {
_ContainerDataProviderElement(super.provider);
@override
String get containerId => (origin as ContainerDataProvider).containerId;
}
String _$watchContainerTabIdHash() =>
@@ -1,80 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/utils/lru_cache.dart';
part 'container_topic.g.dart';
@Riverpod(keepAlive: true)
class ContainerTopicRepository extends _$ContainerTopicRepository {
final _service = GeckoMlService();
//Wait for first complete page laod of any website after startup to ensure everything is ready
final _initialLoadComplete = Completer();
final _lock = Lock();
final _cache = LRUCache<Set<String>, String>(
50,
equals: (a, b) {
return const DeepCollectionEquality.unordered().equals(a, b);
},
hashCode: (key) {
return const DeepCollectionEquality.unordered().hash(key);
},
);
void markInitialLoadComplete() {
if (!_initialLoadComplete.isCompleted) {
_initialLoadComplete.complete();
}
}
Future<String?> getContainerTopic(Set<String> titles) async {
if (titles.isNotEmpty) {
if (_cache.get(titles) case final String title) {
return title;
}
try {
final title = await _lock.synchronized(() async {
await _initialLoadComplete.future;
final title = await _service.getContainerTopic(titles);
return _cache.set(titles, title);
}, timeout: const Duration(seconds: 120));
return title;
} on TimeoutException {
return null;
}
}
return null;
}
@override
void build() {}
}
@Riverpod(keepAlive: true)
Future<String?> containerTopic(Ref ref, String containerId) async {
final titles = await ref.watch(
containerTabsDataProvider(containerId).selectAsync(
(tabData) =>
EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()),
),
);
final topic = await ref
.read(containerTopicRepositoryProvider.notifier)
.getContainerTopic(titles.value);
return topic;
}
@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:synchronized/synchronized.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/embedding_text_processing.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/nearest_neighbor.dart';
import 'package:weblibre/utils/lru_cache.dart';
part 'gecko_inference.g.dart';
@Riverpod(keepAlive: true)
class GeckoInferenceRepository extends _$GeckoInferenceRepository {
final _service = GeckoMlService();
//Wait for first complete page laod of any website after startup to ensure everything is ready
final _initialLoadComplete = Completer();
final _engineLock = Lock();
final _topicCache = LRUCache<Set<String>, String>(
50,
equals: (a, b) {
return const DeepCollectionEquality.unordered().equals(a, b);
},
hashCode: (key) {
return const DeepCollectionEquality.unordered().hash(key);
},
);
final _embeddingCache = LRUCache<String, List<double>>(100);
void markInitialLoadComplete() {
if (!_initialLoadComplete.isCompleted) {
_initialLoadComplete.complete();
}
}
Future<String?> predictDocumentTopic(Set<String> titles) async {
if (titles.isNotEmpty) {
if (_topicCache.get(titles) case final String title) {
return title;
}
try {
final title = await _engineLock.synchronized(() async {
await _initialLoadComplete.future;
final title = await _service.predictDocumentTopic(titles);
return _topicCache.set(titles, title);
}, timeout: const Duration(seconds: 120));
return title;
} on TimeoutException {
return null;
}
}
return null;
}
Future<List<String>?> suggestDocuments({
required String topic,
required List<String> assignedDocumentsInput,
required List<String> unassignedDocumentsInput,
}) async {
final processedDocuments = <String, String>{};
final unassignedDocumentsProcessed = unassignedDocumentsInput.map((doc) {
final processed = preprocessText(doc);
if (processed != doc) {
processedDocuments[processed] = doc;
}
return processed;
}).toList();
final assignedDocumentsProcessed = assignedDocumentsInput
.map((doc) => '$topic. ${preprocessText(doc)}')
.toList();
final embeddings = await generateDocumentEmbeddings([
...unassignedDocumentsProcessed,
...assignedDocumentsProcessed,
]);
final neighbors = embeddings.mapNotNull(
(embeddings) => findNearestNeighborsRecursive(
embeddings: embeddings,
assignedDocuments: assignedDocumentsProcessed,
unassignedDocuments: unassignedDocumentsProcessed,
).map((neighbor) => processedDocuments[neighbor] ?? neighbor).toList(),
);
return neighbors;
}
Future<Map<String, List<double>>?> generateDocumentEmbeddings(
List<String> documents,
) async {
try {
final embeddings = Map.fromEntries(
documents.map((doc) => MapEntry(doc, _embeddingCache.get(doc))),
);
final embeddingsToGenerate = embeddings.entries
.where((e) => e.value == null)
.map((e) => e.key)
.toList();
if (embeddingsToGenerate.isNotEmpty) {
final generatedEmbeddings = await _engineLock.synchronized(() async {
await _initialLoadComplete.future;
final embeddings = await _service.generateDocumentEmbeddings(
documents,
);
return embeddings;
}, timeout: const Duration(seconds: 120));
for (var i = 0; i < embeddingsToGenerate.length; i++) {
embeddings[embeddingsToGenerate[i]] = generatedEmbeddings[i];
}
}
return {
for (final MapEntry(:key, :value) in embeddings.entries)
if (value != null) key: value,
};
} on TimeoutException {
return null;
}
}
@override
void build() {}
}
@Riverpod(keepAlive: true)
Future<String?> containerTopic(Ref ref, String containerId) async {
final titles = await ref.watch(
containerTabsDataProvider(containerId).selectAsync(
(tabData) =>
EquatableValue(tabData.map((tab) => tab.title).nonNulls.toSet()),
),
);
final topic = await ref
.read(geckoInferenceRepositoryProvider.notifier)
.predictDocumentTopic(titles.value);
return topic;
}
@Riverpod()
Future<List<String>?> containerTabSuggestions(
Ref ref,
String? containerId,
) async {
if (containerId == null) {
return null;
}
final container = await ref.watch(containerDataProvider(containerId).future);
final assignedTitles = await ref.watch(
containerTabsDataProvider(containerId).selectAsync(
(tabData) => EquatableValue(
tabData
.where((tab) => tab.title.isNotEmpty)
.map((tab) => (tab.id, tab.title!))
.toSet(),
),
),
);
final unassignedTitles = await ref.watch(
containerTabsDataProvider(null).selectAsync(
(tabData) => EquatableValue(
tabData
.where((tab) => tab.title.isNotEmpty)
.map((tab) => (tab.id, tab.title!))
.toSet(),
),
),
);
if (assignedTitles.value.isNotEmpty && unassignedTitles.value.isNotEmpty) {
final topic =
container?.name ??
await ref
.read(geckoInferenceRepositoryProvider.notifier)
.predictDocumentTopic(
assignedTitles.value.map((tab) => tab.$2).toSet(),
);
if (topic != null) {
final suggestedTitles = await ref
.read(geckoInferenceRepositoryProvider.notifier)
.suggestDocuments(
topic: topic,
assignedDocumentsInput: assignedTitles.value
.map((tab) => tab.$2)
.toList(),
unassignedDocumentsInput: unassignedTitles.value
.map((tab) => tab.$2)
.toList(),
);
return suggestedTitles.mapNotNull(
(titles) => titles
.map(
(title) => unassignedTitles.value
.firstWhere((tab) => tab.$2 == title)
.$1,
)
.toList(),
);
}
}
return null;
}
@@ -1,12 +1,12 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'container_topic.dart';
part of 'gecko_inference.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$containerTopicHash() => r'aa3fd27f26bb94b6c9e794d8470537240b8d2713';
String _$containerTopicHash() => r'b680381eab3dded9ca6f174fbcc5da5c59d7e9f0';
/// Copied from Dart SDK
class _SystemHash {
@@ -146,22 +146,151 @@ class _ContainerTopicProviderElement extends FutureProviderElement<String?>
String get containerId => (origin as ContainerTopicProvider).containerId;
}
String _$containerTopicRepositoryHash() =>
r'8dfc0bc8b4953b5230a1faa24c8b2f0a94ab1f04';
String _$containerTabSuggestionsHash() =>
r'ab8e596f0c7e8562bf998c701145e48eb1e520d5';
/// See also [ContainerTopicRepository].
@ProviderFor(ContainerTopicRepository)
final containerTopicRepositoryProvider =
NotifierProvider<ContainerTopicRepository, void>.internal(
ContainerTopicRepository.new,
name: r'containerTopicRepositoryProvider',
/// See also [containerTabSuggestions].
@ProviderFor(containerTabSuggestions)
const containerTabSuggestionsProvider = ContainerTabSuggestionsFamily();
/// See also [containerTabSuggestions].
class ContainerTabSuggestionsFamily extends Family<AsyncValue<List<String>?>> {
/// See also [containerTabSuggestions].
const ContainerTabSuggestionsFamily();
/// See also [containerTabSuggestions].
ContainerTabSuggestionsProvider call(String? containerId) {
return ContainerTabSuggestionsProvider(containerId);
}
@override
ContainerTabSuggestionsProvider getProviderOverride(
covariant ContainerTabSuggestionsProvider provider,
) {
return call(provider.containerId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'containerTabSuggestionsProvider';
}
/// See also [containerTabSuggestions].
class ContainerTabSuggestionsProvider
extends AutoDisposeFutureProvider<List<String>?> {
/// See also [containerTabSuggestions].
ContainerTabSuggestionsProvider(String? containerId)
: this._internal(
(ref) => containerTabSuggestions(
ref as ContainerTabSuggestionsRef,
containerId,
),
from: containerTabSuggestionsProvider,
name: r'containerTabSuggestionsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTabSuggestionsHash,
dependencies: ContainerTabSuggestionsFamily._dependencies,
allTransitiveDependencies:
ContainerTabSuggestionsFamily._allTransitiveDependencies,
containerId: containerId,
);
ContainerTabSuggestionsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.containerId,
}) : super.internal();
final String? containerId;
@override
Override overrideWith(
FutureOr<List<String>?> Function(ContainerTabSuggestionsRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: ContainerTabSuggestionsProvider._internal(
(ref) => create(ref as ContainerTabSuggestionsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
containerId: containerId,
),
);
}
@override
AutoDisposeFutureProviderElement<List<String>?> createElement() {
return _ContainerTabSuggestionsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ContainerTabSuggestionsProvider &&
other.containerId == containerId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, containerId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ContainerTabSuggestionsRef
on AutoDisposeFutureProviderRef<List<String>?> {
/// The parameter `containerId` of this provider.
String? get containerId;
}
class _ContainerTabSuggestionsProviderElement
extends AutoDisposeFutureProviderElement<List<String>?>
with ContainerTabSuggestionsRef {
_ContainerTabSuggestionsProviderElement(super.provider);
@override
String? get containerId =>
(origin as ContainerTabSuggestionsProvider).containerId;
}
String _$geckoInferenceRepositoryHash() =>
r'bdae6dab14c33c6503f37f2e17b8d50fb820ac26';
/// See also [GeckoInferenceRepository].
@ProviderFor(GeckoInferenceRepository)
final geckoInferenceRepositoryProvider =
NotifierProvider<GeckoInferenceRepository, void>.internal(
GeckoInferenceRepository.new,
name: r'geckoInferenceRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$containerTopicRepositoryHash,
: _$geckoInferenceRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ContainerTopicRepository = Notifier<void>;
typedef _$GeckoInferenceRepository = Notifier<void>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
@@ -1,11 +1,11 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
part 'container_topic.g.dart';
@Riverpod()
class ContainerTopicController extends _$ContainerTopicController {
Future<String?> getContainerTopic(String containerId) async {
Future<String?> predictDocumentTopic(String containerId) async {
state = const AsyncLoading();
final result = await AsyncValue.guard(() async {
@@ -7,7 +7,7 @@ part of 'container_topic.dart';
// **************************************************************************
String _$containerTopicControllerHash() =>
r'229709202edecb21b20432f8e4d0d7aee293f623';
r'fa38ebd3c1352b471e58444b04d1cb0528aae1dd';
/// See also [ContainerTopicController].
@ProviderFor(ContainerTopicController)
@@ -164,7 +164,7 @@ class ContainerEditScreen extends HookConsumerWidget {
containerTopicControllerProvider
.notifier,
)
.getContainerTopic(
.predictDocumentTopic(
initialContainer.id,
);
@@ -7,7 +7,7 @@ import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container_topic.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
class ContainerTitle extends HookConsumerWidget {
final ContainerData container;
@@ -0,0 +1,31 @@
import 'dart:math';
/// Calculates cosine similarity between two lists of floats
/// The lists don't need to be normalized
///
/// [a] first list
/// [b] second list
/// Returns cosine similarity value
double cosSim(List<double> a, List<double> b) {
if (a.length != b.length) {
throw ArgumentError("Lists should have same lengths");
}
if (a.isEmpty) {
return 0;
}
double dotProduct = 0;
double mA = 0;
double mB = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
mA += a[i] * a[i];
mB += b[i] * b[i];
}
mA = sqrt(mA);
mB = sqrt(mB);
return mA == 0 || mB == 0 ? 0 : dotProduct / (mA * mB);
}
@@ -0,0 +1,34 @@
/// Removes trailing domain-related text such as '... - Mail' or '... | News'
/// If there's not enough information remaining after, we keep the text as is
/// [text] tab title with potential domain information
/// Returns the processed string
String preprocessText(String text) {
// Matches 'xyz - Domain' or 'xyz | Domain'
// with a space before and after delimiter
// or if there are multiple delimiters next to each other
final delimiters = RegExp(r'(?<=\s)[|-]+(?=\s)');
final splitText = text.split(delimiters);
// ensure there's enough info without the last element
final hasEnoughInfo =
splitText.isNotEmpty &&
splitText.sublist(0, splitText.length - 1).join(' ').length > 5;
// domain related texts are usually shorter, this takes care of the most common cases
final isPotentialDomainInfo =
splitText.length > 1 && splitText.last.length < 20;
// If both conditions are met, remove the last chunk, filter out empty strings,
// join on space, trim, and lowercase
if (hasEnoughInfo && isPotentialDomainInfo) {
return splitText
.sublist(0, splitText.length - 1) // everything except the last element
.map((t) => t.trim())
.where((t) => t.isNotEmpty) // remove empty strings
.join(' ') // join with spaces
.trim(); // remove leading/trailing spaces
}
// Otherwise, just return the text
return text;
}
@@ -0,0 +1,54 @@
import 'package:collection/collection.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/cosine_similarity.dart';
List<String> findNearestNeighborsRecursive({
required Map<String, List<double>> embeddings,
required List<String> assignedDocuments,
required List<String> unassignedDocuments,
int thresholdMills = 275,
int maxAssignedCount = 4,
int depth = 0,
}) {
final closestTabs = <(String, double)>[];
final similarTabsIndices = <String>[];
for (final unassigned in unassignedDocuments) {
double? closestScore;
for (final assigned in assignedDocuments.take(maxAssignedCount)) {
final cosineSim = cosSim(embeddings[unassigned]!, embeddings[assigned]!);
if (closestScore == null || cosineSim > closestScore) {
closestScore = cosineSim;
}
}
// threshold could also be set via a nimbus experiment, in which case
// it will be an int <= 1000
if (closestScore != null && closestScore > thresholdMills / 1000) {
closestTabs.add((unassigned, closestScore));
similarTabsIndices.add(unassigned);
}
}
closestTabs.sort((a, b) => b.$2.compareTo(a.$2));
final result = closestTabs.map((t) => t.$1).toList();
// recurse once if the initial call only had a single tab
// and we found at least 1 similar tab - this improves recall
if (assignedDocuments.length == 1 && closestTabs.isNotEmpty && depth == 1) {
final recurseSimilarTabs = findNearestNeighborsRecursive(
unassignedDocuments: unassignedDocuments
.whereNot(similarTabsIndices.contains)
.toList(),
assignedDocuments: similarTabsIndices,
thresholdMills: thresholdMills,
embeddings: embeddings,
depth: depth - 1,
);
result.addAll(recurseSimilarTabs);
}
return result;
}
+2
View File
@@ -96,6 +96,8 @@ dev_dependencies:
drift_dev: ^2.28.0
fast_equatable_lint: ^0.3.2
flutter_launcher_icons: ^0.14.4
flutter_test:
sdk: flutter
go_router_builder: ^3.0.1
json_serializable: ^6.9.5
lint: ^2.8.0
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/embedding_text_processing.dart';
void main() {
group('Text processing basic cases', () {
test('trailing domain-like text should be removed', () {
expect(
preprocessText("Some Title - Random Mail"),
equals("Some Title"),
reason: "Should remove '- Random Mail' suffix",
);
});
test('trailing domain-like text with |', () {
expect(
preprocessText("Another Title | Some Video Website"),
equals("Another Title"),
reason: "Should remove '| Some Video Website' suffix",
);
});
test('no delimiter', () {
expect(
preprocessText("Simple Title"),
equals("Simple Title"),
reason: "Should remain unchanged since there's no recognized delimiter",
);
});
test('not enough info in first part', () {
expect(
preprocessText("AB - Mail"),
equals("AB - Mail"),
reason:
"Should not remove '- Mail' because the first part is too short",
);
});
test('should not match for texts such as check-in', () {
expect(
preprocessText("Check-in for flight"),
equals("Check-in for flight"),
reason: "Should not remove '-in'",
);
});
});
group('Text processing edge cases', () {
test('empty string', () {
expect(
preprocessText(""),
equals(""),
reason: "Empty string returns empty string",
);
});
test('exactly 20 chars', () {
const domain20Chars = "12345678901234567890"; // 20 characters
expect(
preprocessText("My Title - $domain20Chars"),
equals("My Title - $domain20Chars"),
reason:
"Should not remove suffix because it's exactly 20 chars long, not < 20",
);
});
test('multiple delimiters, remove last only', () {
expect(
preprocessText("Complex - Title - SomethingSmall"),
equals("Complex Title"),
reason:
"Should remove only the last '- SomethingSmall', ignoring earlier delimiters",
);
});
test('repeated delimiters', () {
expect(
preprocessText("Title --- Domain"),
equals("Title"),
reason: "Should remove the last chunk and filter out empty strings",
);
expect(
preprocessText("Title || Domain"),
equals("Title"),
reason: "Should remove the last chunk with double pipe delimiters too",
);
});
test('long trailing text', () {
const longDomain = "Useful information is present";
expect(
preprocessText("Some Title - $longDomain"),
equals("Some Title - $longDomain"),
reason: "Should not remove suffix if it's >= 20 characters",
);
});
});
}