This commit is contained in:
Nathan Anderson 2026-06-03 05:12:02 -05:00
parent 0c4c72f9c9
commit e64d21dc2e
9 changed files with 608 additions and 177 deletions

View 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;
}

View File

@ -40,7 +40,9 @@ class HomeScreen extends StatelessWidget {
ProfileSyncStatus.idle => AppColors.textSecondary, ProfileSyncStatus.idle => AppColors.textSecondary,
}; };
return Scaffold( return Stack(
children: <Widget>[
Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
centerTitle: true, 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; return;
} }
final bool ok = Navigator.of(context).pop();
await QuestionsHubService.instance.resetGuessScoreSummary(); await QuestionsHubService.instance.resetGuessScoreSummary();
if (!context.mounted) {
return;
}
if (ok) {
Navigator.of(context).pop();
}
} }
class _ScoreStatRow extends StatelessWidget { class _ScoreStatRow extends StatelessWidget {

View File

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart'; import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_defer_result.dart'; import '../models/question_defer_result.dart';
@ -177,7 +178,7 @@ class QuestionsApiService {
return GuessScoreSummary.fromJson(scoreJson); return GuessScoreSummary.fromJson(scoreJson);
} }
Future<GuessScoreSummary?> resetGuessScoreSummary() async { Future<GuessScoreResetResult?> resetGuessScoreSummary() async {
final String? token = await AuthService.instance.getIdToken(); final String? token = await AuthService.instance.getIdToken();
if (token == null) { if (token == null) {
return null; return null;
@ -198,10 +199,17 @@ class QuestionsApiService {
jsonDecode(response.body) as Map<String, dynamic>; jsonDecode(response.body) as Map<String, dynamic>;
final Map<String, dynamic>? scoreJson = final Map<String, dynamic>? scoreJson =
body['score'] as Map<String, dynamic>?; body['score'] as Map<String, dynamic>?;
if (scoreJson == null) { final Map<String, dynamic>? questionJson =
return GuessScoreSummary.empty; body['question'] as Map<String, dynamic>?;
} return GuessScoreResetResult(
return GuessScoreSummary.fromJson(scoreJson); 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) { Map<String, String> _authHeaders(String token) {

View File

@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:signalr_netcore/signalr_client.dart'; import 'package:signalr_netcore/signalr_client.dart';
import '../config/api_config.dart'; import '../config/api_config.dart';
import '../models/guess_score_reset_result.dart';
import '../models/guess_score_summary.dart'; import '../models/guess_score_summary.dart';
import '../models/incoming_question.dart'; import '../models/incoming_question.dart';
import '../models/question_defer_result.dart'; import '../models/question_defer_result.dart';
@ -27,6 +28,7 @@ class QuestionsHubService {
final ValueNotifier<List<IncomingQuestion>> questionQueue = final ValueNotifier<List<IncomingQuestion>> questionQueue =
ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]); ValueNotifier<List<IncomingQuestion>>(<IncomingQuestion>[]);
final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false); final ValueNotifier<bool> questionActionBusy = ValueNotifier<bool>(false);
final ValueNotifier<bool> scoreResetBusy = ValueNotifier<bool>(false);
final ValueNotifier<GuessScoreSummary> guessScoreSummary = final ValueNotifier<GuessScoreSummary> guessScoreSummary =
ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty); ValueNotifier<GuessScoreSummary>(GuessScoreSummary.empty);
@ -300,12 +302,21 @@ class QuestionsHubService {
/// Clears cumulative guess score and answer statistics on the server. /// Clears cumulative guess score and answer statistics on the server.
Future<bool> resetGuessScoreSummary() async { Future<bool> resetGuessScoreSummary() async {
final GuessScoreSummary? score = await _api.resetGuessScoreSummary(); scoreResetBusy.value = true;
if (score == null) { try {
return false; 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. /// Clears pending question UI; keeps the SignalR connection alive.

View File

@ -13,9 +13,14 @@ const double _holdEdgeButtonWidth = 58;
/// Equal left/right margin for the slider; edge buttons sit in the gutters. /// Equal left/right margin for the slider; edge buttons sit in the gutters.
const double _centerSideInset = _holdEdgeButtonWidth + 8; const double _centerSideInset = _holdEdgeButtonWidth + 8;
enum _LockedDragAxis { horizontal, vertical, rejected }
/// Swipeable question card: right submits, left defers (does not resolve). /// 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). /// 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 { class SwipeQuestionTile extends StatefulWidget {
const SwipeQuestionTile({ const SwipeQuestionTile({
super.key, super.key,
@ -23,6 +28,7 @@ class SwipeQuestionTile extends StatefulWidget {
required this.onSwipeRight, required this.onSwipeRight,
required this.onSwipeLeft, required this.onSwipeLeft,
this.busy = false, this.busy = false,
this.swipeAxisToleranceDegrees = 25,
}); });
final String questionId; final String questionId;
@ -30,6 +36,9 @@ class SwipeQuestionTile extends StatefulWidget {
final Future<void> Function() onSwipeLeft; final Future<void> Function() onSwipeLeft;
final bool busy; final bool busy;
/// Max deviation from pure horizontal (0°/180°) or vertical (±90°) to lock axis.
final double swipeAxisToleranceDegrees;
@override @override
State<SwipeQuestionTile> createState() => _SwipeQuestionTileState(); State<SwipeQuestionTile> createState() => _SwipeQuestionTileState();
} }
@ -38,6 +47,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
with TickerProviderStateMixin { with TickerProviderStateMixin {
double _dragOffset = 0; double _dragOffset = 0;
double _verticalOffset = 0; double _verticalOffset = 0;
double _panTotalDx = 0;
double _panTotalDy = 0;
double _scrollAccumulatedDy = 0;
_LockedDragAxis? _lockedDragAxis;
bool _acting = false; bool _acting = false;
bool _holdSavePointerDown = false; bool _holdSavePointerDown = false;
bool _holdDeferPointerDown = false; bool _holdDeferPointerDown = false;
@ -47,6 +60,8 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
late final AnimationController _holdDeferController; late final AnimationController _holdDeferController;
static const double _swipeThreshold = 96; static const double _swipeThreshold = 96;
static const double _axisLockSlop = 14;
static const double _scrollStepPixels = 100;
static const Duration _holdEdgeDuration = Duration(seconds: 1); static const Duration _holdEdgeDuration = Duration(seconds: 1);
static const double _glyphSize = 80; static const double _glyphSize = 80;
static const double _trackEdgeInset = 8; static const double _trackEdgeInset = 8;
@ -70,8 +85,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive; bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
bool get _horizontalSwipeEnabled => bool get _horizontalSwipeEnabled => _holdSaveEnabled && !_holdEdgeActive;
_holdSaveEnabled && !_holdEdgeActive;
@override @override
void initState() { void initState() {
@ -80,16 +94,14 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 140), duration: const Duration(milliseconds: 140),
); );
_holdSaveController = AnimationController( _holdSaveController =
vsync: this, AnimationController(vsync: this, duration: _holdEdgeDuration)
duration: _holdEdgeDuration, ..addListener(_syncHoldSaveDrag)
)..addListener(_syncHoldSaveDrag) ..addStatusListener(_onHoldSaveStatus);
..addStatusListener(_onHoldSaveStatus); _holdDeferController =
_holdDeferController = AnimationController( AnimationController(vsync: this, duration: _holdEdgeDuration)
vsync: this, ..addListener(_syncHoldDeferDrag)
duration: _holdEdgeDuration, ..addStatusListener(_onHoldDeferStatus);
)..addListener(_syncHoldDeferDrag)
..addStatusListener(_onHoldDeferStatus);
} }
@override @override
@ -236,9 +248,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
/// Swipe up +10, swipe down -10; snaps to whole numbers. /// Swipe up +10, swipe down -10; snaps to whole numbers.
int get _snappedSliderValue => int get _snappedSliderValue =>
(_clampedVerticalOffset / _maxVerticalDrag * _sliderMax) (_clampedVerticalOffset / _maxVerticalDrag * _sliderMax).round().clamp(
.round() _sliderMin,
.clamp(_sliderMin, _sliderMax); _sliderMax,
);
double get _clampedVerticalOffset => double get _clampedVerticalOffset =>
_verticalOffset.clamp(-_maxVerticalDrag, _maxVerticalDrag); _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. /// One wheel notch / trackpad tick one integer on the [-10, 10] scale.
void _stepSliderFromScroll(double scrollDy) { void _stepSliderFromScroll(double scrollDy) {
if (scrollDy == 0) { if (scrollDy == 0) {
return; return;
} }
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)) final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
.clamp(_sliderMin, _sliderMax); _sliderMin,
_sliderMax,
);
if (next == _snappedSliderValue) { if (next == _snappedSliderValue) {
return; 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) { void _maybeTriggerSnapFeedback(int snapped) {
if (snapped == _lastSnappedValue) { if (snapped == _lastSnappedValue) {
return; return;
@ -327,16 +493,18 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double width = MediaQuery.sizeOf(context).width;
return LayoutBuilder( return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
const double outerVerticalPadding = 48; const double outerVerticalPadding = 48;
const double trackVerticalInset = 16; const double trackVerticalInset = 16;
final double innerHeight = final double innerHeight = math.max(
math.max(constraints.maxHeight - outerVerticalPadding, 172); constraints.maxHeight - outerVerticalPadding,
final double trackHeight = 172,
math.max(innerHeight - trackVerticalInset, _glyphSize); );
final double trackHeight = math.max(
innerHeight - trackVerticalInset,
_glyphSize,
);
_maxVerticalDrag = math.max( _maxVerticalDrag = math.max(
(trackHeight / 2) - (_glyphSize / 2) - _trackEdgeInset, (trackHeight / 2) - (_glyphSize / 2) - _trackEdgeInset,
40, 40,
@ -351,147 +519,130 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _snapController, animation: _snapController,
builder: (BuildContext context, Widget? child) { builder: (BuildContext context, Widget? child) {
final ({double shakeX, double scale}) motion = final ({double shakeX, double scale}) motion = _snapMotion(
_snapMotion(_snapController.value); _snapController.value,
);
return Transform.translate( return Transform.translate(
offset: Offset(motion.shakeX, 0), offset: Offset(motion.shakeX, 0),
child: Transform.scale( child: Transform.scale(scale: motion.scale, child: child),
scale: motion.scale,
child: child,
),
); );
}, },
child: GestureDetector( child: Listener(
onHorizontalDragUpdate: _horizontalSwipeEnabled behavior: HitTestBehavior.translucent,
? (DragUpdateDetails details) { onPointerSignal: widget.busy || _acting
setState(() { ? null
_dragOffset += details.delta.dx; : _onPointerSignal,
_dragOffset = onPointerPanZoomUpdate: widget.busy || _acting
_dragOffset.clamp(-width * 0.55, width * 0.55); ? null
}); : _onPointerPanZoomUpdate,
} child: GestureDetector(
: null, supportedDevices: const <PointerDeviceKind>{
onHorizontalDragEnd: _horizontalSwipeEnabled PointerDeviceKind.touch,
? (_) => unawaited(_releaseDrag()) PointerDeviceKind.mouse,
: null, PointerDeviceKind.stylus,
child: ClipRRect( PointerDeviceKind.invertedStylus,
borderRadius: BorderRadius.circular(16), },
child: Material( onPanStart: widget.busy || _acting ? null : _onPanStart,
color: Colors.transparent, onPanUpdate: widget.busy || _acting ? null : _onPanUpdate,
elevation: 0, onPanEnd: widget.busy || _acting ? null : _onPanEnd,
child: Container( onPanCancel: widget.busy || _acting ? null : _onPanCancel,
width: constraints.maxWidth, child: ClipRRect(
constraints: const BoxConstraints(minHeight: 220), borderRadius: BorderRadius.circular(16),
padding: const EdgeInsets.symmetric( child: Material(
horizontal: 16, color: Colors.transparent,
vertical: 24, elevation: 0,
), child: Container(
decoration: BoxDecoration( width: constraints.maxWidth,
color: AppColors.surfaceElevated.withValues( constraints: const BoxConstraints(minHeight: 220),
alpha: 0.9, padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
), ),
borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(
border: Border.all( color: AppColors.surfaceElevated.withValues(
color: AppColors.accent.withValues(alpha: 0.18), alpha: 0.9,
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( borderRadius: BorderRadius.circular(16),
top: 8, border: Border.all(
bottom: 8, color: AppColors.accent.withValues(alpha: 0.18),
left: _centerSideInset, width: 1,
right: _centerSideInset, ),
child: Listener( ),
onPointerSignal: (PointerSignalEvent event) { child: Stack(
if (widget.busy || _acting) { alignment: Alignment.center,
return; clipBehavior: Clip.none,
} children: <Widget>[
if (event is PointerScrollEvent) { Positioned(
_stepSliderFromScroll( top: 8,
event.scrollDelta.dy, bottom: 8,
); left: _centerSideInset,
} right: _centerSideInset,
}, child: DecoratedBox(
child: GestureDetector( decoration: BoxDecoration(
behavior: HitTestBehavior.opaque, borderRadius: BorderRadius.circular(14),
onVerticalDragUpdate: widget.busy || _acting color: AppColors.surface.withValues(
? null alpha: 0.6,
: (DragUpdateDetails details) =>
_applyVerticalInputDelta(
details.delta.dy,
),
child: Center(
child: Transform.translate(
offset:
Offset(0, -_snappedVerticalOffset),
child: QuestionGuidGlyph(
guid: widget.questionId,
size: _glyphSize,
displayValue: _snappedSliderValue,
),
), ),
), ),
), ),
), ),
), Positioned(
Positioned( top: 8,
left: 0, bottom: 8,
top: 10, left: _centerSideInset,
bottom: 10, right: _centerSideInset,
child: _HoldSwipeEdgeButton( child: Center(
side: _HoldSwipeEdgeSide.left, child: Transform.translate(
enabled: _holdDeferEnabled, offset: Offset(0, -_snappedVerticalOffset),
progress: _holdDeferController.value, child: QuestionGuidGlyph(
active: _holdDeferPointerDown, guid: widget.questionId,
icon: Icons.reply, size: _glyphSize,
ringColor: AppColors.accent, displayValue: _snappedSliderValue,
tooltipEnabled: 'Hold to defer question', ),
tooltipDisabled: 'Unavailable while busy', ),
semanticsLabel: 'Hold to defer question', ),
onPointerDown: _onHoldDeferPointerDown,
onPointerUp: _onHoldDeferPointerUp,
onPointerCancel: _onHoldDeferPointerUp,
), ),
), Positioned(
Positioned( left: 0,
right: 0, top: 10,
top: 10, bottom: 10,
bottom: 10, child: _HoldSwipeEdgeButton(
child: _HoldSwipeEdgeButton( side: _HoldSwipeEdgeSide.left,
side: _HoldSwipeEdgeSide.right, enabled: _holdDeferEnabled,
enabled: _holdSaveEnabled, progress: _holdDeferController.value,
progress: _holdSaveController.value, active: _holdDeferPointerDown,
active: _holdSavePointerDown, icon: Icons.reply,
icon: Icons.save, ringColor: AppColors.accent,
ringColor: AppColors.success, tooltipEnabled: 'Hold to defer question',
tooltipEnabled: 'Hold to save answer', tooltipDisabled: 'Unavailable while busy',
tooltipDisabled: semanticsLabel: 'Hold to defer question',
'Set an answer before saving', onPointerDown: _onHoldDeferPointerDown,
semanticsLabel: 'Hold to save answer', onPointerUp: _onHoldDeferPointerUp,
onPointerDown: _onHoldSavePointerDown, onPointerCancel: _onHoldDeferPointerUp,
onPointerUp: _onHoldSavePointerUp, ),
onPointerCancel: _onHoldSavePointerUp,
), ),
), 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), backgroundColor: progressColor.withValues(alpha: 0.2),
color: progressColor, color: progressColor,
), ),
Icon( Icon(icon, size: 20, color: iconColor),
icon,
size: 20,
color: iconColor,
),
], ],
), ),
), ),

View File

@ -94,8 +94,14 @@ Handler questionsHandler({
try { try {
final Map<String, dynamic> score = final Map<String, dynamic> score =
await questionsDb.resetGuessScoreSummary(firebaseUid); 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>{ return _jsonResponse(200, <String, dynamic>{
'score': score, 'score': score,
'unansweredCount': unansweredCount,
if (question != null) 'question': question,
}); });
} catch (e, st) { } catch (e, st) {
stderr.writeln('Reset questions score error: $e\n$st'); stderr.writeln('Reset questions score error: $e\n$st');

View File

@ -105,6 +105,24 @@ class QuestionsDb {
return rows.first; 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]. /// Removes an unanswered question owned by [assignedUserId].
Future<void> deleteUnansweredQuestion({ Future<void> deleteUnansweredQuestion({
required String questionId, required String questionId,
@ -373,7 +391,7 @@ class QuestionsDb {
return GuessScoreStore.loadSummary(_connection, firebaseUid); 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( Future<Map<String, dynamic>> resetGuessScoreSummary(
String firebaseUid, { String firebaseUid, {
DateTime? now, DateTime? now,
@ -395,6 +413,7 @@ class QuestionsDb {
await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser( await ProspectiveGuessAssignmentsDb(_connection).deleteAllForUser(
firebaseUid, firebaseUid,
); );
await deleteAllProspectiveGuessQuestionsForUser(firebaseUid);
await UserTradingStateDb(_connection).resetGuessScore( await UserTradingStateDb(_connection).resetGuessScore(
firebaseUid, firebaseUid,
slotStart: earliest, slotStart: earliest,

View File

@ -471,6 +471,31 @@ void main() {
parameters: <String, dynamic>{'uid': uid}, parameters: <String, dynamic>{'uid': uid},
); );
expect((assignmentCount.first[0]! as num).toInt(), 0); 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', test('assignments issue every top-half asset before advancing slot pair',
@ -661,6 +686,153 @@ void main() {
expect(allAssignments.elementAt(2)[0], 'HIGH'); 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 { test('blocks duplicate assignment for same user symbol and slot pair', () async {
if (testDb == null) { if (testDb == null) {
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests'); markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');

View File

@ -26,6 +26,7 @@ void main() {
hub.hasPendingQuestion.value = false; hub.hasPendingQuestion.value = false;
hub.pendingQuestionCount.value = 0; hub.pendingQuestionCount.value = 0;
hub.guessScoreSummary.value = GuessScoreSummary.empty; hub.guessScoreSummary.value = GuessScoreSummary.empty;
hub.scoreResetBusy.value = false;
}); });
await tester.pumpWidget( await tester.pumpWidget(
@ -67,6 +68,7 @@ void main() {
addTearDown(() { addTearDown(() {
hub.guessScoreSummary.value = GuessScoreSummary.empty; hub.guessScoreSummary.value = GuessScoreSummary.empty;
hub.scoreResetBusy.value = false;
}); });
await tester.pumpWidget( await tester.pumpWidget(
@ -98,4 +100,37 @@ void main() {
); );
expect(find.byKey(const Key('guess-score-stats-dialog')), findsOneWidget); 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);
});
} }