diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index 8bb1ffe4..04a2da4b 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -152,7 +152,7 @@ class ContainerEditRoute extends GoRouteData with $ContainerEditRoute { @override Widget build(BuildContext context, GoRouterState state) { return ContainerEditScreen.edit( - initialContainer: ContainerData.fromJson( + initialContainer: ContainerDataWithCount.fromJson( jsonDecode(containerData) as Map, ), ); @@ -161,15 +161,20 @@ class ContainerEditRoute extends GoRouteData with $ContainerEditRoute { class ContainerCreateRoute extends GoRouteData with $ContainerCreateRoute { final String containerData; + final String tabIds; - ContainerCreateRoute({required this.containerData}); + ContainerCreateRoute({required this.containerData, this.tabIds = '[]'}); @override Widget build(BuildContext context, GoRouterState state) { + final tabIdsList = jsonDecode(tabIds) as List; + final tabIdsSet = tabIdsList.cast().toSet(); + return ContainerEditScreen.create( initialContainer: ContainerData.fromJson( jsonDecode(containerData) as Map, ), + tabIds: tabIdsSet.isNotEmpty ? tabIdsSet : null, ); } } diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 4e425d79..76f5ad17 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -758,6 +758,7 @@ mixin $ContainerCreateRoute on GoRouteData { static ContainerCreateRoute _fromState(GoRouterState state) => ContainerCreateRoute( containerData: state.pathParameters['containerData']!, + tabIds: state.uri.queryParameters['tab-ids'] ?? '[]', ); ContainerCreateRoute get _self => this as ContainerCreateRoute; @@ -765,6 +766,7 @@ mixin $ContainerCreateRoute on GoRouteData { @override String get location => GoRouteData.$location( '/browser/containers/create/${Uri.encodeComponent(_self.containerData)}', + queryParams: {if (_self.tabIds != '[]') 'tab-ids': _self.tabIds}, ); @override diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart new file mode 100644 index 00000000..6d7b758f --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_drop_target.dart @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.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/tab.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +/// A widget that wraps a tab and enables creating a new container +/// when another tab is dropped onto it. +/// +/// When a tab is dragged and dropped onto this widget: +/// 1. Both tab titles are collected +/// 2. An AI-suggested container name is generated +/// 3. User is redirected to container creation screen to confirm/modify settings +/// 4. Both tabs are assigned to the new container if user confirms +class TabDropTarget extends HookConsumerWidget { + /// The tab entity that serves as the drop target + final String targetTabId; + + /// The widget to display (typically the tab preview) + final Widget child; + + /// Whether drag-and-drop to create containers is enabled + final bool enabled; + + const TabDropTarget({ + required this.targetTabId, + required this.child, + this.enabled = true, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (!enabled) { + return child; + } + + return DragTarget( + onWillAcceptWithDetails: (details) { + // Only accept if dragging a different tab + return details.data.tabId != targetTabId; + }, + onAcceptWithDetails: (details) async { + final draggedTabId = details.data.tabId; + + final containerRepo = ref.read(containerRepositoryProvider.notifier); + final newContainer = await containerRepo.createNewContainer(); + + if (!context.mounted) return; + + final result = await ContainerCreateRoute( + containerData: jsonEncode(newContainer.toJson()), + tabIds: jsonEncode([draggedTabId, targetTabId]), + ).push(context); + + if (result != null) { + final tabRepo = ref.read(tabDataRepositoryProvider.notifier); + await tabRepo.assignContainer(draggedTabId, result); + await tabRepo.assignContainer(targetTabId, result); + + if (context.mounted) { + showInfoMessage( + context, + 'Created container "${result.name ?? 'New Container'}"', + ); + } + } + }, + builder: (context, candidateData, rejectedData) { + if (candidateData.isEmpty) return child; + + return Container( + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart index 75f55259..8ae05f4c 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -36,6 +36,7 @@ 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_drop_target.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'; @@ -388,26 +389,33 @@ class _TabGrid extends StatelessWidget { ), itemCount: itemCount, itemBuilder: (context, index) { - final Widget tab; + final TabEntity entity; + final String? suggestedId; + 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), - ); + entity = filteredTabEntities.value[index]; + suggestedId = null; } else { final suggestedIndex = index - filteredTabEntities.value.length; - final entity = suggestedTabEntities.value[suggestedIndex]; + entity = suggestedTabEntities.value[suggestedIndex]; + suggestedId = suggestedContainerId; + } - tab = CustomDraggable( - key: Key('suggested_${entity.tabId}'), - child: _TabDraggable( - entity: entity, - onClose: onClose, - suggestedContainerId: suggestedContainerId, - ), - ); + final tab = CustomDraggable( + key: Key( + suggestedId != null ? 'suggested_${entity.tabId}' : entity.tabId, + ), + data: suggestedId != null ? null : TabDragData(entity.tabId), + child: _TabDraggable( + entity: entity, + onClose: onClose, + suggestedContainerId: suggestedId, + ), + ); + + // Only add DragTarget for non-suggested tabs in non-reorder mode + if (suggestedId == null && itemBuilder == null) { + return TabDropTarget(targetTabId: entity.tabId, child: tab); } return (itemBuilder != null) ? itemBuilder!(tab, index) : tab; diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart index b0d96384..5ca974d8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -35,6 +35,7 @@ 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_drop_target.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'; @@ -236,51 +237,56 @@ class _TabListView extends HookConsumerWidget { itemCount: itemCount, itemExtent: _itemHeight, itemBuilder: (context, index) { - final CustomDraggable tab; + final TabEntity entity; + final String? suggestedId; 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, - ), - ); + entity = filteredTabEntities.value[index]; + suggestedId = null; } 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, - ), - ); + entity = suggestedTabEntities.value[suggestedIndex]; + suggestedId = containerId; } - 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, + final tab = CustomDraggable( + key: Key( + suggestedId != null + ? 'suggested_${entity.tabId}' + : entity.tabId, + ), + data: suggestedId != null + ? null + : TabDragData(entity.tabId), + child: _TabDraggable( + entity: entity, + onClose: onClose, + suggestedContainerId: suggestedId, + height: _itemHeight, + ), + ); + + return TabDropTarget( + targetTabId: entity.tabId, + enabled: suggestedId == null, + child: 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, ), - data: tab.data, - childWhenDragging: const SizedBox(height: _itemHeight), - child: tab.child, ); }, ) diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart index 120a2fe4..1515c7b4 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.dart @@ -18,19 +18,51 @@ * along with this program. If not, see . */ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; part 'container_topic.g.dart'; @Riverpod() class ContainerTopicController extends _$ContainerTopicController { Future predictDocumentTopic(String containerId) async { + final tabData = await ref + .read(tabDataRepositoryProvider.notifier) + .getContainerTabsData(containerId); + + if (tabData.isEmpty) return null; + + final titles = tabData.map((t) => t.title).nonNulls.toSet(); + return _predictFromTitles(titles); + } + + Future predictTopicFromTabIds(Set tabIds) async { + final tabStates = ref.read(tabStatesProvider); + final titles = tabIds + .map((tabId) => tabStates[tabId]?.title) + .nonNulls + .toSet(); + + return _predictFromTitles(titles); + } + + Future _predictFromTitles(Set titles) async { + if (titles.isEmpty) return null; + state = const AsyncLoading(); final result = await AsyncValue.guard(() async { - return await ref.read(containerTopicProvider(containerId).future); + final topicResult = await ref + .read(geckoInferenceRepositoryProvider.notifier) + .predictDocumentTopic(titles); + + return topicResult.fold((topic) => topic, onFailure: (_) => null); }); - state = result; + + if (ref.mounted) { + state = result; + } return result.value; } diff --git a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart index b5c2540d..298dad9a 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/controllers/container_topic.g.dart @@ -42,7 +42,7 @@ final class ContainerTopicControllerProvider } String _$containerTopicControllerHash() => - r'e89fbdaaa9106f163ff00a829eb40752dd4af63a'; + r'0b54e0be899e2abb4defbd5614ba231917ad74e5'; abstract class _$ContainerTopicController extends $Notifier> { AsyncValue build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart index d01bde9b..f7e501ac 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_edit.dart @@ -40,22 +40,28 @@ class ContainerEditScreen extends HookConsumerWidget { final _DialogMode _mode; final ContainerData initialContainer; + final Set? tabIds; const ContainerEditScreen._({ required _DialogMode mode, required this.initialContainer, + this.tabIds, }) : _mode = mode; factory ContainerEditScreen.create({ required ContainerData initialContainer, + Set? tabIds, }) { return ContainerEditScreen._( mode: _DialogMode.create, initialContainer: initialContainer, + tabIds: tabIds, ); } - factory ContainerEditScreen.edit({required ContainerData initialContainer}) { + factory ContainerEditScreen.edit({ + required ContainerDataWithCount initialContainer, + }) { return ContainerEditScreen._( mode: _DialogMode.edit, initialContainer: initialContainer, @@ -76,11 +82,6 @@ class ContainerEditScreen extends HookConsumerWidget { text: initialContainer.name, ); - final containerHasTabs = switch (initialContainer) { - ContainerDataWithCount(:final tabCount?) when tabCount > 0 => true, - _ => false, - }; - return Scaffold( appBar: AppBar( title: Text(switch (_mode) { @@ -147,38 +148,11 @@ class ContainerEditScreen extends HookConsumerWidget { ), ), label: const Text('Name'), - suffixIcon: - (_mode == _DialogMode.edit && containerHasTabs) - ? Consumer( - builder: (context, ref, child) { - final isLoading = ref.watch( - containerTopicControllerProvider.select( - (value) => value.isLoading, - ), - ); - - return IconButton( - onPressed: isLoading - ? null - : () async { - final topic = await ref - .read( - containerTopicControllerProvider - .notifier, - ) - .predictDocumentTopic( - initialContainer.id, - ); - - if (topic != null) { - textController.text = topic; - } - }, - icon: const Icon(MdiIcons.creation), - ); - }, - ) - : null, + suffixIcon: _buildMagicWandButton( + context, + ref, + textController, + ), ), controller: textController, ), @@ -311,4 +285,49 @@ class ContainerEditScreen extends HookConsumerWidget { ), ); } + + Widget? _buildMagicWandButton( + BuildContext context, + WidgetRef ref, + TextEditingController textController, + ) { + final predict = switch (_mode) { + _DialogMode.edit => switch (initialContainer) { + ContainerDataWithCount(:final tabCount?) when tabCount > 0 => + (WidgetRef ref) => ref + .read(containerTopicControllerProvider.notifier) + .predictDocumentTopic(initialContainer.id), + _ => null, + }, + _DialogMode.create => switch (tabIds) { + final ids? when ids.isNotEmpty => + (WidgetRef ref) => ref + .read(containerTopicControllerProvider.notifier) + .predictTopicFromTabIds(ids), + _ => null, + }, + }; + + if (predict == null) return null; + + return Consumer( + builder: (context, ref, child) { + final isLoading = ref.watch( + containerTopicControllerProvider.select((value) => value.isLoading), + ); + + return IconButton( + onPressed: isLoading + ? null + : () async { + final topic = await predict(ref); + if (topic != null) { + textController.text = topic; + } + }, + icon: const Icon(MdiIcons.creation), + ); + }, + ); + } }