diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index 60310e5..ffefa31 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -18,6 +18,8 @@ enum _LockedDragAxis { horizontal, vertical, rejected } /// Swipeable question card: right submits, left defers (does not resolve). /// /// The center band supports vertical drag as a slider from -10 (down) to 10 (up). +/// Arrow up/down step the value by one; hold arrow left/right to defer or save +/// (same as the edge hold buttons). /// /// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a /// cardinal axis; diagonal swipes are ignored. @@ -54,7 +56,10 @@ class _SwipeQuestionTileState extends State bool _acting = false; bool _holdSavePointerDown = false; bool _holdDeferPointerDown = false; + bool _keyboardHoldSave = false; + bool _keyboardHoldDefer = false; int _lastSnappedValue = 0; + late final FocusNode _focusNode; late final AnimationController _snapController; late final AnimationController _holdSaveController; late final AnimationController _holdDeferController; @@ -90,6 +95,7 @@ class _SwipeQuestionTileState extends State @override void initState() { super.initState(); + _focusNode = FocusNode()..addListener(_onFocusChange); _snapController = AnimationController( vsync: this, duration: const Duration(milliseconds: 140), @@ -106,12 +112,27 @@ class _SwipeQuestionTileState extends State @override void dispose() { + _focusNode.dispose(); _snapController.dispose(); _holdSaveController.dispose(); _holdDeferController.dispose(); super.dispose(); } + void _onFocusChange() { + if (_focusNode.hasFocus) { + return; + } + if (_keyboardHoldSave) { + _keyboardHoldSave = false; + _endHoldSave(); + } + if (_keyboardHoldDefer) { + _keyboardHoldDefer = false; + _endHoldDefer(); + } + } + void _syncHoldSaveDrag() { if (!mounted || _acting || _holdDeferActive) { return; @@ -168,7 +189,7 @@ class _SwipeQuestionTileState extends State } } - void _onHoldSavePointerDown(PointerDownEvent event) { + void _beginHoldSave() { if (!_holdSaveEnabled || _holdDeferActive) { return; } @@ -176,7 +197,7 @@ class _SwipeQuestionTileState extends State _holdSaveController.forward(from: _holdSaveController.value); } - void _onHoldSavePointerUp(PointerEvent event) { + void _endHoldSave() { if (!_holdSavePointerDown) { return; } @@ -188,6 +209,14 @@ class _SwipeQuestionTileState extends State unawaited(_holdSaveController.reverse()); } + void _onHoldSavePointerDown(PointerDownEvent event) { + _beginHoldSave(); + } + + void _onHoldSavePointerUp(PointerEvent event) { + _endHoldSave(); + } + Future _completeHoldSave() async { if (_acting || widget.busy || _atZero || !mounted) { return; @@ -207,7 +236,7 @@ class _SwipeQuestionTileState extends State } } - void _onHoldDeferPointerDown(PointerDownEvent event) { + void _beginHoldDefer() { if (!_holdDeferEnabled || _holdSaveActive) { return; } @@ -215,7 +244,7 @@ class _SwipeQuestionTileState extends State _holdDeferController.forward(from: _holdDeferController.value); } - void _onHoldDeferPointerUp(PointerEvent event) { + void _endHoldDefer() { if (!_holdDeferPointerDown) { return; } @@ -227,6 +256,14 @@ class _SwipeQuestionTileState extends State unawaited(_holdDeferController.reverse()); } + void _onHoldDeferPointerDown(PointerDownEvent event) { + _beginHoldDefer(); + } + + void _onHoldDeferPointerUp(PointerEvent event) { + _endHoldDefer(); + } + Future _completeHoldDefer() async { if (_acting || widget.busy || !mounted) { return; @@ -400,15 +437,12 @@ class _SwipeQuestionTileState extends State } } - /// One wheel notch / trackpad tick → one integer on the [-10, 10] scale. - void _stepSliderFromScroll(double scrollDy) { - if (scrollDy == 0) { + /// Steps the slider by [delta] integers, clamped to [-10, 10]. + void _stepSliderBy(int delta) { + if (widget.busy || _acting || delta == 0) { return; } - final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp( - _sliderMin, - _sliderMax, - ); + final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax); if (next == _snappedSliderValue) { return; } @@ -421,6 +455,57 @@ class _SwipeQuestionTileState extends State }); } + /// One wheel notch / trackpad tick → one integer on the [-10, 10] scale. + void _stepSliderFromScroll(double scrollDy) { + if (scrollDy == 0) { + return; + } + _stepSliderBy(scrollDy > 0 ? -1 : 1); + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (widget.busy || _acting) { + return KeyEventResult.ignored; + } + + final LogicalKeyboardKey key = event.logicalKey; + + if (key == LogicalKeyboardKey.arrowUp || + key == LogicalKeyboardKey.arrowDown) { + if (event is KeyUpEvent) { + return KeyEventResult.handled; + } + if (event is KeyDownEvent || event is KeyRepeatEvent) { + _stepSliderBy(key == LogicalKeyboardKey.arrowUp ? 1 : -1); + return KeyEventResult.handled; + } + } + + if (key == LogicalKeyboardKey.arrowRight) { + if (event is KeyDownEvent) { + _keyboardHoldSave = true; + _beginHoldSave(); + } else if (event is KeyUpEvent && _keyboardHoldSave) { + _keyboardHoldSave = false; + _endHoldSave(); + } + return KeyEventResult.handled; + } + + if (key == LogicalKeyboardKey.arrowLeft) { + if (event is KeyDownEvent) { + _keyboardHoldDefer = true; + _beginHoldDefer(); + } else if (event is KeyUpEvent && _keyboardHoldDefer) { + _keyboardHoldDefer = false; + _endHoldDefer(); + } + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + void _accumulateScrollDelta(double dy) { if (widget.busy || _acting || dy == 0) { return; @@ -510,10 +595,14 @@ class _SwipeQuestionTileState extends State 40, ); - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKeyEvent, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ Transform.translate( offset: Offset(_dragOffset, 0), child: AnimatedBuilder( @@ -660,7 +749,8 @@ class _SwipeQuestionTileState extends State ), ), ), - ], + ], + ), ); }, );