Add drag-and-drop tab to create new container
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+109
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+24
-16
@@ -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;
|
||||
|
||||
+41
-35
@@ -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,
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
+34
-2
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user