Add arrow-key controls to the guessing question tile.

Up/down step the answer by one; hold left/right to defer or save like the edge hold buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nathanAndrinoid 2026-06-04 19:16:10 +00:00
parent e64d21dc2e
commit 4035684b4d

View File

@ -18,6 +18,8 @@ enum _LockedDragAxis { horizontal, vertical, rejected }
/// Swipeable question card: right submits, left defers (does not resolve). /// 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). /// 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 /// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
/// cardinal axis; diagonal swipes are ignored. /// cardinal axis; diagonal swipes are ignored.
@ -54,7 +56,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool _acting = false; bool _acting = false;
bool _holdSavePointerDown = false; bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false; bool _holdDeferPointerDown = false;
bool _keyboardHoldSave = false;
bool _keyboardHoldDefer = false;
int _lastSnappedValue = 0; int _lastSnappedValue = 0;
late final FocusNode _focusNode;
late final AnimationController _snapController; late final AnimationController _snapController;
late final AnimationController _holdSaveController; late final AnimationController _holdSaveController;
late final AnimationController _holdDeferController; late final AnimationController _holdDeferController;
@ -90,6 +95,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode = FocusNode()..addListener(_onFocusChange);
_snapController = AnimationController( _snapController = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 140), duration: const Duration(milliseconds: 140),
@ -106,12 +112,27 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override @override
void dispose() { void dispose() {
_focusNode.dispose();
_snapController.dispose(); _snapController.dispose();
_holdSaveController.dispose(); _holdSaveController.dispose();
_holdDeferController.dispose(); _holdDeferController.dispose();
super.dispose(); super.dispose();
} }
void _onFocusChange() {
if (_focusNode.hasFocus) {
return;
}
if (_keyboardHoldSave) {
_keyboardHoldSave = false;
_endHoldSave();
}
if (_keyboardHoldDefer) {
_keyboardHoldDefer = false;
_endHoldDefer();
}
}
void _syncHoldSaveDrag() { void _syncHoldSaveDrag() {
if (!mounted || _acting || _holdDeferActive) { if (!mounted || _acting || _holdDeferActive) {
return; return;
@ -168,7 +189,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
} }
void _onHoldSavePointerDown(PointerDownEvent event) { void _beginHoldSave() {
if (!_holdSaveEnabled || _holdDeferActive) { if (!_holdSaveEnabled || _holdDeferActive) {
return; return;
} }
@ -176,7 +197,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_holdSaveController.forward(from: _holdSaveController.value); _holdSaveController.forward(from: _holdSaveController.value);
} }
void _onHoldSavePointerUp(PointerEvent event) { void _endHoldSave() {
if (!_holdSavePointerDown) { if (!_holdSavePointerDown) {
return; return;
} }
@ -188,6 +209,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
unawaited(_holdSaveController.reverse()); unawaited(_holdSaveController.reverse());
} }
void _onHoldSavePointerDown(PointerDownEvent event) {
_beginHoldSave();
}
void _onHoldSavePointerUp(PointerEvent event) {
_endHoldSave();
}
Future<void> _completeHoldSave() async { Future<void> _completeHoldSave() async {
if (_acting || widget.busy || _atZero || !mounted) { if (_acting || widget.busy || _atZero || !mounted) {
return; return;
@ -207,7 +236,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
} }
void _onHoldDeferPointerDown(PointerDownEvent event) { void _beginHoldDefer() {
if (!_holdDeferEnabled || _holdSaveActive) { if (!_holdDeferEnabled || _holdSaveActive) {
return; return;
} }
@ -215,7 +244,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
_holdDeferController.forward(from: _holdDeferController.value); _holdDeferController.forward(from: _holdDeferController.value);
} }
void _onHoldDeferPointerUp(PointerEvent event) { void _endHoldDefer() {
if (!_holdDeferPointerDown) { if (!_holdDeferPointerDown) {
return; return;
} }
@ -227,6 +256,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
unawaited(_holdDeferController.reverse()); unawaited(_holdDeferController.reverse());
} }
void _onHoldDeferPointerDown(PointerDownEvent event) {
_beginHoldDefer();
}
void _onHoldDeferPointerUp(PointerEvent event) {
_endHoldDefer();
}
Future<void> _completeHoldDefer() async { Future<void> _completeHoldDefer() async {
if (_acting || widget.busy || !mounted) { if (_acting || widget.busy || !mounted) {
return; return;
@ -400,15 +437,12 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
} }
} }
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale. /// Steps the slider by [delta] integers, clamped to [-10, 10].
void _stepSliderFromScroll(double scrollDy) { void _stepSliderBy(int delta) {
if (scrollDy == 0) { if (widget.busy || _acting || delta == 0) {
return; return;
} }
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp( final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
_sliderMin,
_sliderMax,
);
if (next == _snappedSliderValue) { if (next == _snappedSliderValue) {
return; return;
} }
@ -421,6 +455,57 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
}); });
} }
/// 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) { void _accumulateScrollDelta(double dy) {
if (widget.busy || _acting || dy == 0) { if (widget.busy || _acting || dy == 0) {
return; return;
@ -510,10 +595,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
40, 40,
); );
return Stack( return Focus(
alignment: Alignment.center, focusNode: _focusNode,
clipBehavior: Clip.none, autofocus: true,
children: <Widget>[ onKeyEvent: _onKeyEvent,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: <Widget>[
Transform.translate( Transform.translate(
offset: Offset(_dragOffset, 0), offset: Offset(_dragOffset, 0),
child: AnimatedBuilder( child: AnimatedBuilder(
@ -660,7 +749,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
), ),
), ),
), ),
], ],
),
); );
}, },
); );