diff --git a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
index f3e481ef..000fd8f7 100644
--- a/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
+++ b/apps/weblibre/lib/features/geckoview/features/tabs/presentation/widgets/container_chips.dart
@@ -33,6 +33,7 @@ import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.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/presentation/widgets/container_chip_content.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.dart';
@@ -360,6 +361,10 @@ class ContainerChips extends HookConsumerWidget {
(filter) => containers.where(filter).toList(),
) ??
containers;
+ final enableContainerReorder =
+ enableDragAndDrop &&
+ containerFilter == null &&
+ searchTextListenable == null;
if (selectedContainer == null &&
availableContainers.isEmpty &&
@@ -477,6 +482,21 @@ class ContainerChips extends HookConsumerWidget {
onSelected: onSelected,
onDeleted: onDeleted,
onLongPress: onLongPress,
+ onReorder: enableContainerReorder
+ ? (oldIndex, newIndex) {
+ unawaited(
+ ref
+ .read(
+ containerRepositoryProvider.notifier,
+ )
+ .reorderContainer(
+ availableContainers,
+ oldIndex,
+ newIndex,
+ ),
+ );
+ }
+ : null,
),
),
if (displayMenu)
diff --git a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart
index 71c61078..a4c3711f 100644
--- a/apps/weblibre/lib/presentation/widgets/selectable_chips.dart
+++ b/apps/weblibre/lib/presentation/widgets/selectable_chips.dart
@@ -18,7 +18,10 @@
* along with this program. If not, see .
*/
import 'package:fading_scroll/fading_scroll.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:nullability/nullability.dart';
class _BadgeWrapper extends StatelessWidget {
@@ -40,17 +43,63 @@ class _BadgeWrapper extends StatelessWidget {
}
}
-class _GestureWrapper extends StatelessWidget {
+class _GestureWrapper extends HookWidget {
final Widget child;
final GestureLongPressCallback? onLongPress;
- const _GestureWrapper({required this.child, this.onLongPress});
+ /// When true, long-press detection is done via a passive [Listener] so it
+ /// doesn't claim the gesture from the surrounding
+ /// `ReorderableDelayedDragStartListener`. The callback fires only on pointer
+ /// release if the touch was held longer than [kLongPressTimeout] without
+ /// moving more than [kTouchSlop]. Any movement above slop arms the drag and
+ /// suppresses the callback.
+ final bool reorderMode;
+
+ const _GestureWrapper({
+ required this.child,
+ this.onLongPress,
+ this.reorderMode = false,
+ });
@override
Widget build(BuildContext context) {
- return onLongPress != null
- ? InkWell(onLongPress: onLongPress, child: child)
- : child;
+ final startPosition = useRef(Offset.zero);
+ final pressStart = useRef(null);
+ final moved = useRef(false);
+
+ if (onLongPress == null) {
+ return child;
+ }
+
+ if (!reorderMode) {
+ return InkWell(onLongPress: onLongPress, child: child);
+ }
+
+ return Listener(
+ onPointerDown: (event) {
+ startPosition.value = event.position;
+ pressStart.value = event.timeStamp;
+ moved.value = false;
+ },
+ onPointerMove: (event) {
+ if (!moved.value &&
+ (event.position - startPosition.value).distance > kTouchSlop) {
+ moved.value = true;
+ }
+ },
+ onPointerUp: (event) {
+ final start = pressStart.value;
+ pressStart.value = null;
+ if (start == null || moved.value) return;
+ if (event.timeStamp - start >= kLongPressTimeout) {
+ onLongPress!();
+ }
+ },
+ onPointerCancel: (_) {
+ pressStart.value = null;
+ },
+ child: child,
+ );
}
}
@@ -110,6 +159,12 @@ class SelectableChips extends StatelessWidget {
final void Function(T item)? onDeleted;
final void Function(T item)? onLongPress;
+ /// Long-press-to-drag reorder among the main items. Indices are
+ /// passed in the same coordinate space as [availableItems] (prefix
+ /// items are excluded). Null disables reorder entirely and the
+ /// non-reorderable [ListView] path is used.
+ final void Function(int oldIndex, int newIndex)? onReorder;
+
const SelectableChips({
required this.itemId,
required this.itemLabel,
@@ -127,6 +182,7 @@ class SelectableChips extends StatelessWidget {
this.onSelected,
this.onDeleted,
this.onLongPress,
+ this.onReorder,
this.sortSelectedFirst = true,
this.scrollController,
this.activeItemKey,
@@ -140,7 +196,10 @@ class SelectableChips extends StatelessWidget {
? availableItems.take(maxCount!).toList()
: availableItems.toList();
- if (sortSelectedFirst) {
+ // Selection-first reorder fights with manual drag-reorder, so when
+ // reorder is on, DB order wins.
+ final sortSelected = sortSelectedFirst && onReorder == null;
+ if (sortSelected) {
if (selectedItem case final T selectedItem) {
final selectedIndex = items.indexWhere(
(item) => itemId(item) == itemId(selectedItem),
@@ -153,83 +212,121 @@ class SelectableChips extends StatelessWidget {
}
}
+ final prefixCount = prefixListItems.length;
+ final totalCount = prefixCount + items.length;
+
+ Widget buildPrefix(int index) {
+ return Padding(
+ key: ValueKey('__selectable_chips_prefix_$index'),
+ padding: const EdgeInsets.only(top: 4.0, right: 4),
+ child: prefixListItems[index],
+ );
+ }
+
+ Widget buildItem(int index) {
+ final item = items[index];
+ final isSelected =
+ selectedItem != null && itemId(item) == itemId(selectedItem as S);
+ final deco = decoration;
+ final itemColorValue = deco?.color?.call(item, isSelected);
+ final canDeleteItem =
+ enableDelete && (deco?.canDelete?.call(item) ?? true);
+ final child = Padding(
+ padding: const EdgeInsets.only(right: 8.0, top: 4.0),
+ child: _BadgeWrapper(
+ count: itemBadgeCount?.call(item),
+ child: _GestureWrapper(
+ reorderMode: onReorder != null,
+ onLongPress: onLongPress.mapNotNull(
+ (callback) =>
+ () => callback(item),
+ ),
+ child: FilterChip(
+ color: itemColorValue != null
+ ? WidgetStatePropertyAll(itemColorValue)
+ : null,
+ selected: selectedBorderColor == null && isSelected,
+ showCheckmark: false,
+ labelPadding: deco?.labelPadding?.call(item),
+ deleteIcon: deco?.deleteIcon?.call(item),
+ onSelected: (value) {
+ if (value) {
+ onSelected?.call(item);
+ } else {
+ onDeleted?.call(item);
+ }
+ },
+ onDeleted: canDeleteItem
+ ? () {
+ onDeleted?.call(item);
+ }
+ : null,
+ label: itemLabel.call(item),
+ avatar: itemAvatar?.call(item),
+ tooltip: itemTooltip?.call(item),
+ side:
+ deco?.side?.call(item, isSelected) ??
+ (isSelected && selectedBorderColor != null
+ ? BorderSide(color: selectedBorderColor!, width: 2.0)
+ : null),
+ ),
+ ),
+ ),
+ );
+
+ final wrappedChild = (itemWrap != null) ? itemWrap!(child, item) : child;
+ final keyed = KeyedSubtree(
+ key: isSelected && activeItemKey != null
+ ? activeItemKey
+ : ValueKey(itemId(item)),
+ child: wrappedChild,
+ );
+ return onReorder != null
+ ? ReorderableDelayedDragStartListener(
+ key: ValueKey('__selectable_chips_item_${itemId(item)}'),
+ index: prefixCount + index,
+ child: keyed,
+ )
+ : keyed;
+ }
+
return FadingScroll(
controller: scrollController,
fadingSize: 15,
builder: (context, controller) {
- return ListView.builder(
- controller: controller,
- cacheExtent: cacheExtent,
+ if (onReorder == null) {
+ return ListView.builder(
+ controller: controller,
+ scrollCacheExtent: cacheExtent.mapNotNull(
+ (extent) => ScrollCacheExtent.pixels(extent),
+ ),
+ scrollDirection: Axis.horizontal,
+ itemCount: totalCount,
+ itemBuilder: (context, index) => index < prefixCount
+ ? buildPrefix(index)
+ : buildItem(index - prefixCount),
+ );
+ }
+
+ return ReorderableListView.builder(
+ scrollController: controller,
+ scrollCacheExtent: cacheExtent.mapNotNull(
+ (extent) => ScrollCacheExtent.pixels(extent),
+ ),
scrollDirection: Axis.horizontal,
- itemCount: prefixListItems.length + items.length,
- itemBuilder: (context, index) {
- if (index < prefixListItems.length) {
- return Padding(
- padding: const EdgeInsets.only(top: 4.0, right: 4),
- child: prefixListItems[index],
- );
+ buildDefaultDragHandles: false,
+ itemCount: totalCount,
+ itemBuilder: (context, index) => index < prefixCount
+ ? buildPrefix(index)
+ : buildItem(index - prefixCount),
+ onReorderItem: (oldIndex, newIndex) {
+ // onReorderItem newIndex is already post-removal.
+ if (oldIndex < prefixCount || newIndex < prefixCount) {
+ // Either source or target is in the non-draggable prefix
+ // region; ignore.
+ return;
}
-
- final item = items[index - prefixListItems.length];
- final isSelected =
- selectedItem != null &&
- itemId(item) == itemId(selectedItem as S);
- final deco = decoration;
- final itemColorValue = deco?.color?.call(item, isSelected);
- final canDeleteItem =
- enableDelete && (deco?.canDelete?.call(item) ?? true);
- final child = Padding(
- padding: const EdgeInsets.only(right: 8.0, top: 4.0),
- child: _BadgeWrapper(
- count: itemBadgeCount?.call(item),
- child: _GestureWrapper(
- onLongPress: onLongPress.mapNotNull(
- (callback) =>
- () => callback(item),
- ),
- child: FilterChip(
- color: itemColorValue != null
- ? WidgetStatePropertyAll(itemColorValue)
- : null,
- selected: selectedBorderColor == null && isSelected,
- showCheckmark: false,
- labelPadding: deco?.labelPadding?.call(item),
- deleteIcon: deco?.deleteIcon?.call(item),
- onSelected: (value) {
- if (value) {
- onSelected?.call(item);
- } else {
- onDeleted?.call(item);
- }
- },
- onDeleted: canDeleteItem
- ? () {
- onDeleted?.call(item);
- }
- : null,
- label: itemLabel.call(item),
- avatar: itemAvatar?.call(item),
- tooltip: itemTooltip?.call(item),
- side:
- deco?.side?.call(item, isSelected) ??
- (isSelected && selectedBorderColor != null
- ? BorderSide(
- color: selectedBorderColor!,
- width: 2.0,
- )
- : null),
- ),
- ),
- ),
- );
-
- final wrappedChild = (itemWrap != null)
- ? itemWrap!(child, item)
- : child;
-
- return isSelected && activeItemKey != null
- ? KeyedSubtree(key: activeItemKey, child: wrappedChild)
- : wrappedChild;
+ onReorder!(oldIndex - prefixCount, newIndex - prefixCount);
},
);
},