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,
|
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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user