diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index e9907d95..d9a14050 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -33,6 +33,10 @@ part of 'routes.dart'; name: 'ContextMenuRoute', path: 'context_menu', ), + TypedGoRoute( + name: 'ContainerDraftRoute', + path: 'container_draft', + ), TypedGoRoute( name: 'ContainerListRoute', path: 'containers', @@ -107,6 +111,13 @@ class TorProxyRoute extends GoRouteData with $TorProxyRoute { } } +class ContainerDraftRoute extends GoRouteData with $ContainerDraftRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const ContainerDraftSuggestionsScreen(); + } +} + class ContainerListRoute extends GoRouteData with $ContainerListRoute { @override Widget build(BuildContext context, GoRouterState state) { diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 234b5228..fd04d5e9 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -34,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/co import 'package:weblibre/features/geckoview/features/history/presentation/screens/history.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/screens/search.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_edit.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 17c5b360..bb8352ce 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -322,6 +322,11 @@ RouteBase get $browserRoute => GoRouteData.$route( name: 'ContextMenuRoute', factory: $ContextMenuRoute._fromState, ), + GoRouteData.$route( + path: 'container_draft', + name: 'ContainerDraftRoute', + factory: $ContainerDraftRoute._fromState, + ), GoRouteData.$route( path: 'containers', name: 'ContainerListRoute', @@ -487,6 +492,27 @@ mixin $ContextMenuRoute on GoRouteData { context.replace(location, extra: _self.$extra); } +mixin $ContainerDraftRoute on GoRouteData { + static ContainerDraftRoute _fromState(GoRouterState state) => + ContainerDraftRoute(); + + @override + String get location => GoRouteData.$location('/container_draft'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $ContainerListRoute on GoRouteData { static ContainerListRoute _fromState(GoRouterState state) => ContainerListRoute(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart index 30740dbb..93a7eb5c 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/gecko_inference.g.dart @@ -42,7 +42,7 @@ final class GeckoInferenceRepositoryProvider } String _$geckoInferenceRepositoryHash() => - r'eb174c9f80f3537bc075ebbffcef787ec55ad217'; + r'5f99fd1e46a8ae291b63df044fe11917cd5d415c'; abstract class _$GeckoInferenceRepository extends $Notifier { void build(); @@ -138,13 +138,13 @@ const suggestClustersProvider = SuggestClustersProvider._(); final class SuggestClustersProvider extends $FunctionalProvider< - AsyncValue tabIds, String? topic})>?>, - List<({List tabIds, String? topic})>?, - FutureOr tabIds, String? topic})>?> + AsyncValue?>, + List?, + FutureOr?> > with - $FutureModifier tabIds, String? topic})>?>, - $FutureProvider tabIds, String? topic})>?> { + $FutureModifier?>, + $FutureProvider?> { const SuggestClustersProvider._() : super( from: null, @@ -161,16 +161,17 @@ final class SuggestClustersProvider @$internal @override - $FutureProviderElement tabIds, String? topic})>?> - $createElement($ProviderPointer pointer) => $FutureProviderElement(pointer); + $FutureProviderElement?> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); @override - FutureOr tabIds, String? topic})>?> create(Ref ref) { + FutureOr?> create(Ref ref) { return suggestClusters(ref); } } -String _$suggestClustersHash() => r'592058ed092384c37a185dcd5657276a3dde5b4e'; +String _$suggestClustersHash() => r'3b7da171b3596f708a8039658578251ae61655ea'; @ProviderFor(containerTabSuggestions) const containerTabSuggestionsProvider = ContainerTabSuggestionsFamily._(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart new file mode 100644 index 00000000..f3073533 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart @@ -0,0 +1,176 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/core/uuid.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tabs.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.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'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; +import 'package:weblibre/presentation/widgets/selectable_chips.dart'; + +class ContainerDraftSuggestionsScreen extends HookConsumerWidget { + const ContainerDraftSuggestionsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final suggestionsAsync = ref.watch(suggestClustersProvider); + + final selectedContainer = useState(null); + final selectedContainerTabs = useState(>{}); + + final selectedTabs = selectedContainer.value != null + ? selectedContainerTabs.value[selectedContainer.value!] + : null; + + return Scaffold( + appBar: AppBar(title: const Text('Draft Containers')), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: suggestionsAsync.when( + data: (suggestions) { + final screenWidth = MediaQuery.of(context).size.width; + + final crossAxisCount = useMemoized(() { + final calculatedCount = calculateCrossAxisItemCount( + screenWidth: screenWidth, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max( + math.min( + calculatedCount, + selectedContainer.value?.tabIds.length ?? 0, + ), + 2, + ); + }, [screenWidth, selectedContainer.value?.tabIds.length]); + + return Column( + children: [ + SizedBox( + height: 48, + child: SelectableChips( + deleteIcon: false, + itemId: (container) => container, + itemAvatar: (container) => + const Icon(MdiIcons.creation, size: 20), + itemLabel: (container) => + Text(container.topic ?? 'Untitled'), + itemBadgeCount: (container) => container.tabIds.length, + availableItems: suggestions!, + selectedItem: selectedContainer.value, + onSelected: (item) { + selectedContainer.value = item; + }, + ), + ), + const Divider(), + const SizedBox(height: 4), + if (selectedContainer.value != null) + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: selectedContainer.value!.tabIds.length, + itemBuilder: (context, index) { + final tabId = selectedContainer.value!.tabIds[index]; + final equatable = selectedContainer.value!; + + return (selectedContainerTabs.value[equatable] + ?.contains(tabId) == + true) + ? TabPreview( + tabId: tabId, + isActive: false, + onDelete: () { + selectedContainerTabs.value = { + ...selectedContainerTabs.value, + equatable: { + ...?selectedContainerTabs + .value[equatable], + }..remove(tabId), + }; + }, + ) + : SuggestedSingleTabPreview( + tabId: tabId, + activeTabId: null, + onTap: () { + selectedContainerTabs.value = { + ...selectedContainerTabs.value, + equatable: { + ...?selectedContainerTabs + .value[equatable], + tabId, + }, + }; + }, + ); + }, + ), + ), + ], + ); + }, + error: (error, stackTrace) { + return FailureWidget( + title: 'Failed to create suggestions', + onRetry: () { + ref.invalidate(suggestClustersProvider); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + ), + ), + ), + floatingActionButton: (selectedTabs?.isNotEmpty ?? false) + ? FloatingActionButton( + child: const Icon(MdiIcons.folderPlus), + onPressed: () async { + final initialColor = await ref + .read(containerRepositoryProvider.notifier) + .unusedRandomContainerColor(); + + final initialContainer = ContainerData( + id: uuid.v7(), + color: initialColor, + name: selectedContainer.value?.topic, + ); + + if (context.mounted) { + final result = await ContainerCreateRoute( + initialContainer, + ).push(context); + + if (result != null) { + for (final tab in selectedTabs!) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(tab, result); + } + + selectedContainerTabs.value = {}; + selectedContainer.value = null; + } + } + }, + ) + : null, + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart index 0da6bd09..a9766d6f 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart @@ -23,6 +23,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart'; @@ -85,126 +86,119 @@ class ContainerChips extends HookConsumerWidget { height: 48, child: Row( children: [ - if (selectedContainer != null || availableContainers.isNotEmpty) - Expanded( - child: SelectableChips( - deleteIcon: false, - itemId: (container) => container.id, - itemAvatar: (container) => Container( - width: 20.0, - height: 20.0, - decoration: BoxDecoration( - color: container.color, - shape: BoxShape.circle, + Expanded( + child: SelectableChips( + deleteIcon: false, + itemId: (container) => container.id, + itemAvatar: (container) => Container( + width: 20.0, + height: 20.0, + decoration: BoxDecoration( + color: container.color, + shape: BoxShape.circle, + ), + ), + itemLabel: (container) => + ContainerTitle(container: container), + itemBadgeCount: (container) => container.tabCount, + itemWrap: (child, container) { + return TabDragContainerTarget( + container: container, + child: child, + ); + }, + prefixListItems: [ + TabDragContainerTarget( + container: null, + child: Consumer( + builder: (context, ref, child) { + final tabCount = ref.watch( + containerTabCountProvider( + // ignore: provider_parameters + ContainerFilterById(containerId: null), + ).select((value) => value.value ?? 0), + ); + + return FilterChip( + avatar: const Icon(MdiIcons.folderHidden), + labelPadding: (tabCount > 0) + ? null + : const EdgeInsets.only(right: 2.0), + label: (tabCount > 0) + ? Text(tabCount.toString()) + : const SizedBox.shrink(), + selected: selectedContainer == null, + showCheckmark: false, + onSelected: (value) { + if (value) { + onSelected?.call(null); + } + }, + ); + }, ), ), - itemLabel: (container) => - ContainerTitle(container: container), - itemBadgeCount: (container) => container.tabCount, - itemWrap: (child, container) { - return TabDragContainerTarget( - container: container, - child: child, - ); - }, - prefixListItems: [ - TabDragContainerTarget( - container: null, - child: Consumer( - builder: (context, ref, child) { - final tabCount = ref.watch( - containerTabCountProvider( - // ignore: provider_parameters - ContainerFilterById(containerId: null), - ).select((value) => value.value ?? 0), - ); + if (showGroupSuggestions) + Consumer( + builder: (context, ref, child) { + final tabSuggestionsEnabled = ref.watch( + tabSuggestionsControllerProvider, + ); + final enableAiFeatures = ref.watch( + generalSettingsWithDefaultsProvider.select( + (settings) => settings.enableLocalAiFeatures, + ), + ); - return FilterChip( - avatar: const Icon(MdiIcons.folderHidden), - labelPadding: (tabCount > 0) - ? null - : const EdgeInsets.only(right: 2.0), - label: (tabCount > 0) - ? Text(tabCount.toString()) - : const SizedBox.shrink(), - selected: selectedContainer == null, - showCheckmark: false, - onSelected: (value) { - if (value) { - onSelected?.call(null); - } - }, - ); - }, - ), - ), - if (showGroupSuggestions) - Consumer( - builder: (context, ref, child) { - final tabSuggestionsEnabled = ref.watch( - tabSuggestionsControllerProvider, - ); - final enableAiFeatures = ref.watch( - generalSettingsWithDefaultsProvider.select( - (settings) => settings.enableLocalAiFeatures, - ), - ); + if (!enableAiFeatures || !tabSuggestionsEnabled) { + return const SizedBox.shrink(); + } - if (!enableAiFeatures || !tabSuggestionsEnabled) { + final suggestions = ref.watch( + suggestClustersProvider, + ); + + return suggestions.when( + data: (data) { + if (data.isEmpty) { + return const SizedBox.shrink(); + } + + return FilterChip( + avatar: const Icon(MdiIcons.autoFix), + label: Text(data!.length.toString()), + showCheckmark: false, + onSelected: (_) async { + await ContainerDraftRoute().push(context); + }, + ); + }, + error: (error, stackTrace) { + logger.e( + 'Error suggesting containers', + error: error, + stackTrace: stackTrace, + ); return const SizedBox.shrink(); - } - - final suggestions = ref.watch( - suggestClustersProvider, - ); - - return suggestions.when( - data: (data) { - if (data.isEmpty) { - return const SizedBox.shrink(); - } - - return FilterChip( - avatar: const Icon(MdiIcons.autoFix), - label: Text(data!.length.toString()), - showCheckmark: false, - onSelected: (value) {}, - ); - }, - error: (error, stackTrace) { - logger.e( - 'Error suggesting containers', - error: error, - stackTrace: stackTrace, - ); - return const SizedBox.shrink(); - }, - loading: () { - return const SizedBox.shrink(); - }, - ); - }, - ), - ], - availableItems: availableContainers, - selectedItem: selectedContainer, - onSelected: onSelected, - onDeleted: onDeleted, - ), - ) - else - Visibility( - visible: displayMenu, - child: Expanded( - child: Text( - "Press '>' to manage Containers.", - style: TextStyle( - color: Theme.of(context).hintColor, - fontStyle: FontStyle.italic, + }, + loading: () { + return const FilterChip( + avatar: Icon(MdiIcons.autoFix), + label: Skeletonizer(child: Text('0')), + showCheckmark: false, + onSelected: null, + ); + }, + ); + }, ), - ), - ), + ], + availableItems: availableContainers, + selectedItem: selectedContainer, + onSelected: onSelected, + onDeleted: onDeleted, ), + ), if (displayMenu) IconButton( visualDensity: VisualDensity.compact,