Add drag-and-drop tab to create new container

This commit is contained in:
Fabian Freund
2026-02-14 19:57:35 +01:00
parent b13e14f74a
commit c336b4fafe
8 changed files with 275 additions and 94 deletions
+7 -2
View File
@@ -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<String, dynamic>,
),
);
@@ -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<String>().toSet();
return ContainerEditScreen.create(
initialContainer: ContainerData.fromJson(
jsonDecode(containerData) as Map<String, dynamic>,
),
tabIds: tabIdsSet.isNotEmpty ? tabIdsSet : null,
);
}
}
+2
View File
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<TabDragData>(
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<ContainerData?>(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,
);
},
);
}
}
@@ -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;
@@ -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,
);
},
)
@@ -18,19 +18,51 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<String?> 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<String?> predictTopicFromTabIds(Set<String> tabIds) async {
final tabStates = ref.read(tabStatesProvider);
final titles = tabIds
.map((tabId) => tabStates[tabId]?.title)
.nonNulls
.toSet();
return _predictFromTitles(titles);
}
Future<String?> _predictFromTitles(Set<String> 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;
}
@@ -42,7 +42,7 @@ final class ContainerTopicControllerProvider
}
String _$containerTopicControllerHash() =>
r'e89fbdaaa9106f163ff00a829eb40752dd4af63a';
r'0b54e0be899e2abb4defbd5614ba231917ad74e5';
abstract class _$ContainerTopicController extends $Notifier<AsyncValue<void>> {
AsyncValue<void> build();
@@ -40,22 +40,28 @@ class ContainerEditScreen extends HookConsumerWidget {
final _DialogMode _mode;
final ContainerData initialContainer;
final Set<String>? tabIds;
const ContainerEditScreen._({
required _DialogMode mode,
required this.initialContainer,
this.tabIds,
}) : _mode = mode;
factory ContainerEditScreen.create({
required ContainerData initialContainer,
Set<String>? 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),
);
},
);
}
}