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; /// 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). class SwipeQuestionTile extends StatefulWidget { const SwipeQuestionTile({ super.key, required this.questionId, required this.onSwipeRight, required this.onSwipeLeft, this.busy = false, }); final String questionId; final Future Function(num answer) onSwipeRight; final Future Function() onSwipeLeft; final bool busy; @override State createState() => _SwipeQuestionTileState(); } class _SwipeQuestionTileState extends State with TickerProviderStateMixin { double _dragOffset = 0; double _verticalOffset = 0; 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 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); }); } /// 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 _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) { final double width = MediaQuery.sizeOf(context).width; 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: GestureDetector( onHorizontalDragUpdate: _horizontalSwipeEnabled ? (DragUpdateDetails details) { setState(() { _dragOffset += details.delta.dx; _dragOffset = _dragOffset.clamp(-width * 0.55, width * 0.55); }); } : null, onHorizontalDragEnd: _horizontalSwipeEnabled ? (_) => unawaited(_releaseDrag()) : null, 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: Listener( onPointerSignal: (PointerSignalEvent event) { if (widget.busy || _acting) { return; } if (event is PointerScrollEvent) { _stepSliderFromScroll( event.scrollDelta.dy, ); } }, child: GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragUpdate: widget.busy || _acting ? null : (DragUpdateDetails details) => _applyVerticalInputDelta( details.delta.dy, ), 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, ), ], ), ), ), ), ), ), ); } }