279 lines
9.7 KiB
Dart
279 lines
9.7 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;
|
||
|
||
@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) {
|
||
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: <Widget>[
|
||
Positioned.fill(
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16),
|
||
gradient: LinearGradient(
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
colors: <Color>[
|
||
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: <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: 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()),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|