206 lines
7.0 KiB
Dart
206 lines
7.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../widgets/overlay_sheet.dart';
|
|
import 'dpad_navigator.dart';
|
|
import 'key_event_utils.dart';
|
|
|
|
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
|
|
/// inside a sheet or dialog.
|
|
///
|
|
/// The host keeps its list and row widgets; this mixin owns the virtual cursor
|
|
/// ([focusedIndex] / [focusedColumn]), the move-mode state and the key handler.
|
|
/// Wire it up by passing [handleReorderKeyEvent] to the list's
|
|
/// `Focus.onKeyEvent` and by reading [focusedIndex], [focusedColumn] and
|
|
/// [movingIndex] when building rows.
|
|
///
|
|
/// Navigation mode: UP/DOWN move between rows (resetting to column 0),
|
|
/// LEFT/RIGHT move between the row (column 0) and the trailing action columns
|
|
/// up to [lastReorderColumn], SELECT on column 0 enters move mode and on any
|
|
/// other column calls [onReorderColumnActivated].
|
|
///
|
|
/// Move mode: UP/DOWN swap the moving row with its neighbour, SELECT confirms
|
|
/// through [onReorderMoveConfirmed], and BACK restores the order captured when
|
|
/// move mode was entered. BACK outside move mode dismisses the hosting sheet.
|
|
/// D-pad keys are consumed at the list boundaries so focus cannot escape.
|
|
mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
|
/// Row height assumed by [ensureFocusedVisible] (Material `ListTile` with a
|
|
/// subtitle) and the list's top padding.
|
|
static const double _itemHeight = 72.0;
|
|
static const double _listTopPadding = 8.0;
|
|
|
|
/// Row the virtual cursor sits on.
|
|
int focusedIndex = 0;
|
|
|
|
/// Column within [focusedIndex]: 0 is the row itself, 1..[lastReorderColumn]
|
|
/// are the trailing action buttons.
|
|
int focusedColumn = 0;
|
|
|
|
/// Row being moved, or null when not in move mode.
|
|
int? movingIndex;
|
|
|
|
int? _originalIndex;
|
|
List<E>? _originalOrder;
|
|
bool _backKeyDownSeen = false;
|
|
|
|
/// The list being reordered. Mutated in place while moving and replaced
|
|
/// wholesale when a move is cancelled.
|
|
List<E> get reorderItems;
|
|
set reorderItems(List<E> value);
|
|
|
|
/// Right-most focusable column index (0 when the row has no action buttons).
|
|
int get lastReorderColumn;
|
|
|
|
/// Scrollable holding the rows, or null when the host does not scroll the
|
|
/// focused row into view.
|
|
ScrollController? get reorderScrollController;
|
|
|
|
/// Called when SELECT confirms a move; [reorderItems] already holds the new
|
|
/// order.
|
|
void onReorderMoveConfirmed();
|
|
|
|
/// Called when SELECT activates a trailing action column (1 or greater).
|
|
void onReorderColumnActivated(int column, int index);
|
|
|
|
/// Scrolls [focusedIndex] into view, parking it ~25% from the viewport top.
|
|
void ensureFocusedVisible() {
|
|
final scrollController = reorderScrollController;
|
|
if (scrollController == null || !scrollController.hasClients) return;
|
|
|
|
final double targetTop = _listTopPadding + (focusedIndex * _itemHeight);
|
|
final double targetBottom = targetTop + _itemHeight;
|
|
|
|
final double viewportTop = scrollController.offset;
|
|
final double viewportHeight = scrollController.position.viewportDimension;
|
|
final double viewportBottom = viewportTop + viewportHeight;
|
|
|
|
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
|
|
|
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
|
0.0,
|
|
scrollController.position.maxScrollExtent,
|
|
);
|
|
|
|
scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
|
}
|
|
|
|
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
|
|
final key = event.logicalKey;
|
|
|
|
// Track back key down/up pairing. If focus was elsewhere during KeyDown
|
|
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
|
|
// KeyUp that would incorrectly pop the dialog. Consume it instead.
|
|
if (key.isBackKey) {
|
|
if (event is KeyDownEvent) {
|
|
_backKeyDownSeen = true;
|
|
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event is KeyUpEvent) {
|
|
_backKeyDownSeen = false;
|
|
}
|
|
}
|
|
|
|
final backResult = handleBackKeyAction(event, () {
|
|
if (movingIndex != null) {
|
|
setState(() {
|
|
final originalOrder = _originalOrder;
|
|
if (originalOrder != null) {
|
|
reorderItems = List<E>.from(originalOrder);
|
|
}
|
|
focusedIndex = _originalIndex ?? 0;
|
|
movingIndex = null;
|
|
_originalIndex = null;
|
|
_originalOrder = null;
|
|
});
|
|
} else {
|
|
OverlaySheetController.popAdaptive(context);
|
|
}
|
|
});
|
|
if (backResult != KeyEventResult.ignored) {
|
|
return backResult;
|
|
}
|
|
|
|
if (!event.isActionable) return KeyEventResult.ignored;
|
|
|
|
final int? moving = movingIndex;
|
|
if (moving != null) {
|
|
// Move mode - arrows reorder the item
|
|
if (key.isUpKey && moving > 0) {
|
|
_swapMovingItem(moving, moving - 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey && moving < reorderItems.length - 1) {
|
|
_swapMovingItem(moving, moving + 1);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isSelectKey) {
|
|
// Confirm move - apply the reorder
|
|
onReorderMoveConfirmed();
|
|
setState(() {
|
|
movingIndex = null;
|
|
_originalIndex = null;
|
|
_originalOrder = null;
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
} else {
|
|
// Navigation mode
|
|
if (key.isUpKey && focusedIndex > 0) {
|
|
setState(() {
|
|
focusedIndex--;
|
|
focusedColumn = 0; // Reset to row when changing rows
|
|
});
|
|
ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey && focusedIndex < reorderItems.length - 1) {
|
|
setState(() {
|
|
focusedIndex++;
|
|
focusedColumn = 0; // Reset to row when changing rows
|
|
});
|
|
ensureFocusedVisible();
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isLeftKey && focusedColumn > 0) {
|
|
setState(() => focusedColumn--);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isRightKey && focusedColumn < lastReorderColumn) {
|
|
setState(() => focusedColumn++);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isSelectKey) {
|
|
if (focusedColumn == 0) {
|
|
// Enter move mode
|
|
setState(() {
|
|
movingIndex = focusedIndex;
|
|
_originalIndex = focusedIndex;
|
|
_originalOrder = List<E>.from(reorderItems);
|
|
});
|
|
} else {
|
|
onReorderColumnActivated(focusedColumn, focusedIndex);
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
|
|
// Block d-pad keys at boundaries so focus doesn't escape the dialog
|
|
if (key.isDpadDirection) {
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
void _swapMovingItem(int from, int to) {
|
|
setState(() {
|
|
final item = reorderItems.removeAt(from);
|
|
reorderItems.insert(to, item);
|
|
movingIndex = to;
|
|
focusedIndex = to;
|
|
});
|
|
ensureFocusedVisible();
|
|
}
|
|
}
|