199 lines
7.0 KiB
Dart
199 lines
7.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.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> {
|
|
double _dragOffset = 0;
|
|
double _verticalOffset = 0;
|
|
bool _acting = false;
|
|
|
|
static const double _swipeThreshold = 96;
|
|
static const double _maxVerticalDrag = 120;
|
|
static const double _sliderMin = -10;
|
|
static const double _sliderMax = 10;
|
|
|
|
/// Swipe up → +10, swipe down → -10 (linear between).
|
|
double get _sliderValue =>
|
|
(_verticalOffset / _maxVerticalDrag * _sliderMax).clamp(_sliderMin, _sliderMax);
|
|
|
|
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(_sliderValue);
|
|
} 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) {
|
|
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: 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: 20,
|
|
bottom: 20,
|
|
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: 24,
|
|
bottom: 24,
|
|
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,
|
|
);
|
|
});
|
|
},
|
|
child: Center(
|
|
child: Transform.translate(
|
|
offset: Offset(0, -_verticalOffset),
|
|
child: QuestionGuidGlyph(
|
|
guid: widget.questionId,
|
|
displayValue: _sliderValue,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (widget.busy)
|
|
const Positioned.fill(
|
|
child: ColoredBox(
|
|
color: Color(0x66000000),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|