781 lines
25 KiB
Dart
781 lines
25 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../guid/guid_glyph_shape.dart';
|
|
import '../theme/app_theme.dart';
|
|
|
|
const double _holdEdgeButtonWidth = 58;
|
|
|
|
/// Equal left/right margin for the slider; edge buttons sit in the gutters.
|
|
const double _centerSideInset = _holdEdgeButtonWidth + 8;
|
|
|
|
enum _LockedDragAxis { horizontal, vertical, rejected }
|
|
|
|
/// 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).
|
|
///
|
|
/// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
|
|
/// cardinal axis; diagonal swipes are ignored.
|
|
class SwipeQuestionTile extends StatefulWidget {
|
|
const SwipeQuestionTile({
|
|
super.key,
|
|
required this.questionId,
|
|
required this.onSwipeRight,
|
|
required this.onSwipeLeft,
|
|
this.busy = false,
|
|
this.swipeAxisToleranceDegrees = 25,
|
|
});
|
|
|
|
final String questionId;
|
|
final Future<void> Function(num answer) onSwipeRight;
|
|
final Future<void> Function() onSwipeLeft;
|
|
final bool busy;
|
|
|
|
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
|
|
final double swipeAxisToleranceDegrees;
|
|
|
|
@override
|
|
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
|
|
}
|
|
|
|
class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|
with TickerProviderStateMixin {
|
|
double _dragOffset = 0;
|
|
double _verticalOffset = 0;
|
|
double _panTotalDx = 0;
|
|
double _panTotalDy = 0;
|
|
double _scrollAccumulatedDy = 0;
|
|
_LockedDragAxis? _lockedDragAxis;
|
|
bool _acting = false;
|
|
bool _holdSavePointerDown = false;
|
|
bool _holdDeferPointerDown = false;
|
|
int _lastSnappedValue = 0;
|
|
late final AnimationController _snapController;
|
|
late final AnimationController _holdSaveController;
|
|
late final AnimationController _holdDeferController;
|
|
|
|
static const double _swipeThreshold = 96;
|
|
static const double _axisLockSlop = 14;
|
|
static const double _scrollStepPixels = 100;
|
|
static const Duration _holdEdgeDuration = Duration(seconds: 1);
|
|
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 _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
|
|
|
|
bool get _holdDeferEnabled => !widget.busy && !_acting;
|
|
|
|
bool get _holdSaveActive =>
|
|
_holdSavePointerDown || _holdSaveController.value > 0;
|
|
|
|
bool get _holdDeferActive =>
|
|
_holdDeferPointerDown || _holdDeferController.value > 0;
|
|
|
|
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
|
|
|
|
bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_snapController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 140),
|
|
);
|
|
_holdSaveController =
|
|
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
|
..addListener(_syncHoldSaveDrag)
|
|
..addStatusListener(_onHoldSaveStatus);
|
|
_holdDeferController =
|
|
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
|
..addListener(_syncHoldDeferDrag)
|
|
..addStatusListener(_onHoldDeferStatus);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_snapController.dispose();
|
|
_holdSaveController.dispose();
|
|
_holdDeferController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _syncHoldSaveDrag() {
|
|
if (!mounted || _acting || _holdDeferActive) {
|
|
return;
|
|
}
|
|
final double width = MediaQuery.sizeOf(context).width;
|
|
final double next = _holdSaveController.value * width;
|
|
if (next == _dragOffset) {
|
|
return;
|
|
}
|
|
setState(() => _dragOffset = next);
|
|
}
|
|
|
|
void _syncHoldDeferDrag() {
|
|
if (!mounted || _acting || _holdSaveActive) {
|
|
return;
|
|
}
|
|
final double width = MediaQuery.sizeOf(context).width;
|
|
final double next = -_holdDeferController.value * width;
|
|
if (next == _dragOffset) {
|
|
return;
|
|
}
|
|
setState(() => _dragOffset = next);
|
|
}
|
|
|
|
void _onHoldEdgeDismissed() {
|
|
if (mounted && !_acting) {
|
|
setState(() => _dragOffset = 0);
|
|
}
|
|
}
|
|
|
|
void _onHoldSaveStatus(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed) {
|
|
if (!_holdDeferActive) {
|
|
_onHoldEdgeDismissed();
|
|
}
|
|
} else if (status == AnimationStatus.completed &&
|
|
_holdSavePointerDown &&
|
|
mounted) {
|
|
_holdSavePointerDown = false;
|
|
unawaited(_completeHoldSave());
|
|
}
|
|
}
|
|
|
|
void _onHoldDeferStatus(AnimationStatus status) {
|
|
if (status == AnimationStatus.dismissed) {
|
|
if (!_holdSaveActive) {
|
|
_onHoldEdgeDismissed();
|
|
}
|
|
} else if (status == AnimationStatus.completed &&
|
|
_holdDeferPointerDown &&
|
|
mounted) {
|
|
_holdDeferPointerDown = false;
|
|
unawaited(_completeHoldDefer());
|
|
}
|
|
}
|
|
|
|
void _onHoldSavePointerDown(PointerDownEvent event) {
|
|
if (!_holdSaveEnabled || _holdDeferActive) {
|
|
return;
|
|
}
|
|
_holdSavePointerDown = true;
|
|
_holdSaveController.forward(from: _holdSaveController.value);
|
|
}
|
|
|
|
void _onHoldSavePointerUp(PointerEvent event) {
|
|
if (!_holdSavePointerDown) {
|
|
return;
|
|
}
|
|
_holdSavePointerDown = false;
|
|
if (_holdSaveController.value >= 1.0) {
|
|
unawaited(_completeHoldSave());
|
|
return;
|
|
}
|
|
unawaited(_holdSaveController.reverse());
|
|
}
|
|
|
|
Future<void> _completeHoldSave() async {
|
|
if (_acting || widget.busy || _atZero || !mounted) {
|
|
return;
|
|
}
|
|
final double width = MediaQuery.sizeOf(context).width;
|
|
setState(() {
|
|
_acting = true;
|
|
_dragOffset = width;
|
|
});
|
|
await widget.onSwipeRight(_snappedSliderValue);
|
|
if (mounted) {
|
|
setState(() {
|
|
_acting = false;
|
|
_dragOffset = 0;
|
|
});
|
|
_holdSaveController.reset();
|
|
}
|
|
}
|
|
|
|
void _onHoldDeferPointerDown(PointerDownEvent event) {
|
|
if (!_holdDeferEnabled || _holdSaveActive) {
|
|
return;
|
|
}
|
|
_holdDeferPointerDown = true;
|
|
_holdDeferController.forward(from: _holdDeferController.value);
|
|
}
|
|
|
|
void _onHoldDeferPointerUp(PointerEvent event) {
|
|
if (!_holdDeferPointerDown) {
|
|
return;
|
|
}
|
|
_holdDeferPointerDown = false;
|
|
if (_holdDeferController.value >= 1.0) {
|
|
unawaited(_completeHoldDefer());
|
|
return;
|
|
}
|
|
unawaited(_holdDeferController.reverse());
|
|
}
|
|
|
|
Future<void> _completeHoldDefer() async {
|
|
if (_acting || widget.busy || !mounted) {
|
|
return;
|
|
}
|
|
final double width = MediaQuery.sizeOf(context).width;
|
|
setState(() {
|
|
_acting = true;
|
|
_dragOffset = -width;
|
|
});
|
|
await widget.onSwipeLeft();
|
|
if (mounted) {
|
|
setState(() {
|
|
_acting = false;
|
|
_dragOffset = 0;
|
|
});
|
|
_holdDeferController.reset();
|
|
}
|
|
}
|
|
|
|
/// 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 _applyVerticalInputDelta(double dy) {
|
|
setState(() {
|
|
_verticalOffset -= dy;
|
|
_verticalOffset = _verticalOffset.clamp(
|
|
-_maxVerticalDrag,
|
|
_maxVerticalDrag,
|
|
);
|
|
if (_atZero) {
|
|
_dragOffset = 0;
|
|
}
|
|
_maybeTriggerSnapFeedback(_snappedSliderValue);
|
|
});
|
|
}
|
|
|
|
void _applyHorizontalDelta(double dx, double maxOffset) {
|
|
setState(() {
|
|
_dragOffset += dx;
|
|
_dragOffset = _dragOffset.clamp(-maxOffset, maxOffset);
|
|
});
|
|
}
|
|
|
|
bool _angleNearCardinal(double angleDeg, double targetDeg) {
|
|
var diff = angleDeg - targetDeg;
|
|
while (diff > 180) {
|
|
diff -= 360;
|
|
}
|
|
while (diff < -180) {
|
|
diff += 360;
|
|
}
|
|
return diff.abs() <= widget.swipeAxisToleranceDegrees;
|
|
}
|
|
|
|
bool _isHorizontalAngle(double angleDeg) =>
|
|
_angleNearCardinal(angleDeg, 0) ||
|
|
_angleNearCardinal(angleDeg, 180) ||
|
|
_angleNearCardinal(angleDeg, -180);
|
|
|
|
bool _isVerticalAngle(double angleDeg) =>
|
|
_angleNearCardinal(angleDeg, 90) || _angleNearCardinal(angleDeg, -90);
|
|
|
|
_LockedDragAxis _resolveDragAxis(double totalDx, double totalDy) {
|
|
final double angleDeg = math.atan2(totalDy, totalDx) * 180 / math.pi;
|
|
final bool horizontal = _isHorizontalAngle(angleDeg);
|
|
final bool vertical = _isVerticalAngle(angleDeg);
|
|
if (horizontal && vertical) {
|
|
return totalDx.abs() >= totalDy.abs()
|
|
? _LockedDragAxis.horizontal
|
|
: _LockedDragAxis.vertical;
|
|
}
|
|
if (horizontal) {
|
|
return _LockedDragAxis.horizontal;
|
|
}
|
|
if (vertical) {
|
|
return _LockedDragAxis.vertical;
|
|
}
|
|
return _LockedDragAxis.rejected;
|
|
}
|
|
|
|
void _resetPanGesture() {
|
|
_panTotalDx = 0;
|
|
_panTotalDy = 0;
|
|
_lockedDragAxis = null;
|
|
}
|
|
|
|
void _onPanStart(DragStartDetails details) {
|
|
if (widget.busy || _acting || _holdEdgeActive) {
|
|
return;
|
|
}
|
|
_resetPanGesture();
|
|
}
|
|
|
|
void _onPanUpdate(DragUpdateDetails details) {
|
|
if (widget.busy || _acting || _holdEdgeActive) {
|
|
return;
|
|
}
|
|
|
|
_panTotalDx += details.delta.dx;
|
|
_panTotalDy += details.delta.dy;
|
|
|
|
if (_lockedDragAxis == null) {
|
|
final double distance = math.sqrt(
|
|
_panTotalDx * _panTotalDx + _panTotalDy * _panTotalDy,
|
|
);
|
|
if (distance < _axisLockSlop) {
|
|
return;
|
|
}
|
|
|
|
var axis = _resolveDragAxis(_panTotalDx, _panTotalDy);
|
|
if (axis == _LockedDragAxis.horizontal && !_horizontalSwipeEnabled) {
|
|
axis = _LockedDragAxis.rejected;
|
|
}
|
|
_lockedDragAxis = axis;
|
|
|
|
final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55;
|
|
switch (axis) {
|
|
case _LockedDragAxis.horizontal:
|
|
_applyHorizontalDelta(_panTotalDx, maxHorizontal);
|
|
case _LockedDragAxis.vertical:
|
|
_applyVerticalInputDelta(_panTotalDy);
|
|
case _LockedDragAxis.rejected:
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_lockedDragAxis == _LockedDragAxis.rejected) {
|
|
return;
|
|
}
|
|
|
|
final double maxHorizontal = MediaQuery.sizeOf(context).width * 0.55;
|
|
switch (_lockedDragAxis!) {
|
|
case _LockedDragAxis.horizontal:
|
|
_applyHorizontalDelta(details.delta.dx, maxHorizontal);
|
|
case _LockedDragAxis.vertical:
|
|
_applyVerticalInputDelta(details.delta.dy);
|
|
case _LockedDragAxis.rejected:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _onPanEnd(DragEndDetails details) {
|
|
final _LockedDragAxis? axis = _lockedDragAxis;
|
|
_resetPanGesture();
|
|
|
|
if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) {
|
|
unawaited(_releaseDrag());
|
|
} else if (axis == _LockedDragAxis.rejected && mounted) {
|
|
setState(() => _dragOffset = 0);
|
|
}
|
|
}
|
|
|
|
void _onPanCancel() {
|
|
final _LockedDragAxis? axis = _lockedDragAxis;
|
|
_resetPanGesture();
|
|
if (axis == _LockedDragAxis.horizontal && _horizontalSwipeEnabled) {
|
|
unawaited(_releaseDrag());
|
|
} else if (mounted) {
|
|
setState(() => _dragOffset = 0);
|
|
}
|
|
}
|
|
|
|
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
|
|
void _stepSliderFromScroll(double scrollDy) {
|
|
if (scrollDy == 0) {
|
|
return;
|
|
}
|
|
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
|
|
_sliderMin,
|
|
_sliderMax,
|
|
);
|
|
if (next == _snappedSliderValue) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_verticalOffset = next / _sliderMax * _maxVerticalDrag;
|
|
if (_atZero) {
|
|
_dragOffset = 0;
|
|
}
|
|
_maybeTriggerSnapFeedback(next);
|
|
});
|
|
}
|
|
|
|
void _accumulateScrollDelta(double dy) {
|
|
if (widget.busy || _acting || dy == 0) {
|
|
return;
|
|
}
|
|
_scrollAccumulatedDy += dy;
|
|
while (_scrollAccumulatedDy.abs() >= _scrollStepPixels) {
|
|
final double step = _scrollAccumulatedDy > 0
|
|
? _scrollStepPixels
|
|
: -_scrollStepPixels;
|
|
_stepSliderFromScroll(step);
|
|
_scrollAccumulatedDy -= step;
|
|
}
|
|
}
|
|
|
|
void _onPointerSignal(PointerSignalEvent event) {
|
|
if (event is PointerScrollEvent) {
|
|
_accumulateScrollDelta(event.scrollDelta.dy);
|
|
}
|
|
}
|
|
|
|
void _onPointerPanZoomUpdate(PointerPanZoomUpdateEvent event) {
|
|
_accumulateScrollDelta(event.panDelta.dy);
|
|
}
|
|
|
|
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) {
|
|
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: Listener(
|
|
behavior: HitTestBehavior.translucent,
|
|
onPointerSignal: widget.busy || _acting
|
|
? null
|
|
: _onPointerSignal,
|
|
onPointerPanZoomUpdate: widget.busy || _acting
|
|
? null
|
|
: _onPointerPanZoomUpdate,
|
|
child: GestureDetector(
|
|
supportedDevices: const <PointerDeviceKind>{
|
|
PointerDeviceKind.touch,
|
|
PointerDeviceKind.mouse,
|
|
PointerDeviceKind.stylus,
|
|
PointerDeviceKind.invertedStylus,
|
|
},
|
|
onPanStart: widget.busy || _acting ? null : _onPanStart,
|
|
onPanUpdate: widget.busy || _acting ? null : _onPanUpdate,
|
|
onPanEnd: widget.busy || _acting ? null : _onPanEnd,
|
|
onPanCancel: widget.busy || _acting ? null : _onPanCancel,
|
|
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: _centerSideInset,
|
|
right: _centerSideInset,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(14),
|
|
color: AppColors.surface.withValues(
|
|
alpha: 0.6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 8,
|
|
bottom: 8,
|
|
left: _centerSideInset,
|
|
right: _centerSideInset,
|
|
child: Center(
|
|
child: Transform.translate(
|
|
offset: Offset(0, -_snappedVerticalOffset),
|
|
child: QuestionGuidGlyph(
|
|
guid: widget.questionId,
|
|
size: _glyphSize,
|
|
displayValue: _snappedSliderValue,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
top: 10,
|
|
bottom: 10,
|
|
child: _HoldSwipeEdgeButton(
|
|
side: _HoldSwipeEdgeSide.left,
|
|
enabled: _holdDeferEnabled,
|
|
progress: _holdDeferController.value,
|
|
active: _holdDeferPointerDown,
|
|
icon: Icons.reply,
|
|
ringColor: AppColors.accent,
|
|
tooltipEnabled: 'Hold to defer question',
|
|
tooltipDisabled: 'Unavailable while busy',
|
|
semanticsLabel: 'Hold to defer question',
|
|
onPointerDown: _onHoldDeferPointerDown,
|
|
onPointerUp: _onHoldDeferPointerUp,
|
|
onPointerCancel: _onHoldDeferPointerUp,
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
top: 10,
|
|
bottom: 10,
|
|
child: _HoldSwipeEdgeButton(
|
|
side: _HoldSwipeEdgeSide.right,
|
|
enabled: _holdSaveEnabled,
|
|
progress: _holdSaveController.value,
|
|
active: _holdSavePointerDown,
|
|
icon: Icons.save,
|
|
ringColor: AppColors.success,
|
|
tooltipEnabled: 'Hold to save answer',
|
|
tooltipDisabled:
|
|
'Set an answer before saving',
|
|
semanticsLabel: 'Hold to save answer',
|
|
onPointerDown: _onHoldSavePointerDown,
|
|
onPointerUp: _onHoldSavePointerUp,
|
|
onPointerCancel: _onHoldSavePointerUp,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (widget.busy)
|
|
Positioned.fill(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: const ColoredBox(
|
|
color: Color(0x66000000),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _HoldSwipeEdgeSide { left, right }
|
|
|
|
/// Edge control: hold ~1s to sweep the tile off-screen.
|
|
class _HoldSwipeEdgeButton extends StatelessWidget {
|
|
const _HoldSwipeEdgeButton({
|
|
required this.side,
|
|
required this.enabled,
|
|
required this.progress,
|
|
required this.active,
|
|
required this.icon,
|
|
required this.ringColor,
|
|
required this.tooltipEnabled,
|
|
required this.tooltipDisabled,
|
|
required this.semanticsLabel,
|
|
required this.onPointerDown,
|
|
required this.onPointerUp,
|
|
required this.onPointerCancel,
|
|
});
|
|
|
|
final _HoldSwipeEdgeSide side;
|
|
final bool enabled;
|
|
final double progress;
|
|
final bool active;
|
|
final IconData icon;
|
|
final Color ringColor;
|
|
final String tooltipEnabled;
|
|
final String tooltipDisabled;
|
|
final String semanticsLabel;
|
|
final void Function(PointerDownEvent event) onPointerDown;
|
|
final void Function(PointerEvent event) onPointerUp;
|
|
final void Function(PointerEvent event) onPointerCancel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color iconColor = enabled
|
|
? AppColors.textPrimary.withValues(alpha: active ? 1 : 0.85)
|
|
: AppColors.textSecondary.withValues(alpha: 0.35);
|
|
final Color progressColor = enabled
|
|
? ringColor.withValues(alpha: active ? 0.95 : 0.7)
|
|
: AppColors.textSecondary.withValues(alpha: 0.2);
|
|
final bool onLeft = side == _HoldSwipeEdgeSide.left;
|
|
|
|
return Listener(
|
|
onPointerDown: enabled ? onPointerDown : null,
|
|
onPointerUp: enabled ? onPointerUp : null,
|
|
onPointerCancel: enabled ? onPointerCancel : null,
|
|
child: Tooltip(
|
|
message: enabled ? tooltipEnabled : tooltipDisabled,
|
|
child: Semantics(
|
|
button: true,
|
|
enabled: enabled,
|
|
label: semanticsLabel,
|
|
child: Container(
|
|
width: _holdEdgeButtonWidth,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface.withValues(alpha: enabled ? 0.92 : 0.72),
|
|
borderRadius: BorderRadius.horizontal(
|
|
left: onLeft ? Radius.zero : const Radius.circular(12),
|
|
right: onLeft ? const Radius.circular(12) : Radius.zero,
|
|
),
|
|
border: Border(
|
|
left: onLeft
|
|
? BorderSide.none
|
|
: BorderSide(
|
|
color: AppColors.accent.withValues(alpha: 0.14),
|
|
),
|
|
right: onLeft
|
|
? BorderSide(
|
|
color: AppColors.accent.withValues(alpha: 0.14),
|
|
)
|
|
: BorderSide.none,
|
|
),
|
|
boxShadow: <BoxShadow>[
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.32),
|
|
offset: Offset(onLeft ? 4 : -4, 0),
|
|
blurRadius: 12,
|
|
),
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.14),
|
|
offset: const Offset(0, 2),
|
|
blurRadius: 6,
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 40,
|
|
height: 40,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: <Widget>[
|
|
if (progress > 0)
|
|
CircularProgressIndicator(
|
|
value: progress,
|
|
strokeWidth: 2.5,
|
|
backgroundColor: progressColor.withValues(alpha: 0.2),
|
|
color: progressColor,
|
|
),
|
|
Icon(icon, size: 20, color: iconColor),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|