cyberhybridhub/lib/widgets/swipe_question_tile.dart

276 lines
9.8 KiB
Dart

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<void> Function(num answer) onSwipeRight;
final Future<void> Function() onSwipeLeft;
final bool busy;
@override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
}
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
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;
bool get _atZero => _snappedSliderValue == 0;
bool get _horizontalSwipeEnabled => !widget.busy && !_acting && !_atZero;
@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<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: constraints.maxWidth * 0.22,
right: constraints.maxWidth * 0.22,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: AppColors.surface.withValues(
alpha: 0.6,
),
),
),
),
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,
);
if (_atZero) {
_dragOffset = 0;
}
_maybeTriggerSnapFeedback(
_snappedSliderValue,
);
});
},
child: Center(
child: Transform.translate(
offset:
Offset(0, -_snappedVerticalOffset),
child: QuestionGuidGlyph(
guid: widget.questionId,
size: _glyphSize,
displayValue: _snappedSliderValue,
),
),
),
),
),
],
),
),
),
),
),
),
),
if (widget.busy)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: const ColoredBox(
color: Color(0x66000000),
child: Center(child: CircularProgressIndicator()),
),
),
),
],
);
},
);
}
}