container bar reordering

This commit is contained in:
Fabian Freund
2026-05-26 11:57:26 +02:00
parent 8d882ffc3f
commit 417586fd8f
2 changed files with 194 additions and 77 deletions
@@ -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/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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/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/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/container_chip_content.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/tab_drag_container_target.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(), (filter) => containers.where(filter).toList(),
) ?? ) ??
containers; containers;
final enableContainerReorder =
enableDragAndDrop &&
containerFilter == null &&
searchTextListenable == null;
if (selectedContainer == null && if (selectedContainer == null &&
availableContainers.isEmpty && availableContainers.isEmpty &&
@@ -477,6 +482,21 @@ class ContainerChips extends HookConsumerWidget {
onSelected: onSelected, onSelected: onSelected,
onDeleted: onDeleted, onDeleted: onDeleted,
onLongPress: onLongPress, onLongPress: onLongPress,
onReorder: enableContainerReorder
? (oldIndex, newIndex) {
unawaited(
ref
.read(
containerRepositoryProvider.notifier,
)
.reorderContainer(
availableContainers,
oldIndex,
newIndex,
),
);
}
: null,
), ),
), ),
if (displayMenu) if (displayMenu)
@@ -18,7 +18,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
class _BadgeWrapper extends StatelessWidget { class _BadgeWrapper extends StatelessWidget {
@@ -40,17 +43,63 @@ class _BadgeWrapper extends StatelessWidget {
} }
} }
class _GestureWrapper extends StatelessWidget { class _GestureWrapper extends HookWidget {
final Widget child; final Widget child;
final GestureLongPressCallback? onLongPress; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return onLongPress != null final startPosition = useRef(Offset.zero);
? InkWell(onLongPress: onLongPress, child: child) final pressStart = useRef<Duration?>(null);
: child; 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<T extends S, S, K> extends StatelessWidget {
final void Function(T item)? onDeleted; final void Function(T item)? onDeleted;
final void Function(T item)? onLongPress; 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({ const SelectableChips({
required this.itemId, required this.itemId,
required this.itemLabel, required this.itemLabel,
@@ -127,6 +182,7 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
this.onSelected, this.onSelected,
this.onDeleted, this.onDeleted,
this.onLongPress, this.onLongPress,
this.onReorder,
this.sortSelectedFirst = true, this.sortSelectedFirst = true,
this.scrollController, this.scrollController,
this.activeItemKey, this.activeItemKey,
@@ -140,7 +196,10 @@ class SelectableChips<T extends S, S, K> extends StatelessWidget {
? availableItems.take(maxCount!).toList() ? availableItems.take(maxCount!).toList()
: availableItems.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) { if (selectedItem case final T selectedItem) {
final selectedIndex = items.indexWhere( final selectedIndex = items.indexWhere(
(item) => itemId(item) == itemId(selectedItem), (item) => itemId(item) == itemId(selectedItem),
@@ -153,83 +212,121 @@ class SelectableChips<T extends S, S, K> 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( return FadingScroll(
controller: scrollController, controller: scrollController,
fadingSize: 15, fadingSize: 15,
builder: (context, controller) { builder: (context, controller) {
return ListView.builder( if (onReorder == null) {
controller: controller, return ListView.builder(
cacheExtent: cacheExtent, 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, scrollDirection: Axis.horizontal,
itemCount: prefixListItems.length + items.length, buildDefaultDragHandles: false,
itemBuilder: (context, index) { itemCount: totalCount,
if (index < prefixListItems.length) { itemBuilder: (context, index) => index < prefixCount
return Padding( ? buildPrefix(index)
padding: const EdgeInsets.only(top: 4.0, right: 4), : buildItem(index - prefixCount),
child: prefixListItems[index], 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;
} }
onReorder!(oldIndex - prefixCount, newIndex - prefixCount);
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;
}, },
); );
}, },