cyberhybridhub/lib/widgets/swipe_question_tile.dart
2026-06-03 04:21:42 -05:00

634 lines
21 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;
/// 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<void> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft;
final bool busy;
@override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
}
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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<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);
});
}
/// 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<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) {
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: <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: 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: <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: 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>[
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,
),
],
),
),
),
),
),
),
);
}
}