cyberhybridhub/lib/widgets/swipe_question_tile.dart
2026-05-31 11:17:12 -05:00

279 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()),
),
),
],
);
},
);
}
}