From e64d21dc2e968653cbf29b2633e75bae8dadea12 Mon Sep 17 00:00:00 2001 From: Nathan Anderson Date: Wed, 3 Jun 2026 05:12:02 -0500 Subject: [PATCH] reseting --- lib/models/guess_score_reset_result.dart | 15 + lib/screens/home_screen.dart | 36 +- lib/services/questions_api_service.dart | 18 +- lib/services/questions_hub_service.dart | 21 +- lib/widgets/swipe_question_tile.dart | 461 ++++++++++++------ server/lib/handlers/questions_handler.dart | 6 + server/lib/questions_db.dart | 21 +- ...et_history_prospective_questions_test.dart | 172 +++++++ test/home_screen_score_test.dart | 35 ++ 9 files changed, 608 insertions(+), 177 deletions(-) create mode 100644 lib/models/guess_score_reset_result.dart diff --git a/lib/models/guess_score_reset_result.dart b/lib/models/guess_score_reset_result.dart new file mode 100644 index 0000000..d31444f --- /dev/null +++ b/lib/models/guess_score_reset_result.dart @@ -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; +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6fcab76..f257a69 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -40,7 +40,9 @@ class HomeScreen extends StatelessWidget { ProfileSyncStatus.idle => AppColors.textSecondary, }; - return Scaffold( + return Stack( + children: [ + 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: [ + ModalBarrier( + dismissible: false, + color: Colors.black.withValues(alpha: 0.54), + ), + const Center( + child: CircularProgressIndicator(color: AppColors.accent), + ), + ], + ); + }, + ), + ], ); } } @@ -403,14 +427,8 @@ Future _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 { diff --git a/lib/services/questions_api_service.dart b/lib/services/questions_api_service.dart index 2b270f4..2b55c0b 100644 --- a/lib/services/questions_api_service.dart +++ b/lib/services/questions_api_service.dart @@ -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 resetGuessScoreSummary() async { + Future 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; final Map? scoreJson = body['score'] as Map?; - if (scoreJson == null) { - return GuessScoreSummary.empty; - } - return GuessScoreSummary.fromJson(scoreJson); + final Map? questionJson = + body['question'] as Map?; + 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 _authHeaders(String token) { diff --git a/lib/services/questions_hub_service.dart b/lib/services/questions_hub_service.dart index cd83438..d8f2bc0 100644 --- a/lib/services/questions_hub_service.dart +++ b/lib/services/questions_hub_service.dart @@ -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> questionQueue = ValueNotifier>([]); final ValueNotifier questionActionBusy = ValueNotifier(false); + final ValueNotifier scoreResetBusy = ValueNotifier(false); final ValueNotifier guessScoreSummary = ValueNotifier(GuessScoreSummary.empty); @@ -300,12 +302,21 @@ class QuestionsHubService { /// Clears cumulative guess score and answer statistics on the server. Future 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. diff --git a/lib/widgets/swipe_question_tile.dart b/lib/widgets/swipe_question_tile.dart index 6b63e83..60310e5 100644 --- a/lib/widgets/swipe_question_tile.dart +++ b/lib/widgets/swipe_question_tile.dart @@ -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 Function() onSwipeLeft; final bool busy; + /// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis. + final double swipeAxisToleranceDegrees; + @override State createState() => _SwipeQuestionTileState(); } @@ -38,6 +47,10 @@ class _SwipeQuestionTileState extends State 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 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 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 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 /// 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 }); } + 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 }); } + 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 @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 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.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: [ - 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: [ + 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), ], ), ), diff --git a/server/lib/handlers/questions_handler.dart b/server/lib/handlers/questions_handler.dart index 4a3bc59..db4f1f3 100644 --- a/server/lib/handlers/questions_handler.dart +++ b/server/lib/handlers/questions_handler.dart @@ -94,8 +94,14 @@ Handler questionsHandler({ try { final Map score = await questionsDb.resetGuessScoreSummary(firebaseUid); + final Map? question = + await questionService.ensureProspectiveQuestionQueued(firebaseUid); + final int unansweredCount = + await questionsDb.countUnansweredQuestions(firebaseUid); return _jsonResponse(200, { 'score': score, + 'unansweredCount': unansweredCount, + if (question != null) 'question': question, }); } catch (e, st) { stderr.writeln('Reset questions score error: $e\n$st'); diff --git a/server/lib/questions_db.dart b/server/lib/questions_db.dart index 5024939..8c701f1 100644 --- a/server/lib/questions_db.dart +++ b/server/lib/questions_db.dart @@ -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 deleteAllProspectiveGuessQuestionsForUser( + String firebaseUid, + ) async { + await _connection.execute( + Sql.named( + ''' + DELETE FROM questions + WHERE assigned_user_id = @uid + AND source_tag = 'market_history:prospective' + ''', + ), + parameters: {'uid': firebaseUid}, + ); + } + /// Removes an unanswered question owned by [assignedUserId]. Future 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> 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, diff --git a/server/test/integration/market_history_prospective_questions_test.dart b/server/test/integration/market_history_prospective_questions_test.dart index 2c6a8b2..b07de07 100644 --- a/server/test/integration/market_history_prospective_questions_test.dart +++ b/server/test/integration/market_history_prospective_questions_test.dart @@ -471,6 +471,31 @@ void main() { parameters: {'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: {'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: {'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: {'refreshed_at': now}, + ); + + Future 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: { + 'symbol': symbol, + 'as_of': asOf, + 'volume': volume, + 'raw': jsonEncode({ + '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? firstPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(firstPayload, isNotNull); + expect(firstPayload!['unansweredCount'], 2); + + final List> 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: {'uid': uid}, + ); + expect((questionCountAfterReset.first[0]! as num).toInt(), 0); + + final Map? afterResetPayload = + await service.ensureProspectiveQuestionQueued(uid, now: now); + expect(afterResetPayload, isNotNull); + expect(afterResetPayload!['unansweredCount'], 2); + + final List> afterResetQueued = + await testDb!.questionsDb.listUnansweredQuestions(uid); + expect(afterResetQueued, hasLength(2)); + + final Set symbols = afterResetQueued + .map((Map row) { + final Map? metadata = + row['metadata'] as Map?; + 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: { + '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'); diff --git a/test/home_screen_score_test.dart b/test/home_screen_score_test.dart index 83f7ced..fdd104f 100644 --- a/test/home_screen_score_test.dart +++ b/test/home_screen_score_test.dart @@ -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); + }); }