276 lines
9.8 KiB
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()),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|