import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../guid/guid_glyph_shape.dart'; import '../services/questions_hub_service.dart'; import '../theme/app_theme.dart'; const double _holdEdgeButtonWidth = 58; /// Equal left/right margin for the slider; edge buttons sit in the gutters. const double _centerSideInset = _holdEdgeButtonWidth + 8; enum _LockedDragAxis { horizontal, vertical, rejected } /// Whether a hold-to-swipe edge action was started from pointer or keyboard. enum _HoldInputSource { none, pointer, keyboard } /// 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. class SwipeQuestionTile extends StatefulWidget { const SwipeQuestionTile({ super.key, required this.questionId, required this.onSwipeRight, required this.onSwipeLeft, this.busy = false, this.swipeAxisToleranceDegrees = 25, }); final String questionId; final Future Function(num answer) onSwipeRight; final Future Function() onSwipeLeft; final bool busy; /// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis. final double swipeAxisToleranceDegrees; @override State createState() => _SwipeQuestionTileState(); } class _SwipeQuestionTileState extends State with TickerProviderStateMixin { double _dragOffset = 0; double _verticalOffset = 0; double _panTotalDx = 0; double _panTotalDy = 0; double _scrollAccumulatedDy = 0; _LockedDragAxis? _lockedDragAxis; bool _acting = false; _HoldInputSource _holdSaveInput = _HoldInputSource.none; _HoldInputSource _holdDeferInput = _HoldInputSource.none; int _lastSnappedValue = 0; late final FocusNode _focusNode; late final AnimationController _snapController; late final AnimationController _holdSaveController; late final AnimationController _holdDeferController; static const double _swipeThreshold = 96; static const double _axisLockSlop = 14; static const double _scrollStepPixels = 100; static const Duration _holdEdgeDuration = Duration(seconds: 1); static const double _glyphSize = 80; static const double _trackEdgeInset = 8; static const int _sliderMin = -10; static const int _sliderMax = 10; /// Updated each build from the tile height so ±10 reaches near the track edges. double _maxVerticalDrag = 120; bool get _atZero => _snappedSliderValue == 0; bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero; bool get _holdDeferEnabled => !widget.busy && !_acting; bool get _holdSaveActive => _holdSaveInput != _HoldInputSource.none || _holdSaveController.value > 0; bool get _holdDeferActive => _holdDeferInput != _HoldInputSource.none || _holdDeferController.value > 0; bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive; bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive; @override void initState() { super.initState(); _focusNode = FocusNode()..addListener(_onFocusChange); _snapController = AnimationController( vsync: this, duration: const Duration(milliseconds: 140), ); _holdSaveController = AnimationController(vsync: this, duration: _holdEdgeDuration) ..addListener(_syncHoldSaveDrag) ..addStatusListener(_onHoldSaveStatus); _holdDeferController = AnimationController(vsync: this, duration: _holdEdgeDuration) ..addListener(_syncHoldDeferDrag) ..addStatusListener(_onHoldDeferStatus); HardwareKeyboard.instance.addHandler(_handleHardwareKey); WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus()); } @override void didUpdateWidget(SwipeQuestionTile oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.questionId != widget.questionId || (oldWidget.busy && !widget.busy)) { WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus()); } } void _requestTileFocus() { if (!mounted || widget.busy) { return; } _focusNode.requestFocus(); } @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleHardwareKey); _focusNode.dispose(); _snapController.dispose(); _holdSaveController.dispose(); _holdDeferController.dispose(); super.dispose(); } void _onFocusChange() { if (_focusNode.hasFocus) { return; } _cancelKeyboardHolds(); } void _cancelKeyboardHolds() { if (_holdSaveInput == _HoldInputSource.keyboard) { _endHoldSave(_HoldInputSource.keyboard); } if (_holdDeferInput == _HoldInputSource.keyboard) { _endHoldDefer(_HoldInputSource.keyboard); } } void _syncHoldSaveDrag() { if (!mounted || _acting || _holdDeferActive) { return; } final double width = MediaQuery.sizeOf(context).width; final double next = _holdSaveController.value * width; if (next == _dragOffset) { return; } setState(() => _dragOffset = next); } void _syncHoldDeferDrag() { if (!mounted || _acting || _holdSaveActive) { return; } final double width = MediaQuery.sizeOf(context).width; final double next = -_holdDeferController.value * width; if (next == _dragOffset) { return; } setState(() => _dragOffset = next); } void _onHoldEdgeDismissed() { if (mounted && !_acting) { setState(() => _dragOffset = 0); } } void _onHoldSaveStatus(AnimationStatus status) { if (status == AnimationStatus.dismissed) { if (!_holdDeferActive) { _onHoldEdgeDismissed(); } } else if (status == AnimationStatus.completed && _holdSaveInput != _HoldInputSource.none && mounted) { _holdSaveInput = _HoldInputSource.none; unawaited(_completeHoldSave()); } } void _onHoldDeferStatus(AnimationStatus status) { if (status == AnimationStatus.dismissed) { if (!_holdSaveActive) { _onHoldEdgeDismissed(); } } else if (status == AnimationStatus.completed && _holdDeferInput != _HoldInputSource.none && mounted) { _holdDeferInput = _HoldInputSource.none; unawaited(_completeHoldDefer()); } } void _beginHoldSave(_HoldInputSource source) { if (!_holdSaveEnabled || _holdDeferActive || _holdSaveInput != _HoldInputSource.none) { return; } _holdSaveInput = source; _holdSaveController.forward(from: _holdSaveController.value); } void _endHoldSave(_HoldInputSource source) { if (_holdSaveInput != source) { return; } _holdSaveInput = _HoldInputSource.none; if (_holdSaveController.value >= 1.0) { unawaited(_completeHoldSave()); return; } unawaited(_holdSaveController.reverse()); } void _onHoldSavePointerDown(PointerDownEvent event) { _beginHoldSave(_HoldInputSource.pointer); } void _onHoldSavePointerUp(PointerEvent event) { _endHoldSave(_HoldInputSource.pointer); } Future _completeHoldSave() async { if (_acting || widget.busy || _atZero || !mounted) { return; } final double width = MediaQuery.sizeOf(context).width; setState(() { _acting = true; _dragOffset = width; }); await widget.onSwipeRight(_snappedSliderValue); if (mounted) { setState(() { _acting = false; _dragOffset = 0; }); _holdSaveController.reset(); } } void _beginHoldDefer(_HoldInputSource source) { if (!_holdDeferEnabled || _holdSaveActive || _holdDeferInput != _HoldInputSource.none) { return; } _holdDeferInput = source; _holdDeferController.forward(from: _holdDeferController.value); } void _endHoldDefer(_HoldInputSource source) { if (_holdDeferInput != source) { return; } _holdDeferInput = _HoldInputSource.none; if (_holdDeferController.value >= 1.0) { unawaited(_completeHoldDefer()); return; } unawaited(_holdDeferController.reverse()); } void _onHoldDeferPointerDown(PointerDownEvent event) { _beginHoldDefer(_HoldInputSource.pointer); } void _onHoldDeferPointerUp(PointerEvent event) { _endHoldDefer(_HoldInputSource.pointer); } Future _completeHoldDefer() async { if (_acting || widget.busy || !mounted) { return; } final double width = MediaQuery.sizeOf(context).width; setState(() { _acting = true; _dragOffset = -width; }); await widget.onSwipeLeft(); if (mounted) { setState(() { _acting = false; _dragOffset = 0; }); _holdDeferController.reset(); } } /// Swipe up → +10, swipe down → -10; snaps to whole numbers. int get _snappedSliderValue => (_clampedVerticalOffset / _maxVerticalDrag * _sliderMax).round().clamp( _sliderMin, _sliderMax, ); double get _clampedVerticalOffset => _verticalOffset.clamp(-_maxVerticalDrag, _maxVerticalDrag); double get _snappedVerticalOffset => _snappedSliderValue / _sliderMax * _maxVerticalDrag; void _applyVerticalInputDelta(double dy) { setState(() { _verticalOffset -= dy; _verticalOffset = _verticalOffset.clamp( -_maxVerticalDrag, _maxVerticalDrag, ); if (_atZero) { _dragOffset = 0; } _maybeTriggerSnapFeedback(_snappedSliderValue); }); } void _applyHorizontalDelta(double dx, double maxOffset) { setState(() { _dragOffset += dx; _dragOffset = _dragOffset.clamp(-maxOffset, maxOffset); }); } bool _angleNearCardinal(double angleDeg, double targetDeg) { var diff = angleDeg - targetDeg; while (diff > 180) { diff -= 360; } while (diff < -180) { diff += 360; } return diff.abs() <= widget.swipeAxisToleranceDegrees; } bool _isHorizontalAngle(double angleDeg) => _angleNearCardinal(angleDeg, 0) || _angleNearCardinal(angleDeg, 180) || _angleNearCardinal(angleDeg, -180); bool _isVerticalAngle(double angleDeg) => _angleNearCardinal(angleDeg, 90) || _angleNearCardinal(angleDeg, -90); _LockedDragAxis _resolveDragAxis(double totalDx, double totalDy) { final double angleDeg = math.atan2(totalDy, totalDx) * 180 / math.pi; final bool horizontal = _isHorizontalAngle(angleDeg); final bool vertical = _isVerticalAngle(angleDeg); if (horizontal && vertical) { return totalDx.abs() >= totalDy.abs() ? _LockedDragAxis.horizontal : _LockedDragAxis.vertical; } if (horizontal) { return _LockedDragAxis.horizontal; } if (vertical) { return _LockedDragAxis.vertical; } return _LockedDragAxis.rejected; } void _resetPanGesture() { _panTotalDx = 0; _panTotalDy = 0; _lockedDragAxis = null; } void _onPanStart(DragStartDetails details) { if (widget.busy || _acting || _holdEdgeActive) { return; } _resetPanGesture(); } void _onPanUpdate(DragUpdateDetails details) { if (widget.busy || _acting || _holdEdgeActive) { return; } _panTotalDx += details.delta.dx; _panTotalDy += details.delta.dy; if (_lockedDragAxis == null) { final double distance = math.sqrt( _panTotalDx * _panTotalDx + _panTotalDy * _panTotalDy, ); if (distance < _axisLockSlop) { return; } var axis = _resolveDragAxis(_panTotalDx, _panTotalDy); if (axis == _LockedDragAxis.horizontal && !_horizontalSwipeEnabled) { axis = _LockedDragAxis.rejected; } _lockedDragAxis = axis; final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55; switch (axis) { case _LockedDragAxis.horizontal: _applyHorizontalDelta(_panTotalDx, maxHorizontal); case _LockedDragAxis.vertical: _applyVerticalInputDelta(_panTotalDy); case _LockedDragAxis.rejected: break; } return; } if (_lockedDragAxis == _LockedDragAxis.rejected) { return; } final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55; switch (_lockedDragAxis!) { case _LockedDragAxis.horizontal: _applyHorizontalDelta(details.delta.dx, maxHorizontal); case _LockedDragAxis.vertical: _applyVerticalInputDelta(details.delta.dy); case _LockedDragAxis.rejected: break; } } void _onPanEnd(DragEndDetails details) { final _LockedDragAxis? axis = _lockedDragAxis; _resetPanGesture(); if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) { unawaited(_releaseDrag()); } else if (axis == _LockedDragAxis.rejected && mounted) { setState(() => _dragOffset = 0); } } void _onPanCancel() { final _LockedDragAxis? axis = _lockedDragAxis; _resetPanGesture(); if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) { unawaited(_releaseDrag()); } else if (mounted) { setState(() => _dragOffset = 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 + delta).clamp(_sliderMin, _sliderMax); if (next == _snappedSliderValue) { return; } setState(() { _verticalOffset = next / _sliderMax * _maxVerticalDrag; if (_atZero) { _dragOffset = 0; } _maybeTriggerSnapFeedback(next); }); } /// 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); } /// True while the question panel is open and this tile should own arrow keys. bool get _captureArrowKeys => mounted && !widget.busy && !_acting && QuestionsHubService.instance.questionPanelOpen.value; /// Primary path for web/production: does not require widget focus. bool _handleHardwareKey(KeyEvent event) { if (!_captureArrowKeys || !_isArrowKey(event.logicalKey)) { return false; } return _processArrowKey(event) == KeyEventResult.handled; } static bool _isArrowKey(LogicalKeyboardKey key) => key == LogicalKeyboardKey.arrowUp || key == LogicalKeyboardKey.arrowDown || key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight; /// Arrow up/down step value; hold left/right to defer / save (edge buttons). KeyEventResult _processArrowKey(KeyEvent event) { if (widget.busy || _acting) { return KeyEventResult.ignored; } final LogicalKeyboardKey key = event.logicalKey; const _HoldInputSource kb = _HoldInputSource.keyboard; 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; } return KeyEventResult.ignored; } if (key == LogicalKeyboardKey.arrowRight) { if (event is KeyDownEvent) { _beginHoldSave(kb); } else if (event is KeyUpEvent) { _endHoldSave(kb); } return KeyEventResult.handled; } if (key == LogicalKeyboardKey.arrowLeft) { if (event is KeyDownEvent) { _beginHoldDefer(kb); } else if (event is KeyUpEvent) { _endHoldDefer(kb); } return KeyEventResult.handled; } return KeyEventResult.ignored; } void _accumulateScrollDelta(double dy) { if (widget.busy || _acting || dy == 0) { return; } _scrollAccumulatedDy += dy; while (_scrollAccumulatedDy.abs() >= _scrollStepPixels) { final double step = _scrollAccumulatedDy > 0 ? _scrollStepPixels : -_scrollStepPixels; _stepSliderFromScroll(step); _scrollAccumulatedDy -= step; } } void _onPointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent) { _accumulateScrollDelta(event.scrollDelta.dy); } } void _onPointerPanZoomUpdate(PointerPanZoomUpdateEvent event) { _accumulateScrollDelta(event.panDelta.dy); } void _maybeTriggerSnapFeedback(int snapped) { if (snapped == _lastSnappedValue) { return; } _lastSnappedValue = snapped; unawaited(_snapController.forward(from: 0)); HapticFeedback.selectionClick(); } ({double shakeX, double scale}) _snapMotion(double t) { final double damp = 1 - t; return ( shakeX: math.sin(t * math.pi * 6) * 5 * damp, scale: 1 + 0.035 * math.sin(t * math.pi) * damp, ); } Future _releaseDrag() async { if (_acting || widget.busy || _atZero) { setState(() => _dragOffset = 0); return; } if (_dragOffset > _swipeThreshold) { setState(() { _acting = true; _dragOffset = MediaQuery.sizeOf(context).width; }); await widget.onSwipeRight(_snappedSliderValue); } else if (_dragOffset < -_swipeThreshold) { setState(() { _acting = true; _dragOffset = -MediaQuery.sizeOf(context).width; }); await widget.onSwipeLeft(); } else if (mounted) { setState(() => _dragOffset = 0); } if (mounted) { setState(() { _acting = false; _dragOffset = 0; }); } } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { const double outerVerticalPadding = 48; const double trackVerticalInset = 16; final double innerHeight = math.max( constraints.maxHeight - outerVerticalPadding, 172, ); final double trackHeight = math.max( innerHeight - trackVerticalInset, _glyphSize, ); _maxVerticalDrag = math.max( (trackHeight / 2) - (_glyphSize / 2) - _trackEdgeInset, 40, ); return Focus( focusNode: _focusNode, autofocus: true, descendantsAreFocusable: false, child: Stack( alignment: Alignment.center, clipBehavior: Clip.none, children: [ Transform.translate( offset: Offset(_dragOffset, 0), child: AnimatedBuilder( animation: _snapController, builder: (BuildContext context, Widget? child) { final ({double shakeX, double scale}) motion = _snapMotion( _snapController.value, ); return Transform.translate( offset: Offset(motion.shakeX, 0), child: Transform.scale(scale: motion.scale, child: child), ); }, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: widget.busy || _acting ? null : (_) => _requestTileFocus(), onPointerSignal: widget.busy || _acting ? null : _onPointerSignal, onPointerPanZoomUpdate: widget.busy || _acting ? null : _onPointerPanZoomUpdate, child: GestureDetector( supportedDevices: const { PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.stylus, PointerDeviceKind.invertedStylus, }, onPanStart: widget.busy || _acting ? null : _onPanStart, onPanUpdate: widget.busy || _acting ? null : _onPanUpdate, onPanEnd: widget.busy || _acting ? null : _onPanEnd, onPanCancel: widget.busy || _acting ? null : _onPanCancel, child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Material( color: Colors.transparent, elevation: 0, child: Container( width: constraints.maxWidth, constraints: const BoxConstraints(minHeight: 220), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 24, ), decoration: BoxDecoration( color: AppColors.surfaceElevated.withValues( alpha: 0.9, ), borderRadius: BorderRadius.circular(16), border: Border.all( color: AppColors.accent.withValues(alpha: 0.18), width: 1, ), ), child: Stack( alignment: Alignment.center, clipBehavior: Clip.none, children: [ Positioned( top: 8, bottom: 8, left: _centerSideInset, right: _centerSideInset, child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: AppColors.surface.withValues( alpha: 0.6, ), ), ), ), Positioned( top: 8, bottom: 8, left: _centerSideInset, right: _centerSideInset, child: Center( child: Transform.translate( offset: Offset(0, -_snappedVerticalOffset), child: QuestionGuidGlyph( guid: widget.questionId, size: _glyphSize, displayValue: _snappedSliderValue, ), ), ), ), Positioned( left: 0, top: 10, bottom: 10, child: _HoldSwipeEdgeButton( side: _HoldSwipeEdgeSide.left, enabled: _holdDeferEnabled, progress: _holdDeferController.value, active: _holdDeferInput != _HoldInputSource.none, icon: Icons.reply, ringColor: AppColors.accent, tooltipEnabled: 'Hold to defer question', tooltipDisabled: 'Unavailable while busy', semanticsLabel: 'Hold to defer question', onPointerDown: _onHoldDeferPointerDown, onPointerUp: _onHoldDeferPointerUp, onPointerCancel: _onHoldDeferPointerUp, ), ), Positioned( right: 0, top: 10, bottom: 10, child: _HoldSwipeEdgeButton( side: _HoldSwipeEdgeSide.right, enabled: _holdSaveEnabled, progress: _holdSaveController.value, active: _holdSaveInput != _HoldInputSource.none, icon: Icons.save, ringColor: AppColors.success, tooltipEnabled: 'Hold to save answer', tooltipDisabled: 'Set an answer before saving', semanticsLabel: 'Hold to save answer', onPointerDown: _onHoldSavePointerDown, onPointerUp: _onHoldSavePointerUp, onPointerCancel: _onHoldSavePointerUp, ), ), ], ), ), ), ), ), ), ), ), if (widget.busy) Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(16), child: const ColoredBox( color: Color(0x66000000), child: Center(child: CircularProgressIndicator()), ), ), ), ], ), ); }, ); } } enum _HoldSwipeEdgeSide { left, right } /// Edge control: hold ~1s to sweep the tile off-screen. class _HoldSwipeEdgeButton extends StatelessWidget { const _HoldSwipeEdgeButton({ required this.side, required this.enabled, required this.progress, required this.active, required this.icon, required this.ringColor, required this.tooltipEnabled, required this.tooltipDisabled, required this.semanticsLabel, required this.onPointerDown, required this.onPointerUp, required this.onPointerCancel, }); final _HoldSwipeEdgeSide side; final bool enabled; final double progress; final bool active; final IconData icon; final Color ringColor; final String tooltipEnabled; final String tooltipDisabled; final String semanticsLabel; final void Function(PointerDownEvent event) onPointerDown; final void Function(PointerEvent event) onPointerUp; final void Function(PointerEvent event) onPointerCancel; @override Widget build(BuildContext context) { final Color iconColor = enabled ? AppColors.textPrimary.withValues(alpha: active ? 1 : 0.85) : AppColors.textSecondary.withValues(alpha: 0.35); final Color progressColor = enabled ? ringColor.withValues(alpha: active ? 0.95 : 0.7) : AppColors.textSecondary.withValues(alpha: 0.2); final bool onLeft = side == _HoldSwipeEdgeSide.left; return Listener( onPointerDown: enabled ? onPointerDown : null, onPointerUp: enabled ? onPointerUp : null, onPointerCancel: enabled ? onPointerCancel : null, child: Tooltip( message: enabled ? tooltipEnabled : tooltipDisabled, child: Semantics( button: true, enabled: enabled, label: semanticsLabel, child: Container( width: _holdEdgeButtonWidth, alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.surface.withValues(alpha: enabled ? 0.92 : 0.72), borderRadius: BorderRadius.horizontal( left: onLeft ? Radius.zero : const Radius.circular(12), right: onLeft ? const Radius.circular(12) : Radius.zero, ), border: Border( left: onLeft ? BorderSide.none : BorderSide( color: AppColors.accent.withValues(alpha: 0.14), ), right: onLeft ? BorderSide( color: AppColors.accent.withValues(alpha: 0.14), ) : BorderSide.none, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.32), offset: Offset(onLeft ? 4 : -4, 0), blurRadius: 12, ), BoxShadow( color: Colors.black.withValues(alpha: 0.14), offset: const Offset(0, 2), blurRadius: 6, ), ], ), child: Center( child: SizedBox( width: 40, height: 40, child: Stack( alignment: Alignment.center, children: [ if (progress > 0) CircularProgressIndicator( value: progress, strokeWidth: 2.5, backgroundColor: progressColor.withValues(alpha: 0.2), color: progressColor, ), Icon(icon, size: 20, color: iconColor), ], ), ), ), ), ), ), ); } }