added list view; persist tab view ui settings;

This commit is contained in:
Fabian Freund
2025-12-27 21:09:45 +01:00
parent 27e8dfd38e
commit e7ef8e060e
35 changed files with 2194 additions and 1227 deletions
@@ -57,7 +57,7 @@ final class TabSessionProvider extends $NotifierProvider<TabSession, void> {
}
}
String _$tabSessionHash() => r'f0a3aca70719afd37cad260c5d36621455252bfb';
String _$tabSessionHash() => r'4b7206863e205dfeee6201ba6072e742c43ab6f3';
final class TabSessionFamily extends $Family
with $ClassFamilyOverride<TabSession, void, void, void, String?> {
@@ -255,7 +255,7 @@ Future<bool> isTabTunneled(Ref ref, String? tabId) async {
case TorRegularTabProxyMode.container:
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabState.id);
.getTabContainerId(tabState.id);
final containerData = await containerId.mapNotNull(
(containerId) => ref
@@ -188,7 +188,7 @@ final class IsTabTunneledProvider
}
}
String _$isTabTunneledHash() => r'fd8ca366b0835eef8aea7ad64453e6a6f2e69e82';
String _$isTabTunneledHash() => r'2e1a911c26345951cf259ca4547d236a71607667';
final class IsTabTunneledFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
@@ -188,7 +188,7 @@ class TabRepository extends _$TabRepository {
Future<bool> selectTab(String tabId) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabId);
.getTabContainerId(tabId);
if (!ref.mounted) return false;
@@ -234,7 +234,7 @@ class TabRepository extends _$TabRepository {
final currentContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabId);
.getTabContainerId(tabId);
if (!ref.mounted) return;
@@ -419,7 +419,7 @@ class TabRepository extends _$TabRepository {
} else {
final tabContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabState.id);
.getTabContainerId(tabState.id);
if (targetContainerId != tabContainerId) {
if (originUri == null) {
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
}
}
String _$tabRepositoryHash() => r'ef1116cbf09f456deae7a7d3f5cee3ba843d7430';
String _$tabRepositoryHash() => r'7af24e759044d1b1b471cdf90a3eb38eef06f61b';
abstract class _$TabRepository extends $Notifier<void> {
void build();
@@ -83,7 +83,7 @@ class SelectedBangData extends _$SelectedBangData {
}
@Riverpod()
EquatableValue<List<String>> availableTabIds(
EquatableValue<List<DefaultTabEntity>> availableTabIds(
Ref ref,
ContainerFilter containerFilter,
) {
@@ -94,10 +94,37 @@ EquatableValue<List<String>> availableTabIds(
);
final tabList = ref.watch(tabListProvider);
return EquatableValue(
containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ??
[],
);
final availableTabs =
containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ??
[];
switch (containerFilter) {
case ContainerFilterById():
return EquatableValue(
availableTabs
.map(
(t) => DefaultTabEntity(
tabId: t,
containerId: containerFilter.containerId,
),
)
.toList(),
);
case ContainerFilterDisabled():
return ref.watch(
watchTabsContainerIdProvider(EquatableValue(availableTabs)).select(
(value) => EquatableValue(
value.value?.entries
.map(
(e) =>
DefaultTabEntity(tabId: e.key, containerId: e.value),
)
.toList() ??
[],
),
),
);
}
}
@Riverpod()
@@ -109,8 +136,9 @@ EquatableValue<Map<String, TabState>> availableTabStates(
final tabStates = ref.watch(tabStatesProvider);
return EquatableValue({
for (final tabId in availableTabs.value)
if (tabStates.containsKey(tabId)) tabId: tabStates[tabId]!,
for (final tabEntity in availableTabs.value)
if (tabStates.containsKey(tabEntity.tabId))
tabEntity.tabId: tabStates[tabEntity.tabId]!,
});
}
@@ -171,7 +199,12 @@ EquatableValue<List<TabEntity>> suggestedTabEntities(
.whereNot(
(tabId) => excludedTabIds.value?.contains(tabId) ?? false,
)
.map((tabId) => DefaultTabEntity(tabId: tabId))
.map(
(tabId) => DefaultTabEntity(
tabId: tabId,
containerId: containerId,
),
)
.toList(),
) ??
const [],
@@ -198,6 +231,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
.map(
(tab) => SearchResultTabEntity(
tabId: tab.id,
containerId: tab.containerId,
searchQuery: result.query,
),
)
@@ -208,13 +242,7 @@ EquatableValue<List<TabEntity>> seamlessFilteredTabEntities(
)
.value;
final availableTabs = ref.watch(
availableTabIdsProvider(containerFilter).select(
(value) => EquatableValue(
value.value.map((tab) => DefaultTabEntity(tabId: tab)).toList(),
),
),
);
final availableTabs = ref.watch(availableTabIdsProvider(containerFilter));
if (tabSearchResults == null) {
if (groupTrees) {
@@ -212,11 +212,11 @@ const availableTabIdsProvider = AvailableTabIdsFamily._();
final class AvailableTabIdsProvider
extends
$FunctionalProvider<
EquatableValue<List<String>>,
EquatableValue<List<String>>,
EquatableValue<List<String>>
EquatableValue<List<DefaultTabEntity>>,
EquatableValue<List<DefaultTabEntity>>,
EquatableValue<List<DefaultTabEntity>>
>
with $Provider<EquatableValue<List<String>>> {
with $Provider<EquatableValue<List<DefaultTabEntity>>> {
const AvailableTabIdsProvider._({
required AvailableTabIdsFamily super.from,
required ContainerFilter super.argument,
@@ -240,21 +240,22 @@ final class AvailableTabIdsProvider
@$internal
@override
$ProviderElement<EquatableValue<List<String>>> $createElement(
$ProviderElement<EquatableValue<List<DefaultTabEntity>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<String>> create(Ref ref) {
EquatableValue<List<DefaultTabEntity>> create(Ref ref) {
final argument = this.argument as ContainerFilter;
return availableTabIds(ref, argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<String>> value) {
Override overrideWithValue(EquatableValue<List<DefaultTabEntity>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<List<String>>>(value),
providerOverride:
$SyncValueProvider<EquatableValue<List<DefaultTabEntity>>>(value),
);
}
@@ -269,12 +270,12 @@ final class AvailableTabIdsProvider
}
}
String _$availableTabIdsHash() => r'aa166b471d45913df5166a3126abeb7c0d791412';
String _$availableTabIdsHash() => r'fa7280a7a5f9c277faa71a30f5dd50afa30a20b1';
final class AvailableTabIdsFamily extends $Family
with
$FunctionalFamilyOverride<
EquatableValue<List<String>>,
EquatableValue<List<DefaultTabEntity>>,
ContainerFilter
> {
const AvailableTabIdsFamily._()
@@ -358,7 +359,7 @@ final class AvailableTabStatesProvider
}
String _$availableTabStatesHash() =>
r'f506b050e1dc4f50fbd2699d97af5a16a037bc4b';
r'32f15b0f739495e11025bdc10d324764465b542d';
final class AvailableTabStatesFamily extends $Family
with
@@ -497,7 +498,7 @@ final class SuggestedTabEntitiesProvider
}
String _$suggestedTabEntitiesHash() =>
r'6fb58bf4b7377f826377c88110c8e4aca9f6d6f9';
r'1c3d9a4f85db60301fe5c5b55fb55512d1b59696';
final class SuggestedTabEntitiesFamily extends $Family
with $FunctionalFamilyOverride<EquatableValue<List<TabEntity>>, String?> {
@@ -601,7 +602,7 @@ final class SeamlessFilteredTabEntitiesProvider
}
String _$seamlessFilteredTabEntitiesHash() =>
r'eb6573514f55b598fbc1a91370079b4b8372c55e';
r'f2bf14737bc03f71fc61252359979b4e901e5df7';
final class SeamlessFilteredTabEntitiesFamily extends $Family
with
@@ -17,9 +17,14 @@
* 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: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 'tab_view_controllers.g.dart';
@@ -37,11 +42,19 @@ class TabSuggestionsController extends _$TabSuggestionsController {
@override
bool build() {
return false;
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'TabSuggestions',
encode: (state) => jsonEncode([state]),
decode: (encoded) => (jsonDecode(encoded) as List<dynamic>).first as bool,
);
return stateOrNull ?? false;
}
}
enum TabsViewMode {
list(MdiIcons.folderTable, 'List'),
grid(MdiIcons.table, 'Grid'),
tree(MdiIcons.familyTree, 'Tree');
@@ -61,7 +74,17 @@ class TabsViewModeController extends _$TabsViewModeController {
@override
TabsViewMode build() {
return TabsViewMode.grid;
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'TabsViewMode',
encode: (state) => jsonEncode([state.name]),
decode: (encoded) {
final name = (jsonDecode(encoded) as List<dynamic>).first as String;
return TabsViewMode.values.firstWhere((e) => e.name == name);
},
);
return stateOrNull ?? TabsViewMode.list;
}
}
@@ -42,7 +42,7 @@ final class TabSuggestionsControllerProvider
}
String _$tabSuggestionsControllerHash() =>
r'5ce7f385b8aba14d432912cf0acb06aad04433fc';
r'c60912e474177f2a0f15167743db439d157cd852';
abstract class _$TabSuggestionsController extends $Notifier<bool> {
bool build();
@@ -96,7 +96,7 @@ final class TabsViewModeControllerProvider
}
String _$tabsViewModeControllerHash() =>
r'9c574458f6d5a4c6a8b5aabf96ad7f5dcd043dd0';
r'e013b174218fcad81981f7d939cf161c3b002adc';
abstract class _$TabsViewModeController extends $Notifier<TabsViewMode> {
TabsViewMode build();
@@ -30,7 +30,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/ui_helper.dart';
@@ -123,7 +123,7 @@ class TabTreeDialog extends HookConsumerWidget {
final id = node.key!.value as String;
return SizedBox.fromSize(
size: childSize,
child: SingleTabPreview(
child: SingleGridTabPreview(
key: ValueKey(id),
tabId: id,
activeTabId: selectedTabId,
@@ -46,7 +46,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
@@ -780,7 +782,18 @@ class _ViewTabsSheet extends HookConsumerWidget {
topRight: Radius.circular(28),
),
child: switch (tabsViewMode) {
TabsViewMode.grid => ViewTabsWidget(
TabsViewMode.list => ViewTabListWidget(
scrollController: scrollController,
showNewTabFab: true,
tabsReorderable: tabsReorderable,
draggableScrollableController: draggableScrollableController,
onClose: () {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
},
),
TabsViewMode.grid => ViewTabGridWidget(
scrollController: scrollController,
showNewTabFab: true,
tabsReorderable: tabsReorderable,
@@ -23,7 +23,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class TabViewScreen extends HookConsumerWidget {
@@ -40,7 +42,16 @@ class TabViewScreen extends HookConsumerWidget {
child: Scaffold(
body: SafeArea(
child: switch (tabsViewMode) {
TabsViewMode.grid => ViewTabsWidget(
TabsViewMode.list => ViewTabListWidget(
key: ValueKey(tabsReorderable),
scrollController: scrollController,
tabsReorderable: tabsReorderable,
showNewTabFab: false,
onClose: () {
const BrowserRoute().go(context);
},
),
TabsViewMode.grid => ViewTabGridWidget(
key: ValueKey(tabsReorderable),
scrollController: scrollController,
tabsReorderable: tabsReorderable,
@@ -0,0 +1,457 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_reorderable_grid_view/widgets/custom_draggable.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart';
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_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart';
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/selected_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_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String? suggestedContainerId;
final VoidCallback onClose;
const _TabDraggable({
required this.entity,
required this.onClose,
this.suggestedContainerId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeTab = ref.watch(selectedTabProvider);
final dragData = ref.watch(
willAcceptDropProvider.select((value) {
final dragTabId = switch (value) {
ContainerDropData() => value.tabId,
DeleteDropData() => value.tabId,
null => null,
};
return (dragTabId == entity.tabId) ? value : null;
}),
);
// Cache the tab widget to avoid rebuilding
final tab = useMemoized(() {
return (suggestedContainerId != null)
? SuggestedSingleGridTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onTap: () async {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(suggestedContainerId!);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(entity.tabId, containerData);
}
},
)
: SingleGridTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(),
},
);
}, [entity.tabId, activeTab, suggestedContainerId]);
return switch (dragData) {
ContainerDropData() => Opacity(
opacity: 0.3,
child: Transform.scale(scale: 0.9, child: tab),
),
DeleteDropData() => Opacity(
opacity: 0.3,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate),
child: tab,
),
),
null => tab,
};
}
}
class _TabGridView extends HookConsumerWidget {
final ScrollController scrollController;
final bool tabsReorderable;
final VoidCallback onClose;
const _TabGridView({
required this.scrollController,
required this.tabsReorderable,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenWidth = MediaQuery.of(context).size.width;
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
);
final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider);
final suggestedTabEntities = tabSuggestionsEnabled
? ref.watch(suggestedTabEntitiesProvider(containerId))
: EquatableValue(<TabEntity>[]);
final itemCount =
filteredTabEntities.value.length +
//Limit to 3 sugegstions for now
math.min<int>(suggestedTabEntities.value.length, 3);
final activeTab = ref.watch(selectedTabProvider);
final crossAxisCount = useMemoized(() {
final calculatedCount = calculateCrossAxisItemCount(
screenWidth: screenWidth,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(math.min(calculatedCount, itemCount), 2);
}, [screenWidth, itemCount]);
final itemSize = useMemoized(
() => calculateItemSize(
screenWidth: screenWidth,
childAspectRatio: 0.75,
horizontalPadding: 4.0,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
[screenWidth, crossAxisCount],
);
final lastScroll = useRef<String?>(null);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
if (lastScroll.value != activeTab) {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = (index ~/ 2) * itemSize.height;
if (offset != scrollController.offset) {
lastScroll.value = activeTab;
unawaited(
scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
}
}
});
return null;
}, [filteredTabEntities, activeTab]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: FadingScroll(
fadingSize: 5,
controller: scrollController,
builder: (context, controller) {
return !tabsReorderable
? _TabGrid(
key: ValueKey(crossAxisCount),
crossAxisCount: crossAxisCount,
itemCount: itemCount,
scrollController: controller,
itemBuilder: (widget, _) {
if (widget is CustomDraggable) {
return LongPressDraggable(
feedback: Material(
color: Colors
.transparent, // removes white corners when having shadow
child: Transform.scale(
scale: 1.05,
child: SizedBox(
height: itemSize.height,
width: itemSize.width,
child: widget.child,
),
),
),
data: widget.data,
childWhenDragging: SizedBox(
height: itemSize.height,
width: itemSize.width,
),
child: widget.child,
);
}
return widget;
},
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
)
: ReorderableBuilder.builder(
//Rebuild when cross axis count changes
key: ValueKey(crossAxisCount),
scrollController: controller,
itemCount: itemCount,
onDragStarted: (index) {
ref.read(willAcceptDropProvider.notifier).clear();
},
onReorderPositions: (positions) async {
assert(
positions.length == 1,
'Not ready for multiple reorders',
);
final oldIndex = positions.first.oldIndex;
final newIndex = positions.first.newIndex;
final containerRepository = ref.read(
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
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final String key;
if (newIndex <= 0) {
key = await containerRepository.getLeadingOrderKey(
containerId,
);
} else if (newIndex >=
filteredTabEntities.value.length - 1) {
key = await containerRepository.getTrailingOrderKey(
containerId,
);
} else {
if (newIndex < oldIndex) {
key = (await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[newIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository.getOrderKeyBeforeTab(
filteredTabEntities.value[newIndex + 1].tabId,
containerId,
);
}
}
await ref
.read(tabDataRepositoryProvider.notifier)
.assignOrderKey(tabId, key);
},
childBuilder: (itemBuilder) {
return _TabGrid(
crossAxisCount: crossAxisCount,
itemCount: itemCount,
scrollController: controller,
itemBuilder: itemBuilder,
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
);
},
);
},
),
);
}
}
class _TabGrid extends StatelessWidget {
const _TabGrid({
super.key,
required this.crossAxisCount,
required this.scrollController,
required this.itemCount,
required this.itemBuilder,
required this.suggestedContainerId,
required this.filteredTabEntities,
required this.suggestedTabEntities,
required this.onClose,
});
final int crossAxisCount;
final int itemCount;
final ScrollController? scrollController;
final String? suggestedContainerId;
final EquatableValue<List<TabEntity>> filteredTabEntities;
final EquatableValue<List<TabEntity>> suggestedTabEntities;
final Widget Function(Widget, int)? itemBuilder;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return GridView.builder(
controller: scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: itemCount,
itemBuilder: (context, index) {
final Widget tab;
if (index < filteredTabEntities.value.length) {
final entity = filteredTabEntities.value[index];
tab = CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(entity: entity, onClose: onClose),
);
} else {
final suggestedIndex = index - filteredTabEntities.value.length;
final entity = suggestedTabEntities.value[suggestedIndex];
tab = CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
onClose: onClose,
suggestedContainerId: suggestedContainerId,
),
);
}
return (itemBuilder != null) ? itemBuilder!(tab, index) : tab;
},
);
}
}
class ViewTabGridWidget extends HookConsumerWidget {
final ScrollController scrollController;
final DraggableScrollableController? draggableScrollableController;
final bool showNewTabFab;
final bool tabsReorderable;
final VoidCallback onClose;
const ViewTabGridWidget({
required this.onClose,
required this.scrollController,
this.draggableScrollableController,
required this.tabsReorderable,
required this.showNewTabFab,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
alignment: Alignment.bottomRight,
children: [
NestedScrollView(
physics: const NeverScrollableScrollPhysics(),
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverToBoxAdapter(
child:
draggableScrollableController.mapNotNull(
(draggableScrollableController) =>
DraggableScrollableHeader(
controller: draggableScrollableController,
child: TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.grid,
),
),
) ??
TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.grid,
),
),
],
body: _TabGridView(
scrollController: scrollController,
tabsReorderable: tabsReorderable,
onClose: onClose,
),
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
}
}
@@ -0,0 +1,425 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_reorderable_grid_view/widgets/custom_draggable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart';
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_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
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/selected_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_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String? suggestedContainerId;
final VoidCallback onClose;
final double height;
const _TabDraggable({
required this.entity,
required this.onClose,
required this.height,
this.suggestedContainerId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeTab = ref.watch(selectedTabProvider);
final dragData = ref.watch(
willAcceptDropProvider.select((value) {
final dragTabId = switch (value) {
ContainerDropData() => value.tabId,
DeleteDropData() => value.tabId,
null => null,
};
return (dragTabId == entity.tabId) ? value : null;
}),
);
// Cache the tab widget to avoid rebuilding
final tab = useMemoized(() {
return (suggestedContainerId != null)
? SuggestedSingleListTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onTap: () async {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(suggestedContainerId!);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(entity.tabId, containerData);
}
},
)
: SingleListTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(),
},
);
}, [entity.tabId, activeTab, suggestedContainerId]);
return switch (dragData) {
ContainerDropData() => Opacity(
opacity: 0.3,
child: Transform.scale(
scale: 0.9,
child: SizedBox(
height: height,
width: MediaQuery.of(context).size.width,
child: tab,
),
),
),
DeleteDropData() => Opacity(
opacity: 0.3,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate),
child: tab,
),
),
null => tab,
};
}
}
class _TabListView extends HookConsumerWidget {
final ScrollController scrollController;
final bool tabsReorderable;
final VoidCallback onClose;
const _TabListView({
required this.scrollController,
required this.tabsReorderable,
required this.onClose,
});
static const _itemHeight = 80.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
// final screenWidth = MediaQuery.of(context).size.width;
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
);
final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider);
final suggestedTabEntities = tabSuggestionsEnabled
? ref.watch(suggestedTabEntitiesProvider(containerId))
: EquatableValue(<TabEntity>[]);
final itemCount =
filteredTabEntities.value.length +
//Limit to 3 sugegstions for now
math.min<int>(suggestedTabEntities.value.length, 3);
final activeTab = ref.watch(selectedTabProvider);
final lastScroll = useRef<String?>(null);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
if (lastScroll.value != activeTab) {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = index * _itemHeight;
if (offset != scrollController.offset) {
lastScroll.value = activeTab;
unawaited(
scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
}
}
});
return null;
}, [filteredTabEntities, activeTab]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: FadingScroll(
fadingSize: 5,
controller: scrollController,
builder: (context, controller) {
return !tabsReorderable
? ListView.builder(
controller: scrollController,
itemCount: itemCount,
itemExtent: _itemHeight,
itemBuilder: (context, index) {
final CustomDraggable tab;
if (index < filteredTabEntities.value.length) {
final entity = filteredTabEntities.value[index];
tab = CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(
entity: entity,
onClose: onClose,
height: _itemHeight,
),
);
} else {
final suggestedIndex =
index - filteredTabEntities.value.length;
final entity = suggestedTabEntities.value[suggestedIndex];
tab = CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
onClose: onClose,
suggestedContainerId: containerId,
height: _itemHeight,
),
);
}
return LongPressDraggable(
feedback: Material(
color: Colors
.transparent, // removes white corners when having shadow
child: Transform.scale(
scale: 1.05,
child: SizedBox(
height: _itemHeight,
width: MediaQuery.of(context).size.width,
child: tab.child,
),
),
),
data: tab.data,
childWhenDragging: const SizedBox(height: _itemHeight),
child: tab.child,
);
},
)
: ReorderableListView.builder(
scrollController: controller,
itemCount: itemCount,
onReorderStart: (index) {
ref.read(willAcceptDropProvider.notifier).clear();
},
onReorder: (oldIndex, newIndex) async {
final containerRepository = ref.read(
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
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final String key;
if (newIndex <= 0) {
key = await containerRepository.getLeadingOrderKey(
containerId,
);
} else if (newIndex >=
filteredTabEntities.value.length - 1) {
key = await containerRepository.getTrailingOrderKey(
containerId,
);
} else {
if (newIndex < oldIndex) {
key = (await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[newIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository.getOrderKeyBeforeTab(
filteredTabEntities.value[newIndex + 1].tabId,
containerId,
);
}
}
await ref
.read(tabDataRepositoryProvider.notifier)
.assignOrderKey(tabId, key);
},
itemBuilder: (context, index) {
final CustomDraggable tab;
if (index < filteredTabEntities.value.length) {
final entity = filteredTabEntities.value[index];
tab = CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(
entity: entity,
onClose: onClose,
height: _itemHeight,
),
);
} else {
final suggestedIndex =
index - filteredTabEntities.value.length;
final entity = suggestedTabEntities.value[suggestedIndex];
tab = CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
onClose: onClose,
suggestedContainerId: containerId,
height: _itemHeight,
),
);
}
// return LongPressDraggable(
// key: tab.key,
// feedback: Material(
// color: Colors
// .transparent, // removes white corners when having shadow
// child: Transform.scale(
// scale: 1.05,
// child: SizedBox(
// height: _itemHeight,
// width: MediaQuery.of(context).size.width,
// child: tab.child,
// ),
// ),
// ),
// data: tab.data,
// child: tab.child,
// );
return tab;
},
);
},
),
);
}
}
class ViewTabListWidget extends HookConsumerWidget {
final ScrollController scrollController;
final DraggableScrollableController? draggableScrollableController;
final bool showNewTabFab;
final bool tabsReorderable;
final VoidCallback onClose;
const ViewTabListWidget({
required this.onClose,
required this.scrollController,
this.draggableScrollableController,
required this.tabsReorderable,
required this.showNewTabFab,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
alignment: Alignment.bottomRight,
children: [
NestedScrollView(
physics: const NeverScrollableScrollPhysics(),
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverToBoxAdapter(
child:
draggableScrollableController.mapNotNull(
(draggableScrollableController) =>
DraggableScrollableHeader(
controller: draggableScrollableController,
child: TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.list,
),
),
) ??
TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.list,
),
),
],
body: _TabListView(
scrollController: scrollController,
tabsReorderable: tabsReorderable,
onClose: onClose,
),
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
}
}
@@ -24,24 +24,22 @@ 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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.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/tabs/data/entities/tab_entity.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class TabContainer extends StatelessWidget {
class GridTabItemContainer extends StatelessWidget {
final bool isActive;
final bool isPrivate;
final Widget? child;
const TabContainer({
const GridTabItemContainer({
required this.isActive,
required this.isPrivate,
this.child,
@@ -71,38 +69,7 @@ class TabContainer 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 HookConsumerWidget {
class GridTabPreview extends HookConsumerWidget {
final String tabId;
final bool isActive;
@@ -114,7 +81,7 @@ class TabPreview extends HookConsumerWidget {
final Widget? trailingChild;
const TabPreview({
const GridTabPreview({
required this.tabId,
required this.isActive,
this.onTap,
@@ -135,7 +102,7 @@ class TabPreview extends HookConsumerWidget {
final extendedDeleteMenuController = useMenuController();
return TabContainer(
return GridTabItemContainer(
isActive: isActive,
isPrivate: tabState.isPrivate,
child: InkWell(
@@ -258,7 +225,145 @@ class TabPreview extends HookConsumerWidget {
}
}
class SingleTabPreview extends HookConsumerWidget {
class ListTabPreview extends HookConsumerWidget {
final String tabId;
final bool isActive;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onDelete;
final void Function(String host)? onDeleteAll;
final Widget? trailingChild;
const ListTabPreview({
required this.tabId,
required this.isActive,
this.onTap,
this.onLongPress,
this.onDelete,
this.onDeleteAll,
this.trailingChild,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final tabState = ref.watch(tabStateProvider(tabId));
if (tabState == null) {
return const SizedBox.shrink();
}
final extendedDeleteMenuController = useMenuController();
return Container(
decoration: BoxDecoration(
color: tabState.isPrivate ? const Color(0xFF25003E) : null,
border: isActive ? Border.all(color: colorScheme.primary) : null,
// borderRadius: const BorderRadius.all(Radius.circular(16.0)),
),
child: Row(
children: [
Expanded(
child: ListTile(
key: ValueKey(tabState.id),
onTap: onTap,
onLongPress: onLongPress,
contentPadding: const EdgeInsets.only(left: 4),
leading: (tabState.thumbnail != null)
? RepaintBoundary(
child: RawImage(
image: tabState.thumbnail!.value,
fit: BoxFit.fitHeight,
),
)
: TabIcon(tabState: tabState, iconSize: 32),
title: Text(
overflow: TextOverflow.ellipsis,
tabState.titleOrAuthority,
maxLines: 2,
style: tabState.isPrivate
? const TextStyle(color: Colors.white)
: null,
),
subtitle: Row(
children: [
Expanded(
child: UriBreadcrumb(
uri: tabState.url,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tabState.isPrivate ? Colors.white : null,
),
),
),
if (tabState.isPrivate) ...[
const SizedBox(width: 6.0),
const SizedBox(
height: 16,
width: 24,
child: Stack(
fit: StackFit.expand,
children: [
Positioned(
top: -4,
child: Icon(
MdiIcons.dominoMask,
color: Color(0xFF8000D7),
),
),
],
),
),
],
],
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onDelete != null || onDeleteAll != null)
MenuAnchor(
controller: extendedDeleteMenuController,
builder: (context, controller, child) {
return child!;
},
menuChildren: [
MenuItemButton(
onPressed: () {
onDeleteAll?.call(tabState.url.host);
},
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
child: Text('Close all from ${tabState.url.host}'),
),
],
child: IconButton(
onPressed: onDelete,
onLongPress: onDeleteAll != null
? () {
if (extendedDeleteMenuController.isOpen) {
extendedDeleteMenuController.close();
} else {
extendedDeleteMenuController.open();
}
}
: null,
icon: const Icon(Icons.close),
),
),
?trailingChild,
],
),
],
),
);
}
}
class SingleGridTabPreview extends HookConsumerWidget {
final String tabId;
final String? activeTabId;
final String? sourceSearchQuery;
@@ -267,7 +372,7 @@ class SingleTabPreview extends HookConsumerWidget {
final void Function() onClose;
final void Function()? onBeforeDelete;
const SingleTabPreview({
const SingleGridTabPreview({
required this.tabId,
required this.activeTabId,
required this.onClose,
@@ -319,7 +424,7 @@ class SingleTabPreview extends HookConsumerWidget {
},
child: Opacity(
opacity: 1.0 - draggedDistance.value / deleteThreshold,
child: TabPreview(
child: GridTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
onTap: () async {
@@ -341,7 +446,7 @@ class SingleTabPreview extends HookConsumerWidget {
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabId);
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
@@ -382,14 +487,138 @@ class SingleTabPreview extends HookConsumerWidget {
}
}
class SuggestedSingleTabPreview extends StatelessWidget {
class SingleListTabPreview extends HookConsumerWidget {
final String tabId;
final String? activeTabId;
final String? sourceSearchQuery;
final double deleteThreshold;
final void Function() onClose;
final void Function()? onBeforeDelete;
const SingleListTabPreview({
required this.tabId,
required this.activeTabId,
required this.onClose,
required this.sourceSearchQuery,
this.deleteThreshold = 100,
this.onBeforeDelete,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasTabState = ref.watch(
tabStateProvider(tabId).select((value) => value != null),
);
if (!hasTabState) {
return const SizedBox.shrink();
}
final dragStartPosition = useRef(Offset.zero);
final draggedDistance = useState(0.0);
return GestureDetector(
onHorizontalDragStart: (details) {
dragStartPosition.value = details.globalPosition;
draggedDistance.value = 0.0;
},
onHorizontalDragUpdate: (details) {
final cappedDistance = math.min(
(dragStartPosition.value - details.globalPosition).dx.abs(),
deleteThreshold,
);
draggedDistance.value = cappedDistance;
},
onHorizontalDragEnd: (details) async {
if (draggedDistance.value >= deleteThreshold) {
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
}
draggedDistance.value = 0.0;
},
child: Opacity(
opacity: 1.0 - draggedDistance.value / deleteThreshold,
child: ListTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
onTap: () async {
if (tabId != activeTabId) {
//Close first to avoid rebuilds
onClose();
await ref.read(tabRepositoryProvider.notifier).selectTab(tabId);
if (sourceSearchQuery.isNotEmpty &&
ref.read(findInPageControllerProvider(tabId)) ==
FindInPageState.hidden()) {
await ref
.read(findInPageControllerProvider(tabId).notifier)
.findAll(text: sourceSearchQuery!);
}
} else {
onClose();
}
},
onDeleteAll: (host) async {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabContainerId(tabId);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByHost(containerId, host);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: count,
);
}
},
// onDoubleTap: () {
// ref.read(overlayDialogControllerProvider.notifier).show(
// TabActionDialog(
// initialTab: tab,
// onDismiss:
// ref.read(overlayDialogControllerProvider.notifier).dismiss,
// ),
// );
// },
onDelete: () async {
onBeforeDelete?.call();
await ref.read(tabRepositoryProvider.notifier).closeTab(tabId);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
);
}
},
),
),
);
}
}
class SuggestedSingleGridTabPreview extends StatelessWidget {
final String tabId;
final String? activeTabId;
final void Function()? onTap;
const SuggestedSingleTabPreview({
const SuggestedSingleGridTabPreview({
required this.tabId,
required this.activeTabId,
this.onTap,
@@ -400,7 +629,7 @@ class SuggestedSingleTabPreview extends StatelessWidget {
Widget build(BuildContext context) {
return Opacity(
opacity: 0.5,
child: TabPreview(
child: GridTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
onTap: onTap,
@@ -414,133 +643,34 @@ class SuggestedSingleTabPreview extends StatelessWidget {
}
}
class TabTreePreview extends HookConsumerWidget {
final TabTreeEntity entity;
class SuggestedSingleListTabPreview extends StatelessWidget {
final String tabId;
final String? activeTabId;
final Offset stackPadding;
final void Function()? onTap;
final void Function() onClose;
TabTreePreview({
required this.entity,
const SuggestedSingleListTabPreview({
required this.tabId,
required this.activeTabId,
required this.onClose,
required this.stackPadding,
}) : super(key: ValueKey(entity));
Widget _addPadding(int stackCount, int totalCount, Widget child) {
final topPadding = stackPadding * stackCount.toDouble();
final bottomPadding = stackPadding * (totalCount - stackCount).toDouble();
return Positioned(
top: topPadding.dy,
left: topPadding.dx,
right: bottomPadding.dx,
bottom: bottomPadding.dy,
child: child,
);
}
this.onTap,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isTabPrivate = ref.watch(
tabStateProvider(entity.tabId).select((value) => value?.isPrivate),
);
if (isTabPrivate == null) {
return const SizedBox.shrink();
}
final stackCount = math.min(entity.totalTabs, 3) - 1;
return Stack(
children: [
for (var index = 0; index < stackCount; index++)
_addPadding(
index,
stackCount,
TabContainer(
isActive: entity.tabId == activeTabId,
isPrivate: isTabPrivate == true,
),
),
_addPadding(
stackCount,
stackCount,
Badge.count(
isLabelVisible: entity.totalTabs > 1,
count: entity.totalTabs,
alignment: AlignmentDirectional.bottomEnd,
offset: const Offset(-8, -24),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
textColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: TabPreview(
tabId: entity.tabId,
isActive: entity.tabId == activeTabId,
onLongPress: () async {
if (entity.tabId != activeTabId) {
//Close first to avoid rebuilds
onClose();
await ref
.read(tabRepositoryProvider.notifier)
.selectTab(entity.tabId);
} else {
onClose();
}
},
onTap: () async {
if (entity.totalTabs > 1) {
await TabTreeRoute(entity.rootId).push(context);
} else if (entity.tabId != activeTabId) {
//Close first to avoid rebuilds
onClose();
await ref
.read(tabRepositoryProvider.notifier)
.selectTab(entity.tabId);
} else {
onClose();
}
},
// onDeleteAll: (host) async {
// final containerId = await ref
// .read(tabDataRepositoryProvider.notifier)
// .containerTabId(tab.id);
// await ref
// .read(tabDataRepositoryProvider.notifier)
// .closeAllTabsByHost(containerId, host);
// },
// onDoubleTap: () {
// ref.read(overlayDialogControllerProvider.notifier).show(
// TabActionDialog(
// initialTab: tab,
// onDismiss:
// ref.read(overlayDialogControllerProvider.notifier).dismiss,
// ),
// );
// },
onDelete: () async {
final tabs = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(entity.rootId);
await ref
.read(tabRepositoryProvider.notifier)
.closeTabs(tabs.keys.toList());
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: tabs.length,
);
}
},
),
),
Widget build(BuildContext context) {
return Opacity(
opacity: 0.5,
child: ListTabPreview(
tabId: tabId,
isActive: tabId == activeTabId,
onTap: onTap,
trailingChild: const IconButton(
visualDensity: VisualDensity(horizontal: -4.0, vertical: -4.0),
icon: Icon(MdiIcons.creation),
onPressed: null,
),
],
),
);
}
}
@@ -0,0 +1,299 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.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/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart';
import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart';
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/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class _TabTreePreview extends HookConsumerWidget {
final TabTreeEntity entity;
final String? activeTabId;
final Offset stackPadding;
final void Function() onClose;
_TabTreePreview({
required this.entity,
required this.activeTabId,
required this.onClose,
required this.stackPadding,
}) : super(key: ValueKey(entity));
Widget _addPadding(int stackCount, int totalCount, Widget child) {
final topPadding = stackPadding * stackCount.toDouble();
final bottomPadding = stackPadding * (totalCount - stackCount).toDouble();
return Positioned(
top: topPadding.dy,
left: topPadding.dx,
right: bottomPadding.dx,
bottom: bottomPadding.dy,
child: child,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final isTabPrivate = ref.watch(
tabStateProvider(entity.tabId).select((value) => value?.isPrivate),
);
if (isTabPrivate == null) {
return const SizedBox.shrink();
}
final stackCount = math.min(entity.totalTabs, 3) - 1;
return Stack(
children: [
for (var index = 0; index < stackCount; index++)
_addPadding(
index,
stackCount,
GridTabItemContainer(
isActive: entity.tabId == activeTabId,
isPrivate: isTabPrivate == true,
),
),
_addPadding(
stackCount,
stackCount,
Badge.count(
isLabelVisible: entity.totalTabs > 1,
count: entity.totalTabs,
alignment: AlignmentDirectional.bottomEnd,
offset: const Offset(-8, -24),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
textColor: Theme.of(context).colorScheme.onPrimaryContainer,
child: GridTabPreview(
tabId: entity.tabId,
isActive: entity.tabId == activeTabId,
onLongPress: () async {
if (entity.tabId != activeTabId) {
//Close first to avoid rebuilds
onClose();
await ref
.read(tabRepositoryProvider.notifier)
.selectTab(entity.tabId);
} else {
onClose();
}
},
onTap: () async {
if (entity.totalTabs > 1) {
await TabTreeRoute(entity.rootId).push(context);
} else if (entity.tabId != activeTabId) {
//Close first to avoid rebuilds
onClose();
await ref
.read(tabRepositoryProvider.notifier)
.selectTab(entity.tabId);
} else {
onClose();
}
},
// onDeleteAll: (host) async {
// final containerId = await ref
// .read(tabDataRepositoryProvider.notifier)
// .containerTabId(tab.id);
// await ref
// .read(tabDataRepositoryProvider.notifier)
// .closeAllTabsByHost(containerId, host);
// },
// onDoubleTap: () {
// ref.read(overlayDialogControllerProvider.notifier).show(
// TabActionDialog(
// initialTab: tab,
// onDismiss:
// ref.read(overlayDialogControllerProvider.notifier).dismiss,
// ),
// );
// },
onDelete: () async {
final tabs = await ref
.read(tabDataRepositoryProvider.notifier)
.getTabDescendants(entity.rootId);
await ref
.read(tabRepositoryProvider.notifier)
.closeTabs(tabs.keys.toList());
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref.read(tabRepositoryProvider.notifier).undoClose,
count: tabs.length,
);
}
},
),
),
),
],
);
}
}
class ViewTabTreesWidget extends HookConsumerWidget {
final ScrollController scrollController;
final VoidCallback onClose;
final bool showNewTabFab;
const ViewTabTreesWidget({
required this.onClose,
required this.scrollController,
required this.showNewTabFab,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
alignment: Alignment.bottomRight,
children: [
Column(
children: [
TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree),
Expanded(
child: HookConsumer(
builder: (context, ref, child) {
final screenWidth = MediaQuery.of(context).size.width;
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterDisabled(),
groupTrees: true,
),
);
final activeTab = ref.watch(selectedTabProvider);
final crossAxisCount = useMemoized(() {
final calculatedCount = calculateCrossAxisItemCount(
screenWidth: screenWidth,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(
math.min(
calculatedCount,
filteredTabEntities.value.length,
),
2,
);
}, [screenWidth, filteredTabEntities.value.length]);
final itemSize = useMemoized(
() => calculateItemSize(
screenWidth: screenWidth,
childAspectRatio: 0.75,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
mainAxisSpacing: 8.0,
),
[screenWidth, crossAxisCount],
);
useEffect(() {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = (index ~/ 2) * itemSize.height;
if (offset != scrollController.offset) {
unawaited(
scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
return null;
}, [filteredTabEntities, activeTab]);
final tabs = useMemoized(() {
return filteredTabEntities.value
.whereType<TabTreeEntity>()
.map((entity) {
return _TabTreePreview(
entity: entity,
activeTabId: activeTab,
onClose: onClose,
stackPadding: const Offset(8, 8),
);
})
.toList();
}, [filteredTabEntities, activeTab]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: GridView.builder(
controller: scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: tabs.length,
itemBuilder: (context, index) => tabs[index],
),
);
},
),
),
],
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
}
}
@@ -0,0 +1,371 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class TabViewHeader extends HookConsumerWidget {
static const headerSize = 124.0;
final TabsViewMode tabsViewMode;
final VoidCallback onClose;
const TabViewHeader({required this.onClose, required this.tabsViewMode});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchMode = useState(false);
final searchTextFocus = useFocusNode();
final searchTextController = useTextEditingController();
final viewModeMenuController = useMenuController();
final tabsActionMenuController = useMenuController();
final hasSearchText = useListenableSelector(
searchTextController,
() => searchTextController.text.isNotEmpty,
);
final enableAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
useListenableCallback(searchTextController, () async {
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) {
await ref
.read(
tabSearchRepositoryProvider(TabSearchPartition.preview).notifier,
)
.addQuery(searchTextController.text);
}
});
return Material(
//Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555
child: Align(
child: Padding(
padding: const EdgeInsets.only(right: 8.0, left: 8.0, top: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!searchMode.value)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(MdiIcons.tabSearch),
iconSize: 18,
padding: EdgeInsets.zero,
onPressed: () {
switch (ref.read(tabsViewModeControllerProvider)) {
case TabsViewMode.tree:
case TabsViewMode.list:
break;
case TabsViewMode.grid:
ref
.read(
tabsViewModeControllerProvider.notifier,
)
.set(TabsViewMode.list);
}
searchMode.value = true;
searchTextFocus.requestFocus();
},
),
const SizedBox(
height: 32,
child: VerticalDivider(indent: 4, endIndent: 4),
),
MenuAnchor(
controller: viewModeMenuController,
menuChildren: TabsViewMode.values
.map(
(mode) => MenuItemButton(
leadingIcon: Icon(mode.icon),
child: Text(mode.label),
onPressed: () {
ref
.read(
tabsViewModeControllerProvider
.notifier,
)
.set(mode);
},
),
)
.toList(),
child: IconButton(
onPressed: () {
if (viewModeMenuController.isOpen) {
viewModeMenuController.close();
} else {
viewModeMenuController.open();
}
},
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tabsViewMode.icon, size: 18),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
),
),
if (enableAiFeatures &&
switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
})
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();
},
);
},
),
if (switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
})
Consumer(
builder: (context, ref, child) {
final tabsReorderabe = ref.watch(
tabsReorderableControllerProvider,
);
return IconButton.filledTonal(
icon: const Icon(
MdiIcons.orderNumericAscending,
),
isSelected: tabsReorderabe,
iconSize: 18,
padding: EdgeInsets.zero,
onPressed: () {
ref
.read(
tabsReorderableControllerProvider
.notifier,
)
.toggle();
},
);
},
),
],
),
MenuAnchor(
controller: tabsActionMenuController,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline),
child: const Text('Bookmark all'),
onPressed: () async {
final containerId = ref.read(
selectedContainerProvider,
);
final tabData = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabsData(containerId);
for (final tab in tabData) {
if (context.mounted) {
await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode(
BookmarkInfo(
title: tab.title,
url: tab.url.toString(),
).encode(),
),
).push(context);
}
}
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
child: const Text('Close All'),
onPressed: () async {
final result = await showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Close All Tabs'),
content: const Text(
'Are you sure you want to close all displayed tabs?',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Close'),
),
],
);
},
);
if (result == true) {
final container = ref.read(
selectedContainerProvider,
);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByContainer(container);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
count: count,
);
}
}
},
),
],
child: IconButton(
onPressed: () {
if (tabsActionMenuController.isOpen) {
tabsActionMenuController.close();
} else {
tabsActionMenuController.open();
}
},
icon: const Icon(MdiIcons.dotsVertical),
),
),
],
)
else
TextField(
controller: searchTextController,
focusNode: searchTextFocus,
// enableIMEPersonalizedLearning: !incognitoEnabled,
decoration: InputDecoration(
// border: InputBorder.none,
prefixIcon: const Icon(MdiIcons.tabSearch, size: 18),
hintText: 'Search inside tabs...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!hasSearchText)
SpeechToTextButton(
onTextReceived: (data) {
searchTextController.text = data.toString();
},
),
IconButton(
onPressed: () {
searchTextController.clear();
searchTextFocus.requestFocus();
searchMode.value = false;
},
icon: const Icon(Icons.clear),
),
],
),
),
),
if (switch (tabsViewMode) {
TabsViewMode.list || TabsViewMode.grid => true,
TabsViewMode.tree => false,
})
Consumer(
builder: (context, ref, child) {
final selectedContainer = ref.watch(
selectedContainerDataProvider.select(
(value) => value.value,
),
);
return ContainerChips(
showGroupSuggestions: true,
selectedContainer: selectedContainer,
onSelected: (container) async {
if (container != null) {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted &&
result == SetContainerResult.successHasProxy) {
await ref
.read(startProxyControllerProvider.notifier)
.maybeStartProxy(context);
}
} else {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
}
},
onDeleted: (container) {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
},
onLongPress: (container) async {
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
);
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
}
@@ -1,988 +0,0 @@
/*
* Copyright (c) 2024-2025 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:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:fading_scroll/fading_scroll.dart';
import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:flutter_reorderable_grid_view/widgets/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart';
import 'package:weblibre/core/providers/global_drop.dart';
import 'package:weblibre/core/routing/routes.dart';
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/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.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';
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/selected_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_search.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/listenable_callback.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
int calculateCrossAxisItemCount({
required double screenWidth,
required double horizontalPadding,
required double crossAxisSpacing,
}) {
final totalHorizontalPadding = horizontalPadding * 2;
final availableWidth =
screenWidth - totalHorizontalPadding - crossAxisSpacing;
final crossAxisCount = availableWidth ~/ 180.0;
return crossAxisCount;
}
Size calculateItemSize({
required double screenWidth,
required double childAspectRatio,
required double horizontalPadding,
required double mainAxisSpacing,
required double crossAxisSpacing,
required int crossAxisCount,
}) {
final totalHorizontalPadding = horizontalPadding * 2;
final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1);
final availableWidth =
screenWidth - totalHorizontalPadding - totalCrossAxisSpacing;
final itemWidth = availableWidth / crossAxisCount;
final itemHeight = itemWidth / childAspectRatio;
return Size(itemWidth, itemHeight + mainAxisSpacing);
}
class _TabDraggable extends HookConsumerWidget {
final TabEntity entity;
final String? suggestedContainerId;
final VoidCallback onClose;
const _TabDraggable({
required this.entity,
required this.onClose,
this.suggestedContainerId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final activeTab = ref.watch(selectedTabProvider);
final dragData = ref.watch(
willAcceptDropProvider.select((value) {
final dragTabId = switch (value) {
ContainerDropData() => value.tabId,
DeleteDropData() => value.tabId,
null => null,
};
return (dragTabId == entity.tabId) ? value : null;
}),
);
// Cache the tab widget to avoid rebuilding
final tab = useMemoized(() {
return (suggestedContainerId != null)
? SuggestedSingleTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onTap: () async {
final containerData = await ref
.read(containerRepositoryProvider.notifier)
.getContainerData(suggestedContainerId!);
if (containerData != null) {
await ref
.read(tabDataRepositoryProvider.notifier)
.assignContainer(entity.tabId, containerData);
}
},
)
: SingleTabPreview(
key: ValueKey(entity.tabId),
tabId: entity.tabId,
activeTabId: activeTab,
onClose: onClose,
sourceSearchQuery: switch (entity) {
DefaultTabEntity _ => null,
final SearchResultTabEntity entity => entity.searchQuery,
TabTreeEntity _ => throw UnimplementedError(),
},
);
}, [entity.tabId, activeTab, suggestedContainerId]);
return switch (dragData) {
ContainerDropData() => Opacity(
opacity: 0.3,
child: Transform.scale(scale: 0.9, child: tab),
),
DeleteDropData() => Opacity(
opacity: 0.3,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate),
child: tab,
),
),
null => tab,
};
}
}
class _TabViewHeader extends HookConsumerWidget {
static const headerSize = 124.0;
final TabsViewMode tabsViewMode;
final VoidCallback onClose;
const _TabViewHeader({required this.onClose, required this.tabsViewMode});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchMode = useState(false);
final searchTextFocus = useFocusNode();
final searchTextController = useTextEditingController();
final viewModeMenuController = useMenuController();
final tabsActionMenuController = useMenuController();
final hasSearchText = useListenableSelector(
searchTextController,
() => searchTextController.text.isNotEmpty,
);
final enableAiFeatures = ref.watch(
generalSettingsWithDefaultsProvider.select(
(settings) => settings.enableLocalAiFeatures,
),
);
useListenableCallback(searchTextController, () async {
if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) {
await ref
.read(
tabSearchRepositoryProvider(TabSearchPartition.preview).notifier,
)
.addQuery(searchTextController.text);
}
});
return Material(
//Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555
child: Align(
child: Padding(
padding: const EdgeInsets.only(right: 8.0, left: 8.0, top: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!searchMode.value)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(MdiIcons.tabSearch),
iconSize: 18,
padding: EdgeInsets.zero,
onPressed: () {
ref
.read(tabsViewModeControllerProvider.notifier)
.set(TabsViewMode.grid);
searchMode.value = true;
searchTextFocus.requestFocus();
},
),
const SizedBox(
height: 32,
child: VerticalDivider(indent: 4, endIndent: 4),
),
MenuAnchor(
controller: viewModeMenuController,
menuChildren: TabsViewMode.values
.map(
(mode) => MenuItemButton(
leadingIcon: Icon(mode.icon),
child: Text(mode.label),
onPressed: () {
ref
.read(
tabsViewModeControllerProvider
.notifier,
)
.set(mode);
},
),
)
.toList(),
child: IconButton(
onPressed: () {
if (viewModeMenuController.isOpen) {
viewModeMenuController.close();
} else {
viewModeMenuController.open();
}
},
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tabsViewMode.icon, size: 18),
const Icon(Icons.arrow_drop_down, size: 18),
],
),
),
),
if (enableAiFeatures)
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();
},
);
},
),
Consumer(
builder: (context, ref, child) {
final tabsReorderabe = ref.watch(
tabsReorderableControllerProvider,
);
return IconButton.filledTonal(
icon: const Icon(MdiIcons.orderNumericAscending),
isSelected: tabsReorderabe,
iconSize: 18,
padding: EdgeInsets.zero,
onPressed: () {
ref
.read(
tabsReorderableControllerProvider
.notifier,
)
.toggle();
},
);
},
),
],
),
MenuAnchor(
controller: tabsActionMenuController,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline),
child: const Text('Bookmark all'),
onPressed: () async {
final containerId = ref.read(
selectedContainerProvider,
);
final tabData = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabsData(containerId);
for (final tab in tabData) {
if (context.mounted) {
await BookmarkEntryAddRoute(
bookmarkInfo: jsonEncode(
BookmarkInfo(
title: tab.title,
url: tab.url.toString(),
).encode(),
),
).push(context);
}
}
},
),
MenuItemButton(
leadingIcon: const Icon(MdiIcons.closeBoxMultiple),
child: const Text('Close All'),
onPressed: () async {
final result = await showDialog<bool?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
icon: const Icon(Icons.warning),
title: const Text('Close All Tabs'),
content: const Text(
'Are you sure you want to close all displayed tabs?',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context, false);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context, true);
},
child: const Text('Close'),
),
],
);
},
);
if (result == true) {
final container = ref.read(
selectedContainerProvider,
);
final count = await ref
.read(tabDataRepositoryProvider.notifier)
.closeAllTabsByContainer(container);
if (context.mounted) {
ui_helper.showTabUndoClose(
context,
ref
.read(tabRepositoryProvider.notifier)
.undoClose,
count: count,
);
}
}
},
),
],
child: IconButton(
onPressed: () {
if (tabsActionMenuController.isOpen) {
tabsActionMenuController.close();
} else {
tabsActionMenuController.open();
}
},
icon: const Icon(MdiIcons.dotsVertical),
),
),
],
)
else
TextField(
controller: searchTextController,
focusNode: searchTextFocus,
// enableIMEPersonalizedLearning: !incognitoEnabled,
decoration: InputDecoration(
// border: InputBorder.none,
prefixIcon: const Icon(MdiIcons.tabSearch, size: 18),
hintText: 'Search inside tabs...',
floatingLabelBehavior: FloatingLabelBehavior.always,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!hasSearchText)
SpeechToTextButton(
onTextReceived: (data) {
searchTextController.text = data.toString();
},
),
IconButton(
onPressed: () {
searchTextController.clear();
searchTextFocus.requestFocus();
searchMode.value = false;
},
icon: const Icon(Icons.clear),
),
],
),
),
),
if (tabsViewMode == TabsViewMode.grid)
Consumer(
builder: (context, ref, child) {
final selectedContainer = ref.watch(
selectedContainerDataProvider.select(
(value) => value.value,
),
);
return ContainerChips(
showGroupSuggestions: true,
selectedContainer: selectedContainer,
onSelected: (container) async {
if (container != null) {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (context.mounted &&
result == SetContainerResult.successHasProxy) {
await ref
.read(startProxyControllerProvider.notifier)
.maybeStartProxy(context);
}
} else {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
}
},
onDeleted: (container) {
ref
.read(selectedContainerProvider.notifier)
.clearContainer();
},
onLongPress: (container) async {
await ContainerEditRoute(
containerData: jsonEncode(container.toJson()),
).push(context);
},
);
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
}
class _TabView extends HookConsumerWidget {
final ScrollController scrollController;
final bool tabsReorderable;
final VoidCallback onClose;
const _TabView({
required this.scrollController,
required this.tabsReorderable,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenWidth = MediaQuery.of(context).size.width;
final containerId = ref.watch(selectedContainerProvider);
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterById(containerId: containerId),
groupTrees: false,
),
);
final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider);
final suggestedTabEntities = tabSuggestionsEnabled
? ref.watch(suggestedTabEntitiesProvider(containerId))
: EquatableValue(<TabEntity>[]);
final itemCount =
filteredTabEntities.value.length +
//Limit to 3 sugegstions for now
math.min<int>(suggestedTabEntities.value.length, 3);
final activeTab = ref.watch(selectedTabProvider);
final crossAxisCount = useMemoized(() {
final calculatedCount = calculateCrossAxisItemCount(
screenWidth: screenWidth,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(math.min(calculatedCount, itemCount), 2);
}, [screenWidth, itemCount]);
final itemSize = useMemoized(
() => calculateItemSize(
screenWidth: screenWidth,
childAspectRatio: 0.75,
horizontalPadding: 4.0,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
[screenWidth, crossAxisCount],
);
final lastScroll = useRef<String?>(null);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
if (lastScroll.value != activeTab) {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = (index ~/ 2) * itemSize.height;
if (offset != scrollController.offset) {
lastScroll.value = activeTab;
unawaited(
scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
}
}
});
return null;
}, [filteredTabEntities, activeTab]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: FadingScroll(
fadingSize: 5,
controller: scrollController,
builder: (context, controller) {
return !tabsReorderable
? _TabGrid(
key: ValueKey(crossAxisCount),
crossAxisCount: crossAxisCount,
itemCount: itemCount,
scrollController: controller,
itemBuilder: (widget, _) {
if (widget is CustomDraggable) {
return LongPressDraggable(
feedback: Material(
color: Colors
.transparent, // removes white corners when having shadow
child: Transform.scale(
scale: 1.05,
child: SizedBox(
height: itemSize.height,
width: itemSize.width,
child: widget.child,
),
),
),
data: widget.data,
child: widget.child,
);
}
return widget;
},
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
)
: ReorderableBuilder.builder(
//Rebuild when cross axis count changes
key: ValueKey(crossAxisCount),
scrollController: controller,
itemCount: itemCount,
onDragStarted: (index) {
ref.read(willAcceptDropProvider.notifier).clear();
},
onReorderPositions: (positions) async {
assert(
positions.length == 1,
'Not ready for multiple reorders',
);
final oldIndex = positions.first.oldIndex;
final newIndex = positions.first.newIndex;
final containerRepository = ref.read(
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
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabId);
final String key;
if (newIndex <= 0) {
key = await containerRepository.getLeadingOrderKey(
containerId,
);
} else if (newIndex >=
filteredTabEntities.value.length - 1) {
key = await containerRepository.getTrailingOrderKey(
containerId,
);
} else {
if (newIndex < oldIndex) {
key = (await containerRepository.getOrderKeyAfterTab(
filteredTabEntities.value[newIndex - 1].tabId,
containerId,
))!;
} else {
key = await containerRepository.getOrderKeyBeforeTab(
filteredTabEntities.value[newIndex + 1].tabId,
containerId,
);
}
}
await ref
.read(tabDataRepositoryProvider.notifier)
.assignOrderKey(tabId, key);
},
childBuilder: (itemBuilder) {
return _TabGrid(
crossAxisCount: crossAxisCount,
itemCount: itemCount,
scrollController: controller,
itemBuilder: itemBuilder,
suggestedContainerId: containerId,
filteredTabEntities: filteredTabEntities,
suggestedTabEntities: suggestedTabEntities,
onClose: onClose,
);
},
);
},
),
);
}
}
class _TabGrid extends StatelessWidget {
const _TabGrid({
super.key,
required this.crossAxisCount,
required this.scrollController,
required this.itemCount,
required this.itemBuilder,
required this.suggestedContainerId,
required this.filteredTabEntities,
required this.suggestedTabEntities,
required this.onClose,
});
final int crossAxisCount;
final int itemCount;
final ScrollController? scrollController;
final String? suggestedContainerId;
final EquatableValue<List<TabEntity>> filteredTabEntities;
final EquatableValue<List<TabEntity>> suggestedTabEntities;
final Widget Function(Widget, int)? itemBuilder;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return GridView.builder(
controller: scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: itemCount,
itemBuilder: (context, index) {
final Widget tab;
if (index < filteredTabEntities.value.length) {
final entity = filteredTabEntities.value[index];
tab = CustomDraggable(
key: Key(entity.tabId),
data: TabDragData(entity.tabId),
child: _TabDraggable(entity: entity, onClose: onClose),
);
} else {
final suggestedIndex = index - filteredTabEntities.value.length;
final entity = suggestedTabEntities.value[suggestedIndex];
tab = CustomDraggable(
key: Key('suggested_${entity.tabId}'),
child: _TabDraggable(
entity: entity,
onClose: onClose,
suggestedContainerId: suggestedContainerId,
),
);
}
return (itemBuilder != null) ? itemBuilder!(tab, index) : tab;
},
);
}
}
class ViewTabsWidget extends HookConsumerWidget {
final ScrollController scrollController;
final DraggableScrollableController? draggableScrollableController;
final bool showNewTabFab;
final bool tabsReorderable;
final VoidCallback onClose;
const ViewTabsWidget({
required this.onClose,
required this.scrollController,
this.draggableScrollableController,
required this.tabsReorderable,
required this.showNewTabFab,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
alignment: Alignment.bottomRight,
children: [
NestedScrollView(
physics: const NeverScrollableScrollPhysics(),
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverToBoxAdapter(
child:
draggableScrollableController.mapNotNull(
(draggableScrollableController) =>
DraggableScrollableHeader(
controller: draggableScrollableController,
child: _TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.grid,
),
),
) ??
_TabViewHeader(
onClose: onClose,
tabsViewMode: TabsViewMode.grid,
),
),
],
body: _TabView(
scrollController: scrollController,
tabsReorderable: tabsReorderable,
onClose: onClose,
),
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: _TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
}
}
class ViewTabTreesWidget extends HookConsumerWidget {
final ScrollController scrollController;
final VoidCallback onClose;
final bool showNewTabFab;
const ViewTabTreesWidget({
required this.onClose,
required this.scrollController,
required this.showNewTabFab,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
alignment: Alignment.bottomRight,
children: [
Column(
children: [
_TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree),
Expanded(
child: HookConsumer(
builder: (context, ref, child) {
final screenWidth = MediaQuery.of(context).size.width;
final filteredTabEntities = ref.watch(
seamlessFilteredTabEntitiesProvider(
searchPartition: TabSearchPartition.preview,
// ignore: document_ignores using fast equatable
// ignore: provider_parameters
containerFilter: ContainerFilterDisabled(),
groupTrees: true,
),
);
final activeTab = ref.watch(selectedTabProvider);
final crossAxisCount = useMemoized(() {
final calculatedCount = calculateCrossAxisItemCount(
screenWidth: screenWidth,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
);
return math.max(
math.min(
calculatedCount,
filteredTabEntities.value.length,
),
2,
);
}, [screenWidth, filteredTabEntities.value.length]);
final itemSize = useMemoized(
() => calculateItemSize(
screenWidth: screenWidth,
childAspectRatio: 0.75,
horizontalPadding: 4.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
mainAxisSpacing: 8.0,
),
[screenWidth, crossAxisCount],
);
useEffect(() {
final index = filteredTabEntities.value.indexWhere(
(entity) => entity.tabId == activeTab,
);
if (index > -1) {
final offset = (index ~/ 2) * itemSize.height;
if (offset != scrollController.offset) {
unawaited(
scrollController.animateTo(
offset,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
),
);
}
}
return null;
}, [filteredTabEntities, activeTab]);
final tabs = useMemoized(() {
return filteredTabEntities.value
.whereType<TabTreeEntity>()
.map((entity) {
return TabTreePreview(
entity: entity,
activeTabId: activeTab,
onClose: onClose,
stackPadding: const Offset(8, 8),
);
})
.toList();
}, [filteredTabEntities, activeTab]);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: GridView.builder(
controller: scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//Sync values for itemHeight calculation _calculateItemHeight
childAspectRatio: 0.75,
mainAxisSpacing: 8.0,
crossAxisSpacing: 8.0,
crossAxisCount: crossAxisCount,
),
itemCount: tabs.length,
itemBuilder: (context, index) => tabs[index],
),
);
},
),
),
],
),
if (showNewTabFab)
Padding(
padding: const EdgeInsets.only(
top: _TabViewHeader.headerSize + 4,
right: 4,
),
child: FloatingActionButton.small(
onPressed: () async {
final settings = ref.read(generalSettingsWithDefaultsProvider);
await SearchRoute(
tabType:
ref.read(selectedTabTypeProvider) ??
settings.defaultCreateTabType,
).push(context);
onClose();
},
child: const Icon(Icons.add),
),
),
],
);
}
}
@@ -0,0 +1,33 @@
import 'dart:ui';
int calculateCrossAxisItemCount({
required double screenWidth,
required double horizontalPadding,
required double crossAxisSpacing,
}) {
final totalHorizontalPadding = horizontalPadding * 2;
final availableWidth =
screenWidth - totalHorizontalPadding - crossAxisSpacing;
final crossAxisCount = availableWidth ~/ 180.0;
return crossAxisCount;
}
Size calculateItemSize({
required double screenWidth,
required double childAspectRatio,
required double horizontalPadding,
required double mainAxisSpacing,
required double crossAxisSpacing,
required int crossAxisCount,
}) {
final totalHorizontalPadding = horizontalPadding * 2;
final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1);
final availableWidth =
screenWidth - totalHorizontalPadding - totalCrossAxisSpacing;
final itemWidth = availableWidth / crossAxisCount;
final itemHeight = itemWidth / childAspectRatio;
return Size(itemWidth, itemHeight + mainAxisSpacing);
}
@@ -17,11 +17,10 @@
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/json_persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
@@ -30,6 +29,7 @@ import 'package:weblibre/features/user/data/providers.dart';
part 'providers.g.dart';
@Riverpod(keepAlive: true)
@JsonPersist()
class HistoryFilter extends _$HistoryFilter {
void updateVisitType(VisitType type, bool value) {
if (value) {
@@ -49,15 +49,9 @@ class HistoryFilter extends _$HistoryFilter {
@override
HistoryFilterOptions build() {
final riverpodStorage = ref.watch(riverpodDatabaseStorageProvider);
persist(
riverpodStorage,
ref.watch(riverpodDatabaseStorageProvider),
key: 'HistoryFilterOptions',
decode: (encoded) => HistoryFilterOptions.fromJson(
jsonDecode(encoded) as Map<String, dynamic>,
),
encode: (state) => jsonEncode(state.toJson()),
);
return stateOrNull ?? HistoryFilterOptions.withDefaults();
@@ -10,8 +10,10 @@ part of 'providers.dart';
// ignore_for_file: type=lint, type=warning
@ProviderFor(HistoryFilter)
@JsonPersist()
const historyFilterProvider = HistoryFilterProvider._();
@JsonPersist()
final class HistoryFilterProvider
extends $NotifierProvider<HistoryFilter, HistoryFilterOptions> {
const HistoryFilterProvider._()
@@ -41,9 +43,10 @@ final class HistoryFilterProvider
}
}
String _$historyFilterHash() => r'1f8269e5f1227d6efde194e00c3d79a268ab1e4d';
String _$historyFilterHash() => r'685b92590011ec450ef0c19820e4a6750d26b79d';
abstract class _$HistoryFilter extends $Notifier<HistoryFilterOptions> {
@JsonPersist()
abstract class _$HistoryFilterBase extends $Notifier<HistoryFilterOptions> {
HistoryFilterOptions build();
@$mustCallSuper
@override
@@ -100,3 +103,40 @@ final class BrowsingHistoryProvider
}
String _$browsingHistoryHash() => r'3b6ee5853387481d7544bc9733741a5519f84f40';
// **************************************************************************
// JsonGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
abstract class _$HistoryFilter extends _$HistoryFilterBase {
/// The default key used by [persist].
String get key {
const resolvedKey = "HistoryFilter";
return resolvedKey;
}
/// A variant of [persist], for JSON-specific encoding.
///
/// You can override [key] to customize the key used for storage.
PersistResult persist(
FutureOr<Storage<String, String>> storage, {
String? key,
String Function(HistoryFilterOptions state)? encode,
HistoryFilterOptions Function(String encoded)? decode,
StorageOptions options = const StorageOptions(),
}) {
return NotifierPersistX(this).persist<String, String>(
storage,
key: key ?? this.key,
encode: encode ?? $jsonCodex.encode,
decode:
decode ??
(encoded) {
final e = $jsonCodex.decode(encoded);
return HistoryFilterOptions.fromJson(e as Map<String, Object?>);
},
options: options,
);
}
}
@@ -65,6 +65,18 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
return query.map((row) => row.read(db.tab.containerId));
}
Selectable<MapEntry<String, String?>> getTabsContainerId(
Iterable<String> tabIds,
) {
final query = selectOnly(db.tab)
..addColumns([db.tab.id, db.tab.containerId])
..where(db.tab.id.isIn(tabIds));
return query.map(
(row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.containerId)),
);
}
Future<String> _generateOrderKey({
required Value<String?> parentId,
required Value<String?> containerId,
@@ -140,6 +140,7 @@ queryTabsBasic WITH TabQueryResult:
)
SELECT
t.id,
t.container_id,
t.title,
CAST(t.url AS TEXT) AS url,
t.url AS clean_url,
@@ -166,6 +167,7 @@ queryTabsFullContent WITH TabQueryResult:
)
SELECT
t.id,
t.container_id,
highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title,
highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url,
snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content,
@@ -2161,12 +2161,13 @@ class DefinitionsDrift extends i7.ModularAccessor {
i0.Selectable<i8.TabQueryResult> queryTabsBasic({required String query}) {
return customSelect(
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, 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.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',
variables: [i0.Variable<String>(query)],
readsFrom: {tab, tabFts},
).map(
(i0.QueryRow row) => i8.TabQueryResult(
id: row.read<String>('id'),
containerId: row.readNullable<String>('container_id'),
title: row.readNullable<String>('title'),
url: row.readNullable<String>('url'),
cleanUrl: i3.Tab.$converterurl.fromSql(
@@ -2185,7 +2186,7 @@ class DefinitionsDrift extends i7.ModularAccessor {
required String query,
}) {
return customSelect(
'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, 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, 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',
variables: [
i0.Variable<String>(beforeMatch),
i0.Variable<String>(afterMatch),
@@ -2197,6 +2198,7 @@ class DefinitionsDrift extends i7.ModularAccessor {
).map(
(i0.QueryRow row) => i8.TabQueryResult(
id: row.read<String>('id'),
containerId: row.readNullable<String>('container_id'),
title: row.readNullable<String>('title'),
url: row.readNullable<String>('url'),
cleanUrl: i3.Tab.$converterurl.fromSql(
@@ -26,23 +26,29 @@ sealed class TabEntity with FastEquatable {
class DefaultTabEntity extends TabEntity {
@override
final String tabId;
final String? containerId;
DefaultTabEntity({required this.tabId});
DefaultTabEntity({required this.tabId, required this.containerId});
@override
List<Object?> get hashParameters => [tabId];
List<Object?> get hashParameters => [tabId, containerId];
}
class SearchResultTabEntity extends TabEntity {
@override
final String tabId;
final String? containerId;
final String searchQuery;
SearchResultTabEntity({required this.tabId, required this.searchQuery});
SearchResultTabEntity({
required this.tabId,
required this.searchQuery,
required this.containerId,
});
@override
List<Object?> get hashParameters => [tabId, searchQuery];
List<Object?> get hashParameters => [tabId, searchQuery, containerId];
}
class TabTreeEntity extends TabEntity {
@@ -21,6 +21,7 @@ import 'package:fast_equatable/fast_equatable.dart';
class TabQueryResult with FastEquatable {
final String id;
final String? containerId;
final String? title;
final Uri? cleanUrl;
@@ -33,6 +34,7 @@ class TabQueryResult with FastEquatable {
TabQueryResult({
required this.id,
required this.containerId,
required this.title,
required this.url,
required this.cleanUrl,
@@ -44,6 +46,7 @@ class TabQueryResult with FastEquatable {
@override
List<Object?> get hashParameters => [
id,
containerId,
title,
cleanUrl,
url,
@@ -17,6 +17,7 @@
* 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:fast_equatable/fast_equatable.dart';
import 'package:nullability/nullability.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -129,6 +130,19 @@ Stream<String?> watchContainerTabId(Ref ref, String tabId) {
.watchSingleOrNull();
}
@Riverpod()
Stream<Map<String, String?>> watchTabsContainerId(
Ref ref,
EquatableValue<List<String>> tabIds,
) {
return ref
.read(tabDatabaseProvider)
.tabDao
.getTabsContainerId(tabIds.value)
.watch()
.map(Map.fromEntries);
}
@Riverpod()
Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
return ref.read(tabDatabaseProvider).containerDao.allAssignedSites().watch();
@@ -671,6 +671,88 @@ final class WatchContainerTabIdFamily extends $Family
String toString() => r'watchContainerTabIdProvider';
}
@ProviderFor(watchTabsContainerId)
const watchTabsContainerIdProvider = WatchTabsContainerIdFamily._();
final class WatchTabsContainerIdProvider
extends
$FunctionalProvider<
AsyncValue<Map<String, String?>>,
Map<String, String?>,
Stream<Map<String, String?>>
>
with
$FutureModifier<Map<String, String?>>,
$StreamProvider<Map<String, String?>> {
const WatchTabsContainerIdProvider._({
required WatchTabsContainerIdFamily super.from,
required EquatableValue<List<String>> super.argument,
}) : super(
retry: null,
name: r'watchTabsContainerIdProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$watchTabsContainerIdHash();
@override
String toString() {
return r'watchTabsContainerIdProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<Map<String, String?>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<Map<String, String?>> create(Ref ref) {
final argument = this.argument as EquatableValue<List<String>>;
return watchTabsContainerId(ref, argument);
}
@override
bool operator ==(Object other) {
return other is WatchTabsContainerIdProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$watchTabsContainerIdHash() =>
r'ffdebb10f7118cdff93695c821ef5fbb0d434de1';
final class WatchTabsContainerIdFamily extends $Family
with
$FunctionalFamilyOverride<
Stream<Map<String, String?>>,
EquatableValue<List<String>>
> {
const WatchTabsContainerIdFamily._()
: super(
retry: null,
name: r'watchTabsContainerIdProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
WatchTabsContainerIdProvider call(EquatableValue<List<String>> tabIds) =>
WatchTabsContainerIdProvider._(argument: tabIds, from: this);
@override
String toString() => r'watchTabsContainerIdProvider';
}
@ProviderFor(watchAllAssignedSites)
const watchAllAssignedSitesProvider = WatchAllAssignedSitesProvider._();
@@ -125,7 +125,7 @@ class SelectedContainer extends _$SelectedContainer {
if (next != null) {
final tabContainerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(next);
.getTabContainerId(next);
if (ref.mounted && tabContainerId != stateOrNull) {
if (tabContainerId != null) {
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
}
}
String _$selectedContainerHash() => r'fd41c7d7ff0cfaff2829fd93e2afd41d883b8317';
String _$selectedContainerHash() => r'35a8fc409e563758156a197bb8b114387d31b389';
abstract class _$SelectedContainer extends $Notifier<String?> {
String? build();
@@ -34,7 +34,7 @@ class TabDataRepository extends _$TabDataRepository {
ContainerData targetContainer, {
bool closeOldTab = true,
}) async {
final currentContainerId = await getContainerTabId(tabId);
final currentContainerId = await getTabContainerId(tabId);
final currentContainerData = await currentContainerId.mapNotNull(
(containerId) => ref
@@ -118,7 +118,7 @@ class TabDataRepository extends _$TabDataRepository {
.get();
}
Future<String?> getContainerTabId(String tabId) {
Future<String?> getTabContainerId(String tabId) {
return ref
.read(tabDatabaseProvider)
.tabDao
@@ -126,6 +126,15 @@ class TabDataRepository extends _$TabDataRepository {
.getSingleOrNull();
}
Future<Map<String, String?>> getTabsContainerId(Iterable<String> tabIds) {
return ref
.read(tabDatabaseProvider)
.tabDao
.getTabsContainerId(tabIds)
.get()
.then(Map.fromEntries);
}
Future<Map<String, String?>> getTabDescendants(String tabId) async {
final results = await ref
.read(tabDatabaseProvider)
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'e12750bc1962878a5bf5140d97521d642001994f';
String _$tabDataRepositoryHash() => r'3d9149efb88f256cc826eb538f13331b49438a7c';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -30,8 +30,8 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/core/uuid.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart';
import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
@@ -147,7 +147,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
return (selectedContainerTabs.value[equatable]
?.contains(tabId) ==
true)
? TabPreview(
? GridTabPreview(
tabId: tabId,
isActive: false,
onDelete: () {
@@ -160,7 +160,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget {
};
},
)
: SuggestedSingleTabPreview(
: SuggestedSingleGridTabPreview(
tabId: tabId,
activeTabId: null,
onTap: () {
@@ -87,7 +87,7 @@ Future<WebPageInfo> pageInfo(
if (tabState?.id != null) {
final containerId = await ref
.read(tabDataRepositoryProvider.notifier)
.getContainerTabId(tabState!.id);
.getTabContainerId(tabState!.id);
final containerData = await containerId.mapNotNull(
(containerId) => ref
@@ -163,7 +163,7 @@ final class PageInfoProvider
}
}
String _$pageInfoHash() => r'87d23da9b25c3557e7270d90c2dee6f37eaaf8e0';
String _$pageInfoHash() => r'b756ee75e155bcee1e54658556eb8f4ef417d118';
final class PageInfoFamily extends $Family
with