import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../guid/guid_glyph_shape.dart'; import '../theme/app_theme.dart'; /// 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 SingleTickerProviderStateMixin { double _dragOffset = 0; double _verticalOffset = 0; bool _acting = false; int _lastSnappedValue = 0; late final AnimationController _snapController; static const double _swipeThreshold = 96; 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; @override void initState() { super.initState(); _snapController = AnimationController( vsync: this, duration: const Duration(milliseconds: 140), ); } @override void dispose() { _snapController.dispose(); super.dispose(); } /// 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 _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) { 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; final double progress = (_dragOffset / _swipeThreshold).clamp(-1.0, 1.0); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { // Container vertical padding (24×2) + track insets (8×2). 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, children: [ Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Colors.redAccent.withValues( alpha: 0.15 + 0.35 * (-progress).clamp(0.0, 1.0), ), AppColors.surfaceElevated, AppColors.success.withValues( alpha: 0.15 + 0.35 * progress.clamp(0.0, 1.0), ), ], ), ), ), ), 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: widget.busy || _acting ? null : (DragUpdateDetails details) { setState(() { _dragOffset += details.delta.dx; _dragOffset = _dragOffset.clamp(-width * 0.55, width * 0.55); }); }, onHorizontalDragEnd: widget.busy || _acting ? null : (_) => unawaited(_releaseDrag()), child: Material( color: AppColors.surfaceElevated, elevation: 4, shadowColor: Colors.black45, borderRadius: BorderRadius.circular(16), child: Container( width: constraints.maxWidth, constraints: const BoxConstraints(minHeight: 220), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 24, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), ), child: Stack( alignment: Alignment.center, children: [ Positioned( top: 8, bottom: 8, left: constraints.maxWidth * 0.22, right: constraints.maxWidth * 0.22, child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), color: Color.lerp( AppColors.surfaceElevated, AppColors.surface, 0.45, ), ), ), ), Positioned( top: 8, bottom: 8, left: constraints.maxWidth * 0.22, right: constraints.maxWidth * 0.22, child: GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragUpdate: widget.busy || _acting ? null : (DragUpdateDetails details) { setState(() { _verticalOffset -= details.delta.dy; _verticalOffset = _verticalOffset.clamp( -_maxVerticalDrag, _maxVerticalDrag, ); _maybeTriggerSnapFeedback( _snappedSliderValue, ); }); }, child: Center( child: Transform.translate( offset: Offset(0, -_snappedVerticalOffset), child: QuestionGuidGlyph( guid: widget.questionId, size: _glyphSize, displayValue: _snappedSliderValue, ), ), ), ), ), ], ), ), ), ), ), ), if (widget.busy) const Positioned.fill( child: ColoredBox( color: Color(0x66000000), child: Center(child: CircularProgressIndicator()), ), ), ], ); }, ); } }