reseting
This commit is contained in:
parent
0c4c72f9c9
commit
e64d21dc2e
15
lib/models/guess_score_reset_result.dart
Normal file
15
lib/models/guess_score_reset_result.dart
Normal file
@ -0,0 +1,15 @@
|
||||
import 'guess_score_summary.dart';
|
||||
import 'incoming_question.dart';
|
||||
|
||||
/// Outcome of resetting guess score and clearing guess-game progress.
|
||||
class GuessScoreResetResult {
|
||||
const GuessScoreResetResult({
|
||||
required this.score,
|
||||
required this.unansweredCount,
|
||||
this.question,
|
||||
});
|
||||
|
||||
final GuessScoreSummary score;
|
||||
final int unansweredCount;
|
||||
final IncomingQuestion? question;
|
||||
}
|
||||
@ -40,7 +40,9 @@ class HomeScreen extends StatelessWidget {
|
||||
ProfileSyncStatus.idle => AppColors.textSecondary,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
centerTitle: true,
|
||||
@ -258,6 +260,28 @@ class HomeScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: QuestionsHubService.instance.scoreResetBusy,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
if (!QuestionsHubService.instance.scoreResetBusy.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Stack(
|
||||
key: const Key('guess-score-reset-blocking-overlay'),
|
||||
children: <Widget>[
|
||||
ModalBarrier(
|
||||
dismissible: false,
|
||||
color: Colors.black.withValues(alpha: 0.54),
|
||||
),
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -403,14 +427,8 @@ Future<void> _confirmResetGuessScore(BuildContext context) async {
|
||||
return;
|
||||
}
|
||||
|
||||
final bool ok =
|
||||
await QuestionsHubService.instance.resetGuessScoreSummary();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (ok) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
await QuestionsHubService.instance.resetGuessScoreSummary();
|
||||
}
|
||||
|
||||
class _ScoreStatRow extends StatelessWidget {
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../config/api_config.dart';
|
||||
import '../models/guess_score_reset_result.dart';
|
||||
import '../models/guess_score_summary.dart';
|
||||
import '../models/incoming_question.dart';
|
||||
import '../models/question_defer_result.dart';
|
||||
@ -177,7 +178,7 @@ class QuestionsApiService {
|
||||
return GuessScoreSummary.fromJson(scoreJson);
|
||||
}
|
||||
|
||||
Future<GuessScoreSummary?> resetGuessScoreSummary() async {
|
||||
Future<GuessScoreResetResult?> resetGuessScoreSummary() async {
|
||||
final String? token = await AuthService.instance.getIdToken();
|
||||
if (token == null) {
|
||||
return null;
|
||||
@ -198,10 +199,17 @@ class QuestionsApiService {
|
||||
jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final Map<String, dynamic>? scoreJson =
|
||||
body['score'] as Map<String, dynamic>?;
|
||||
if (scoreJson == null) {
|
||||
return GuessScoreSummary.empty;
|
||||
}
|
||||
return GuessScoreSummary.fromJson(scoreJson);
|
||||
final Map<String, dynamic>? questionJson =
|
||||
body['question'] as Map<String, dynamic>?;
|
||||
return GuessScoreResetResult(
|
||||
score: scoreJson == null
|
||||
? GuessScoreSummary.empty
|
||||
: GuessScoreSummary.fromJson(scoreJson),
|
||||
unansweredCount: (body['unansweredCount'] as num?)?.toInt() ?? 0,
|
||||
question: questionJson == null
|
||||
? null
|
||||
: IncomingQuestion.fromJson(questionJson),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _authHeaders(String token) {
|
||||
|
||||
@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:signalr_netcore/signalr_client.dart';
|
||||
|
||||
import '../config/api_config.dart';
|
||||
import '../models/guess_score_reset_result.dart';
|
||||
import '../models/guess_score_summary.dart';
|
||||
import '../models/incoming_question.dart';
|
||||
import '../models/question_defer_result.dart';
|
||||
@ -27,6 +28,7 @@ class QuestionsHubService {
|
||||
final ValueNotifier<List<IncomingQuestion>> questionQueue =
|
||||
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
|
||||
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<GuessScoreSummary> guessScoreSummary =
|
||||
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
|
||||
|
||||
@ -300,12 +302,21 @@ class QuestionsHubService {
|
||||
|
||||
/// Clears cumulative guess score and answer statistics on the server.
|
||||
Future<bool> resetGuessScoreSummary() async {
|
||||
final GuessScoreSummary? score = await _api.resetGuessScoreSummary();
|
||||
if (score == null) {
|
||||
return false;
|
||||
scoreResetBusy.value = true;
|
||||
try {
|
||||
final GuessScoreResetResult? result = await _api.resetGuessScoreSummary();
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
guessScoreSummary.value = result.score;
|
||||
_clearPendingUi();
|
||||
if (result.question != null) {
|
||||
_applyIncoming(result.question!);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
scoreResetBusy.value = false;
|
||||
}
|
||||
guessScoreSummary.value = score;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Clears pending question UI; keeps the SignalR connection alive.
|
||||
|
||||
@ -13,9 +13,14 @@ 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,
|
||||
@ -23,6 +28,7 @@ class SwipeQuestionTile extends StatefulWidget {
|
||||
required this.onSwipeRight,
|
||||
required this.onSwipeLeft,
|
||||
this.busy = false,
|
||||
this.swipeAxisToleranceDegrees = 25,
|
||||
});
|
||||
|
||||
final String questionId;
|
||||
@ -30,6 +36,9 @@ class SwipeQuestionTile extends StatefulWidget {
|
||||
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();
|
||||
}
|
||||
@ -38,6 +47,10 @@ 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;
|
||||
@ -47,6 +60,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
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;
|
||||
@ -70,8 +85,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
|
||||
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
|
||||
|
||||
bool get _horizontalSwipeEnabled =>
|
||||
_holdSaveEnabled && !_holdEdgeActive;
|
||||
bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -80,16 +94,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
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);
|
||||
_holdSaveController =
|
||||
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
||||
..addListener(_syncHoldSaveDrag)
|
||||
..addStatusListener(_onHoldSaveStatus);
|
||||
_holdDeferController =
|
||||
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
||||
..addListener(_syncHoldDeferDrag)
|
||||
..addStatusListener(_onHoldDeferStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -236,9 +248,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
|
||||
/// Swipe up → +10, swipe down → -10; snaps to whole numbers.
|
||||
int get _snappedSliderValue =>
|
||||
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax)
|
||||
.round()
|
||||
.clamp(_sliderMin, _sliderMax);
|
||||
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax).round().clamp(
|
||||
_sliderMin,
|
||||
_sliderMax,
|
||||
);
|
||||
|
||||
double get _clampedVerticalOffset =>
|
||||
_verticalOffset.clamp(-_maxVerticalDrag, _maxVerticalDrag);
|
||||
@ -260,13 +273,142 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
|
||||
_sliderMin,
|
||||
_sliderMax,
|
||||
);
|
||||
if (next == _snappedSliderValue) {
|
||||
return;
|
||||
}
|
||||
@ -279,6 +421,30 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@ -327,16 +493,18 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
|
||||
@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);
|
||||
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,
|
||||
@ -351,147 +519,130 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
||||
child: AnimatedBuilder(
|
||||
animation: _snapController,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
final ({double shakeX, double scale}) motion =
|
||||
_snapMotion(_snapController.value);
|
||||
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: 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,
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceElevated.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
left: _centerSideInset,
|
||||
right: _centerSideInset,
|
||||
child: Listener(
|
||||
onPointerSignal: (PointerSignalEvent event) {
|
||||
if (widget.busy || _acting) {
|
||||
return;
|
||||
}
|
||||
if (event is PointerScrollEvent) {
|
||||
_stepSliderFromScroll(
|
||||
event.scrollDelta.dy,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragUpdate: widget.busy || _acting
|
||||
? null
|
||||
: (DragUpdateDetails details) =>
|
||||
_applyVerticalInputDelta(
|
||||
details.delta.dy,
|
||||
),
|
||||
child: Center(
|
||||
child: Transform.translate(
|
||||
offset:
|
||||
Offset(0, -_snappedVerticalOffset),
|
||||
child: QuestionGuidGlyph(
|
||||
guid: widget.questionId,
|
||||
size: _glyphSize,
|
||||
displayValue: _snappedSliderValue,
|
||||
),
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -616,11 +767,7 @@ class _HoldSwipeEdgeButton extends StatelessWidget {
|
||||
backgroundColor: progressColor.withValues(alpha: 0.2),
|
||||
color: progressColor,
|
||||
),
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: iconColor,
|
||||
),
|
||||
Icon(icon, size: 20, color: iconColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -94,8 +94,14 @@ Handler questionsHandler({
|
||||
try {
|
||||
final Map<String, dynamic> score =
|
||||
await questionsDb.resetGuessScoreSummary(firebaseUid);
|
||||
final Map<String, dynamic>? question =
|
||||
await questionService.ensureProspectiveQuestionQueued(firebaseUid);
|
||||
final int unansweredCount =
|
||||
await questionsDb.countUnansweredQuestions(firebaseUid);
|
||||
return _jsonResponse(200, <String, dynamic>{
|
||||
'score': score,
|
||||
'unansweredCount': unansweredCount,
|
||||
if (question != null) 'question': question,
|
||||
});
|
||||
} catch (e, st) {
|
||||
stderr.writeln('Reset questions score error: $e\n$st');
|
||||
|
||||
@ -105,6 +105,24 @@ class QuestionsDb {
|
||||
return rows.first;
|
||||
}
|
||||
|
||||
/// Deletes every guess-game question row for [firebaseUid].
|
||||
///
|
||||
/// Assignments and answer snapshots referencing these rows cascade away.
|
||||
Future<void> deleteAllProspectiveGuessQuestionsForUser(
|
||||
String firebaseUid,
|
||||
) async {
|
||||
await _connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
DELETE FROM questions
|
||||
WHERE assigned_user_id = @uid
|
||||
AND source_tag = 'market_history:prospective'
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{'uid': firebaseUid},
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes an unanswered question owned by [assignedUserId].
|
||||
Future<void> deleteUnansweredQuestion({
|
||||
required String questionId,
|
||||
@ -373,7 +391,7 @@ class QuestionsDb {
|
||||
return GuessScoreStore.loadSummary(_connection, firebaseUid);
|
||||
}
|
||||
|
||||
/// Resets guess score counters and slot progression to the earliest window slot.
|
||||
/// Resets guess score to a blank slate and clears all guess questions/links.
|
||||
Future<Map<String, dynamic>> resetGuessScoreSummary(
|
||||
String firebaseUid, {
|
||||
DateTime? now,
|
||||
@ -395,6 +413,7 @@ class QuestionsDb {
|
||||
await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser(
|
||||
firebaseUid,
|
||||
);
|
||||
await deleteAllProspectiveGuessQuestionsForUser(firebaseUid);
|
||||
await UserTradingStateDb(_connection).resetGuessScore(
|
||||
firebaseUid,
|
||||
slotStart: earliest,
|
||||
|
||||
@ -471,6 +471,31 @@ void main() {
|
||||
parameters: <String, dynamic>{'uid': uid},
|
||||
);
|
||||
expect((assignmentCount.first[0]! as num).toInt(), 0);
|
||||
|
||||
final Result questionCount = await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
SELECT COUNT(*)::int
|
||||
FROM questions
|
||||
WHERE assigned_user_id = @uid
|
||||
AND source_tag = 'market_history:prospective'
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{'uid': uid},
|
||||
);
|
||||
expect((questionCount.first[0]! as num).toInt(), 0);
|
||||
|
||||
final Result answerCount = await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
SELECT COUNT(*)::int
|
||||
FROM market_history_prospective_answers
|
||||
WHERE assigned_user_id = @uid
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{'uid': uid},
|
||||
);
|
||||
expect((answerCount.first[0]! as num).toInt(), 0);
|
||||
});
|
||||
|
||||
test('assignments issue every top-half asset before advancing slot pair',
|
||||
@ -661,6 +686,153 @@ void main() {
|
||||
expect(allAssignments.elementAt(2)[0], 'HIGH');
|
||||
});
|
||||
|
||||
test('resetGuessScoreSummary clears questions then re-bootstrap has no duplicates',
|
||||
() async {
|
||||
if (testDb == null) {
|
||||
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const String uid = 'prospective-reset-blank-slate-uid';
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final DateTime olderSlot =
|
||||
ProspectiveGuessSelection.earliestPlayableSlotStart(now, 5);
|
||||
final DateTime? newerSlotRaw =
|
||||
MarketHistorySessionSlot.nextSlotStart(olderSlot);
|
||||
if (newerSlotRaw == null) {
|
||||
markTestSkipped('No newer slot after earliest window slot');
|
||||
return;
|
||||
}
|
||||
final DateTime newerSlot =
|
||||
MarketHistorySessionSlot.slotStartContaining(newerSlotRaw);
|
||||
|
||||
await testDb!.seedUser(uid);
|
||||
await testDb!.userTradingStateDb.setGuessSlotStart(uid, olderSlot);
|
||||
|
||||
await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
INSERT INTO tradable_assets (symbol, asset_class, status, tradable, refreshed_at)
|
||||
VALUES
|
||||
('HIGH', 'us_equity', 'active', true, @refreshed_at),
|
||||
('MID', 'us_equity', 'active', true, @refreshed_at),
|
||||
('LOW', 'us_equity', 'active', true, @refreshed_at)
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{'refreshed_at': now},
|
||||
);
|
||||
|
||||
Future<void> insertBar({
|
||||
required String symbol,
|
||||
required DateTime asOf,
|
||||
required num volume,
|
||||
}) async {
|
||||
await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
INSERT INTO market_data_snapshots (
|
||||
symbol, asset_class, feed, metric, timeframe, price, volume, as_of, raw
|
||||
) VALUES (
|
||||
@symbol, 'us_equity', 'iex', 'bar', 'sessionHalf', 10, @volume, @as_of, @raw::jsonb
|
||||
)
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{
|
||||
'symbol': symbol,
|
||||
'as_of': asOf,
|
||||
'volume': volume,
|
||||
'raw': jsonEncode(<String, dynamic>{
|
||||
'o': 10,
|
||||
'h': 10,
|
||||
'l': 10,
|
||||
'c': 10,
|
||||
'v': volume,
|
||||
'slot_start': MarketHistorySessionSlot.slotStartWire(asOf),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await insertBar(symbol: 'HIGH', asOf: olderSlot, volume: 500);
|
||||
await insertBar(symbol: 'HIGH', asOf: newerSlot, volume: 500);
|
||||
await insertBar(symbol: 'MID', asOf: olderSlot, volume: 150);
|
||||
await insertBar(symbol: 'MID', asOf: newerSlot, volume: 150);
|
||||
await insertBar(symbol: 'LOW', asOf: olderSlot, volume: 100);
|
||||
await insertBar(symbol: 'LOW', asOf: newerSlot, volume: 100);
|
||||
|
||||
final QuestionService service = testDb!.questionService();
|
||||
|
||||
final Map<String, dynamic>? firstPayload =
|
||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||
expect(firstPayload, isNotNull);
|
||||
expect(firstPayload!['unansweredCount'], 2);
|
||||
|
||||
final List<Map<String, dynamic>> queued =
|
||||
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||
expect(queued, hasLength(2));
|
||||
await testDb!.questionsDb.submitAnswer(
|
||||
questionId: queued.first['id']! as String,
|
||||
assignedUserId: uid,
|
||||
userResponse: 0,
|
||||
);
|
||||
|
||||
await testDb!.questionsDb.resetGuessScoreSummary(uid, now: now);
|
||||
|
||||
final Result questionCountAfterReset = await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
SELECT COUNT(*)::int
|
||||
FROM questions
|
||||
WHERE assigned_user_id = @uid
|
||||
AND source_tag = 'market_history:prospective'
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{'uid': uid},
|
||||
);
|
||||
expect((questionCountAfterReset.first[0]! as num).toInt(), 0);
|
||||
|
||||
final Map<String, dynamic>? afterResetPayload =
|
||||
await service.ensureProspectiveQuestionQueued(uid, now: now);
|
||||
expect(afterResetPayload, isNotNull);
|
||||
expect(afterResetPayload!['unansweredCount'], 2);
|
||||
|
||||
final List<Map<String, dynamic>> afterResetQueued =
|
||||
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
||||
expect(afterResetQueued, hasLength(2));
|
||||
|
||||
final Set<String> symbols = afterResetQueued
|
||||
.map((Map<String, dynamic> row) {
|
||||
final Map<String, dynamic>? metadata =
|
||||
row['metadata'] as Map<String, dynamic>?;
|
||||
return metadata?['symbol'] as String? ??
|
||||
metadata?['prospective_question_id']?.toString() ??
|
||||
row['id']! as String;
|
||||
})
|
||||
.toSet();
|
||||
expect(symbols, hasLength(2));
|
||||
|
||||
final Result assignmentSymbols = await testDb!.connection.execute(
|
||||
Sql.named(
|
||||
'''
|
||||
SELECT symbol
|
||||
FROM market_history_prospective_assignments
|
||||
WHERE assigned_user_id = @uid
|
||||
AND status = @status
|
||||
ORDER BY symbol
|
||||
''',
|
||||
),
|
||||
parameters: <String, dynamic>{
|
||||
'uid': uid,
|
||||
'status': ProspectiveGuessAssignmentsDb.statusPending,
|
||||
},
|
||||
);
|
||||
expect(assignmentSymbols, hasLength(2));
|
||||
expect(
|
||||
assignmentSymbols.map((ResultRow r) => r[0]).toSet(),
|
||||
hasLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
test('blocks duplicate assignment for same user symbol and slot pair', () async {
|
||||
if (testDb == null) {
|
||||
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||
|
||||
@ -26,6 +26,7 @@ void main() {
|
||||
hub.hasPendingQuestion.value = false;
|
||||
hub.pendingQuestionCount.value = 0;
|
||||
hub.guessScoreSummary.value = GuessScoreSummary.empty;
|
||||
hub.scoreResetBusy.value = false;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
@ -67,6 +68,7 @@ void main() {
|
||||
|
||||
addTearDown(() {
|
||||
hub.guessScoreSummary.value = GuessScoreSummary.empty;
|
||||
hub.scoreResetBusy.value = false;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
@ -98,4 +100,37 @@ void main() {
|
||||
);
|
||||
expect(find.byKey(const Key('guess-score-stats-dialog')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('score reset shows blocking overlay while processing', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final QuestionsHubService hub = QuestionsHubService.instance;
|
||||
hub.guessScoreSummary.value = const GuessScoreSummary(
|
||||
total: 5,
|
||||
answersTotal: 2,
|
||||
answersCorrect: 1,
|
||||
percentCorrect: 50,
|
||||
);
|
||||
hub.scoreResetBusy.value = true;
|
||||
|
||||
addTearDown(() {
|
||||
hub.guessScoreSummary.value = GuessScoreSummary.empty;
|
||||
hub.scoreResetBusy.value = false;
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: buildAppTheme(),
|
||||
home: const HomeScreen(
|
||||
user: AppUser(uid: 'u1', displayName: 'Test User'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('guess-score-reset-blocking-overlay')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user