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 '../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 } /// 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). /// /// 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; bool _holdSavePointerDown = false; bool _holdDeferPointerDown = false; int _lastSnappedValue = 0; 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 => _holdSavePointerDown || _holdSaveController.value > 0; bool get _holdDeferActive => _holdDeferPointerDown || _holdDeferController.value > 0; bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive; bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive; @override void initState() { super.initState(); _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); } @override void dispose() { _snapController.dispose(); _holdSaveController.dispose(); _holdDeferController.dispose(); super.dispose(); } 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 && _holdSavePointerDown && mounted) { _holdSavePointerDown = false; unawaited(_completeHoldSave()); } } void _onHoldDeferStatus(AnimationStatus status) { if (status == AnimationStatus.dismissed) { if (!_holdSaveActive) { _onHoldEdgeDismissed(); } } else if (status == AnimationStatus.completed && _holdDeferPointerDown && mounted) { _holdDeferPointerDown = false; unawaited(_completeHoldDefer()); } } void _onHoldSavePointerDown(PointerDownEvent event) { if (!_holdSaveEnabled || _holdDeferActive) { return; } _holdSavePointerDown = true; _holdSaveController.forward(from: _holdSaveController.value); } void _onHoldSavePointerUp(PointerEvent event) { if (!_holdSavePointerDown) { return; } _holdSavePointerDown = false; if (_holdSaveController.value >= 1.0) { unawaited(_completeHoldSave()); return; } unawaited(_holdSaveController.reverse()); } 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 _onHoldDeferPointerDown(PointerDownEvent event) { if (!_holdDeferEnabled || _holdSaveActive) { return; } _holdDeferPointerDown = true; _holdDeferController.forward(from: _holdDeferController.value); } void _onHoldDeferPointerUp(PointerEvent event) { if (!_holdDeferPointerDown) { return; } _holdDeferPointerDown = false; if (_holdDeferController.value >= 1.0) { unawaited(_completeHoldDefer()); return; } unawaited(_holdDeferController.reverse()); } 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); } } /// One wheel notch / trackpad tick → one integer on the [-10, 10] scale. void _stepSliderFromScroll(double scrollDy) { if (scrollDy == 0) { return; } final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp( _sliderMin, _sliderMax, ); if (next == _snappedSliderValue) { return; } setState(() { _verticalOffset = next / _sliderMax * _maxVerticalDrag; if (_atZero) { _dragOffset = 0; } _maybeTriggerSnapFeedback(next); }); } 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 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, 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: _holdDeferPointerDown, 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: _holdSavePointerDown, 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), ], ), ), ), ), ), ), ); } }