cyberhybridhub/lib/widgets/swipe_question_tile.dart
2026-06-03 05:12:02 -05:00

781 lines
25 KiB
Dart

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<void> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft;
final bool busy;
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
final double swipeAxisToleranceDegrees;
@override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
}
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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<void> _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<void> _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<void> _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: <Widget>[
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>{
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: <Widget>[
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>[
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: <Widget>[
if (progress > 0)
CircularProgressIndicator(
value: progress,
strokeWidth: 2.5,
backgroundColor: progressColor.withValues(alpha: 0.2),
color: progressColor,
),
Icon(icon, size: 20, color: iconColor),
],
),
),
),
),
),
),
);
}
}