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 on State { /// 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? _originalOrder; bool _backKeyDownSeen = false; /// The list being reordered. Mutated in place while moving and replaced /// wholesale when a move is cancelled. List get reorderItems; set reorderItems(List 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.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.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(); } }