container bar reordering
This commit is contained in:
+20
@@ -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;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user