buttons
This commit is contained in:
parent
46cc822586
commit
00840a80e4
File diff suppressed because one or more lines are too long
@ -4,6 +4,7 @@ import '../admin/widgets/admin_app_bar_action.dart';
|
|||||||
import '../models/app_user.dart';
|
import '../models/app_user.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_source_preference.dart';
|
||||||
import '../utils/guess_slot_format.dart';
|
import '../utils/guess_slot_format.dart';
|
||||||
import '../models/sync_result.dart';
|
import '../models/sync_result.dart';
|
||||||
import '../models/user_profile.dart';
|
import '../models/user_profile.dart';
|
||||||
@ -190,6 +191,8 @@ class HomeScreen extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
if (panelOpen)
|
||||||
|
_QuestionSourceConfigTopBar(sources: sources),
|
||||||
if (showQuestionPanel)
|
if (showQuestionPanel)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@ -625,3 +628,103 @@ class _SyncStatusChip extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _QuestionSourceConfigTopBar extends StatelessWidget {
|
||||||
|
const _QuestionSourceConfigTopBar({
|
||||||
|
required this.sources,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<QuestionSourcePreference> sources;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Map<String, int> mix =
|
||||||
|
QuestionSourceConfigRepository.effectiveMix(sources);
|
||||||
|
final String mixText = mix.isEmpty
|
||||||
|
? 'No sources enabled'
|
||||||
|
: mix.entries.map((MapEntry<String, int> entry) {
|
||||||
|
final QuestionSourcePreference source = sources.firstWhere(
|
||||||
|
(QuestionSourcePreference s) => s.id == entry.key,
|
||||||
|
);
|
||||||
|
return '${source.label}: ${entry.value}%';
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
key: const Key('question-source-config-top-bar'),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceElevated,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.15),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
Icons.tune_outlined,
|
||||||
|
color: AppColors.accent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
const Text(
|
||||||
|
'Question source configuration',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
mixText,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
key: const Key('question-source-config-top-bar-button'),
|
||||||
|
onPressed: () => QuestionSourceSettingsSheet.show(context),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'Configure',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
backgroundColor: AppColors.accent.withValues(alpha: 0.08),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
side: BorderSide(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -126,6 +126,16 @@ class QuestionsHubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final IncomingQuestion question = IncomingQuestion.fromJson(json);
|
final IncomingQuestion question = IncomingQuestion.fromJson(json);
|
||||||
|
|
||||||
|
if (questionActionBusy.value) {
|
||||||
|
debugPrint('ReceiveQuestion: ignored push because action is busy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingQuestion.value != null && pendingQuestion.value!.id != question.id) {
|
||||||
|
debugPrint('ReceiveQuestion: ignored push because another question is active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_applyIncoming(question);
|
_applyIncoming(question);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -307,8 +317,21 @@ class QuestionsHubService {
|
|||||||
if (refreshed.isEmpty) {
|
if (refreshed.isEmpty) {
|
||||||
if (result.nextQuestion != null &&
|
if (result.nextQuestion != null &&
|
||||||
_acceptsQuestionSource(result.nextQuestion!)) {
|
_acceptsQuestionSource(result.nextQuestion!)) {
|
||||||
|
IncomingQuestion nextQ = result.nextQuestion!;
|
||||||
|
if (nextQ.needsPrepare) {
|
||||||
|
try {
|
||||||
|
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
|
||||||
|
questionId: nextQ.id,
|
||||||
|
);
|
||||||
|
if (prepared != null) {
|
||||||
|
nextQ = prepared;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to prepare next question in defer: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
_setActiveQuestion(
|
_setActiveQuestion(
|
||||||
result.nextQuestion!,
|
nextQ,
|
||||||
unansweredCount: result.unansweredCount,
|
unansweredCount: result.unansweredCount,
|
||||||
);
|
);
|
||||||
} else if (_shouldKeepPanelOpenWithoutQuestion()) {
|
} else if (_shouldKeepPanelOpenWithoutQuestion()) {
|
||||||
@ -334,6 +357,19 @@ class QuestionsHubService {
|
|||||||
next = result.nextQuestion!;
|
next = result.nextQuestion!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (next.needsPrepare) {
|
||||||
|
try {
|
||||||
|
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
|
||||||
|
questionId: next.id,
|
||||||
|
);
|
||||||
|
if (prepared != null) {
|
||||||
|
next = prepared;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to prepare next question in defer: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_setActiveQuestion(
|
_setActiveQuestion(
|
||||||
next,
|
next,
|
||||||
unansweredCount: result.unansweredCount,
|
unansweredCount: result.unansweredCount,
|
||||||
@ -372,10 +408,15 @@ class QuestionsHubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
num effectiveAnswer = answer;
|
||||||
|
if (question.isSpellingFollowUp && answer == 0) {
|
||||||
|
effectiveAnswer = 1;
|
||||||
|
}
|
||||||
|
|
||||||
await _submitAnswer(
|
await _submitAnswer(
|
||||||
answeredQuestionId: question.id,
|
answeredQuestionId: question.id,
|
||||||
question: question,
|
question: question,
|
||||||
answer: answer,
|
answer: effectiveAnswer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -386,6 +427,7 @@ class QuestionsHubService {
|
|||||||
String? answerText,
|
String? answerText,
|
||||||
}) async {
|
}) async {
|
||||||
questionActionBusy.value = true;
|
questionActionBusy.value = true;
|
||||||
|
final DateTime startTime = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final QuestionSubmitResult? result = await _api.submitAnswer(
|
final QuestionSubmitResult? result = await _api.submitAnswer(
|
||||||
questionId: answeredQuestionId,
|
questionId: answeredQuestionId,
|
||||||
@ -396,14 +438,35 @@ class QuestionsHubService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (question.usesSpellingWordTile) {
|
||||||
|
final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds;
|
||||||
|
final int remainingMs = 1000 - elapsedMs;
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: remainingMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (question.isMarketHistory) {
|
if (question.isMarketHistory) {
|
||||||
guessScoreSummary.value = result.score;
|
guessScoreSummary.value = result.score;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.nextQuestion != null &&
|
if (result.nextQuestion != null &&
|
||||||
_acceptsQuestionSource(result.nextQuestion!)) {
|
_acceptsQuestionSource(result.nextQuestion!)) {
|
||||||
|
IncomingQuestion nextQ = result.nextQuestion!;
|
||||||
|
if (nextQ.needsPrepare) {
|
||||||
|
try {
|
||||||
|
final IncomingQuestion? prepared = await _api.prepareSpellingQuestion(
|
||||||
|
questionId: nextQ.id,
|
||||||
|
);
|
||||||
|
if (prepared != null) {
|
||||||
|
nextQ = prepared;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to prepare next question in submit: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
_setActiveQuestion(
|
_setActiveQuestion(
|
||||||
result.nextQuestion!,
|
nextQ,
|
||||||
unansweredCount: result.unansweredCount,
|
unansweredCount: result.unansweredCount,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -44,9 +44,11 @@ class QuestionPresentationRouter extends StatelessWidget {
|
|||||||
return SwipeQuestionTile(
|
return SwipeQuestionTile(
|
||||||
key: ValueKey<String>(question.id),
|
key: ValueKey<String>(question.id),
|
||||||
questionId: question.id,
|
questionId: question.id,
|
||||||
|
questionText: question.text,
|
||||||
busy: busy,
|
busy: busy,
|
||||||
onSwipeRight: onAnswer,
|
onSwipeRight: onAnswer,
|
||||||
onSwipeLeft: onDefer,
|
onSwipeLeft: onDefer,
|
||||||
|
showGuidGlyph: !question.isSpelling,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -195,12 +195,23 @@ class _SpellingQuestionTileState extends State<SpellingQuestionTile> {
|
|||||||
_showingFeedback = true;
|
_showingFeedback = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
await Future<void>.delayed(const Duration(seconds: 1));
|
final DateTime startTime = DateTime.now();
|
||||||
if (!mounted) {
|
try {
|
||||||
return;
|
await widget.onSubmitAnswer(answer);
|
||||||
|
} catch (_) {
|
||||||
|
final int elapsedMs = DateTime.now().difference(startTime).inMilliseconds;
|
||||||
|
final int remainingMs = 1000 - elapsedMs;
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
await Future<void>.delayed(Duration(milliseconds: remainingMs));
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showingFeedback = false;
|
||||||
|
_answerCorrect = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|
||||||
await widget.onSubmitAnswer(answer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -33,12 +33,14 @@ class SwipeQuestionTile extends StatefulWidget {
|
|||||||
required this.questionId,
|
required this.questionId,
|
||||||
required this.onSwipeRight,
|
required this.onSwipeRight,
|
||||||
required this.onSwipeLeft,
|
required this.onSwipeLeft,
|
||||||
|
this.questionText,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
this.swipeAxisToleranceDegrees = 25,
|
this.swipeAxisToleranceDegrees = 25,
|
||||||
this.showGuidGlyph = true,
|
this.showGuidGlyph = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String questionId;
|
final String questionId;
|
||||||
|
final String? questionText;
|
||||||
final Future<void> Function(num answer) onSwipeRight;
|
final Future<void> Function(num answer) onSwipeRight;
|
||||||
final Future<void> Function() onSwipeLeft;
|
final Future<void> Function() onSwipeLeft;
|
||||||
final bool busy;
|
final bool busy;
|
||||||
@ -82,7 +84,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
/// Updated each build from the tile height so ±10 reaches near the track edges.
|
/// Updated each build from the tile height so ±10 reaches near the track edges.
|
||||||
double _maxVerticalDrag = 120;
|
double _maxVerticalDrag = 120;
|
||||||
|
|
||||||
bool get _atZero => _snappedSliderValue == 0;
|
bool get _atZero => widget.showGuidGlyph ? _snappedSliderValue == 0 : false;
|
||||||
|
|
||||||
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
|
bool get _holdSaveEnabled => !widget.busy && !_acting && !_atZero;
|
||||||
|
|
||||||
@ -326,6 +328,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
_snappedSliderValue / _sliderMax * _maxVerticalDrag;
|
_snappedSliderValue / _sliderMax * _maxVerticalDrag;
|
||||||
|
|
||||||
void _applyVerticalInputDelta(double dy) {
|
void _applyVerticalInputDelta(double dy) {
|
||||||
|
if (!widget.showGuidGlyph) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_verticalOffset -= dy;
|
_verticalOffset -= dy;
|
||||||
_verticalOffset = _verticalOffset.clamp(
|
_verticalOffset = _verticalOffset.clamp(
|
||||||
@ -468,7 +473,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
|
|
||||||
/// Steps the slider by [delta] integers, clamped to [-10, 10].
|
/// Steps the slider by [delta] integers, clamped to [-10, 10].
|
||||||
void _stepSliderBy(int delta) {
|
void _stepSliderBy(int delta) {
|
||||||
if (widget.busy || _acting || delta == 0) {
|
if (widget.busy || _acting || delta == 0 || !widget.showGuidGlyph) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
|
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
|
||||||
@ -743,6 +748,26 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else if (widget.questionText != null)
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
left: _centerSideInset,
|
||||||
|
right: _centerSideInset,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
widget.questionText!,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@ -101,6 +101,7 @@ Future<void> main() async {
|
|||||||
marketDataDb: marketDataDb,
|
marketDataDb: marketDataDb,
|
||||||
tradingConfigDb: tradingConfigDb,
|
tradingConfigDb: tradingConfigDb,
|
||||||
tradingStateDb: tradingStateDb,
|
tradingStateDb: tradingStateDb,
|
||||||
|
questionSourcePreferencesDb: questionSourcePreferencesDb,
|
||||||
marketHistoryQuery: MarketHistoryQuery(connection: db.connection),
|
marketHistoryQuery: MarketHistoryQuery(connection: db.connection),
|
||||||
marketHistoryEnv: mh,
|
marketHistoryEnv: mh,
|
||||||
guardrails: Guardrails(allowLive: env.alpaca.allowLive),
|
guardrails: Guardrails(allowLive: env.alpaca.allowLive),
|
||||||
|
|||||||
@ -198,10 +198,11 @@ class QuestionPipeline {
|
|||||||
}
|
}
|
||||||
final List<ResolvedQuestionSourcePreference> prefs =
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
await preferencesDb.getResolved(firebaseUid);
|
await preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Map<String, int> mix = QuestionSourcePreferences.effectiveMix(prefs);
|
||||||
final Set<String> allowed = prefs
|
final Set<String> allowed = prefs
|
||||||
.where(
|
.where(
|
||||||
(ResolvedQuestionSourcePreference s) =>
|
(ResolvedQuestionSourcePreference s) =>
|
||||||
s.enabled && !s.comingSoon,
|
s.enabled && !s.comingSoon && (mix[s.id] ?? 0) > 0,
|
||||||
)
|
)
|
||||||
.map((ResolvedQuestionSourcePreference s) => s.id)
|
.map((ResolvedQuestionSourcePreference s) => s.id)
|
||||||
.toSet();
|
.toSet();
|
||||||
@ -229,7 +230,7 @@ class QuestionPipeline {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _canEnqueue(firebaseUid)) {
|
if (pipelineKey != PipelineKeys.spelling && !await _canEnqueue(firebaseUid)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -730,8 +731,8 @@ class QuestionPipeline {
|
|||||||
/// question is immediately dispatchable and the client never needs a separate
|
/// question is immediately dispatchable and the client never needs a separate
|
||||||
/// prepare call. Falls back to a [spelling_pending] placeholder only when no
|
/// prepare call. Falls back to a [spelling_pending] placeholder only when no
|
||||||
/// catalog words are available.
|
/// catalog words are available.
|
||||||
Future<void> _startSpellingPipeline(String firebaseUid) async {
|
Future<void> _startSpellingPipeline(String firebaseUid, {bool force = false}) async {
|
||||||
if (!await _canEnqueue(firebaseUid)) {
|
if (!force && !await _canEnqueue(firebaseUid)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await _isSpellingEnabled(firebaseUid)) {
|
if (!await _isSpellingEnabled(firebaseUid)) {
|
||||||
@ -843,7 +844,7 @@ class QuestionPipeline {
|
|||||||
);
|
);
|
||||||
case PipelineSteps.spellingFollowUp:
|
case PipelineSteps.spellingFollowUp:
|
||||||
if (userResponse > 0) {
|
if (userResponse > 0) {
|
||||||
await _startSpellingPipeline(firebaseUid);
|
await _startSpellingPipeline(firebaseUid, force: true);
|
||||||
} else {
|
} else {
|
||||||
await _setIdle(firebaseUid, PipelineKeys.spelling);
|
await _setIdle(firebaseUid, PipelineKeys.spelling);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'pipeline/question_pipeline.dart';
|
import 'pipeline/question_pipeline.dart';
|
||||||
import 'question_service.dart';
|
import 'question_service.dart';
|
||||||
import 'question_source_mix.dart';
|
import 'question_source_mix.dart';
|
||||||
@ -14,15 +16,18 @@ class QuestionDispatcher {
|
|||||||
required QuestionSourcePreferencesDb preferencesDb,
|
required QuestionSourcePreferencesDb preferencesDb,
|
||||||
this.debugLoggingEnabled = false,
|
this.debugLoggingEnabled = false,
|
||||||
this.maxQueuedQuestions = 3,
|
this.maxQueuedQuestions = 3,
|
||||||
|
Random? random,
|
||||||
}) : _questionsDb = questionsDb,
|
}) : _questionsDb = questionsDb,
|
||||||
_questionService = questionService,
|
_questionService = questionService,
|
||||||
_questionPipeline = questionPipeline,
|
_questionPipeline = questionPipeline,
|
||||||
_preferencesDb = preferencesDb;
|
_preferencesDb = preferencesDb,
|
||||||
|
_random = random ?? Random();
|
||||||
|
|
||||||
final QuestionsDb _questionsDb;
|
final QuestionsDb _questionsDb;
|
||||||
final QuestionService _questionService;
|
final QuestionService _questionService;
|
||||||
final QuestionPipeline _questionPipeline;
|
final QuestionPipeline _questionPipeline;
|
||||||
final QuestionSourcePreferencesDb _preferencesDb;
|
final QuestionSourcePreferencesDb _preferencesDb;
|
||||||
|
final Random _random;
|
||||||
final bool debugLoggingEnabled;
|
final bool debugLoggingEnabled;
|
||||||
final int maxQueuedQuestions;
|
final int maxQueuedQuestions;
|
||||||
|
|
||||||
@ -99,12 +104,25 @@ class QuestionDispatcher {
|
|||||||
required Map<String, dynamic> answeredQuestion,
|
required Map<String, dynamic> answeredQuestion,
|
||||||
}) async {
|
}) async {
|
||||||
_log('nextQuestionAfterAnswer($firebaseUid)');
|
_log('nextQuestionAfterAnswer($firebaseUid)');
|
||||||
|
final String answeredSourceId = QuestionsDb.resolveSourceId(answeredQuestion);
|
||||||
|
|
||||||
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
var rows = await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
_log('Queue empty, falling back to dispatchNextQuestion');
|
_log('Queue empty, falling back to dispatchNextQuestion');
|
||||||
return dispatchNextQuestion(firebaseUid);
|
return dispatchNextQuestion(firebaseUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pipeline continuity: If we just answered a spelling question and another
|
||||||
|
// spelling question is available (e.g. follow-up or next word quiz), return it
|
||||||
|
// immediately. Pipelines should not be interrupted by mix changes.
|
||||||
|
if (answeredSourceId == 'spelling') {
|
||||||
|
for (final Map<String, dynamic> row in rows) {
|
||||||
|
if (QuestionsDb.resolveSourceId(row) == 'spelling') {
|
||||||
|
return _toClientPayload(row, rows.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final List<ResolvedQuestionSourcePreference> prefs =
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
await _preferencesDb.getResolved(firebaseUid);
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||||
@ -118,9 +136,8 @@ class QuestionDispatcher {
|
|||||||
firebaseUid,
|
firebaseUid,
|
||||||
effectiveMixPercent: mix,
|
effectiveMixPercent: mix,
|
||||||
deliveredCounts: delivered,
|
deliveredCounts: delivered,
|
||||||
afterAnsweringSourceId:
|
|
||||||
QuestionsDb.resolveSourceId(answeredQuestion),
|
|
||||||
allowedSourceIds: allowed,
|
allowedSourceIds: allowed,
|
||||||
|
random: _random,
|
||||||
);
|
);
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
return dispatchNextQuestion(firebaseUid);
|
return dispatchNextQuestion(firebaseUid);
|
||||||
@ -187,6 +204,7 @@ class QuestionDispatcher {
|
|||||||
final String primary = QuestionSourceMix.pickNextSourceId(
|
final String primary = QuestionSourceMix.pickNextSourceId(
|
||||||
effectiveMixPercent: mix,
|
effectiveMixPercent: mix,
|
||||||
deliveredCounts: delivered,
|
deliveredCounts: delivered,
|
||||||
|
random: _random,
|
||||||
);
|
);
|
||||||
_log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)');
|
_log('_enqueueOneFromMix: picked primary=$primary (mix=$mix, delivered=$delivered)');
|
||||||
final List<String> order = _sourceTryOrderForEnqueue(
|
final List<String> order = _sourceTryOrderForEnqueue(
|
||||||
@ -229,6 +247,7 @@ class QuestionDispatcher {
|
|||||||
firebaseUid,
|
firebaseUid,
|
||||||
effectiveMixPercent: mix,
|
effectiveMixPercent: mix,
|
||||||
allowedSourceIds: allowed,
|
allowedSourceIds: allowed,
|
||||||
|
random: _random,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,44 +1,42 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'question_source_preferences.dart';
|
import 'question_source_preferences.dart';
|
||||||
import 'questions_db.dart';
|
import 'questions_db.dart';
|
||||||
|
|
||||||
/// Picks the next question source to match configured mix percentages over time.
|
/// Picks the next question source randomly matching configured mix percentages.
|
||||||
abstract final class QuestionSourceMix {
|
abstract final class QuestionSourceMix {
|
||||||
/// Source id with the largest deficit vs configured effective mix (sequential balance).
|
/// Source id selected using weighted random selection based on configured mix ratios.
|
||||||
static String pickNextSourceId({
|
static String pickNextSourceId({
|
||||||
required Map<String, int> effectiveMixPercent,
|
required Map<String, int> effectiveMixPercent,
|
||||||
required Map<String, int> deliveredCounts,
|
required Map<String, int> deliveredCounts,
|
||||||
|
Random? random,
|
||||||
}) {
|
}) {
|
||||||
if (effectiveMixPercent.isEmpty) {
|
if (effectiveMixPercent.isEmpty) {
|
||||||
return 'market_history';
|
return 'market_history';
|
||||||
}
|
}
|
||||||
|
|
||||||
final int totalDelivered =
|
final int totalWeight =
|
||||||
deliveredCounts.values.fold<int>(0, (int a, int b) => a + b);
|
effectiveMixPercent.values.fold<int>(0, (int a, int b) => a + b);
|
||||||
if (totalDelivered == 0) {
|
if (totalWeight <= 0) {
|
||||||
return effectiveMixPercent.entries
|
return effectiveMixPercent.keys.first;
|
||||||
.reduce(
|
|
||||||
(
|
|
||||||
MapEntry<String, int> a,
|
|
||||||
MapEntry<String, int> b,
|
|
||||||
) =>
|
|
||||||
a.value >= b.value ? a : b,
|
|
||||||
)
|
|
||||||
.key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? pick;
|
// If one source has 100% of the active weight, bypass the random roll completely.
|
||||||
var maxDeficit = double.negativeInfinity;
|
|
||||||
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
|
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
|
||||||
final double target = entry.value / 100.0;
|
if (entry.value == totalWeight) {
|
||||||
final double actual =
|
return entry.key;
|
||||||
(deliveredCounts[entry.key] ?? 0) / totalDelivered;
|
|
||||||
final double deficit = target - actual;
|
|
||||||
if (deficit > maxDeficit) {
|
|
||||||
maxDeficit = deficit;
|
|
||||||
pick = entry.key;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return pick ?? effectiveMixPercent.keys.first;
|
|
||||||
|
final Random rng = random ?? Random();
|
||||||
|
var roll = rng.nextInt(totalWeight);
|
||||||
|
for (final MapEntry<String, int> entry in effectiveMixPercent.entries) {
|
||||||
|
roll -= entry.value;
|
||||||
|
if (roll < 0) {
|
||||||
|
return entry.key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effectiveMixPercent.keys.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, int> effectiveMixFromResolved(
|
static Map<String, int> effectiveMixFromResolved(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:postgres/postgres.dart';
|
import 'package:postgres/postgres.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@ -273,8 +274,8 @@ class QuestionsDb {
|
|||||||
String assignedUserId, {
|
String assignedUserId, {
|
||||||
required Map<String, int> effectiveMixPercent,
|
required Map<String, int> effectiveMixPercent,
|
||||||
Map<String, int>? deliveredCounts,
|
Map<String, int>? deliveredCounts,
|
||||||
String? afterAnsweringSourceId,
|
|
||||||
Set<String>? allowedSourceIds,
|
Set<String>? allowedSourceIds,
|
||||||
|
Random? random,
|
||||||
}) async {
|
}) async {
|
||||||
List<Map<String, dynamic>> rows =
|
List<Map<String, dynamic>> rows =
|
||||||
await listUnansweredQuestions(assignedUserId);
|
await listUnansweredQuestions(assignedUserId);
|
||||||
@ -285,19 +286,12 @@ class QuestionsDb {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (afterAnsweringSourceId == 'spelling') {
|
|
||||||
for (final Map<String, dynamic> row in rows) {
|
|
||||||
if (resolveSourceId(row) == 'spelling') {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, int> delivered =
|
final Map<String, int> delivered =
|
||||||
deliveredCounts ?? await countDeliveredBySourceId(assignedUserId);
|
deliveredCounts ?? await countDeliveredBySourceId(assignedUserId);
|
||||||
final String preferred = QuestionSourceMix.pickNextSourceId(
|
final String preferred = QuestionSourceMix.pickNextSourceId(
|
||||||
effectiveMixPercent: effectiveMixPercent,
|
effectiveMixPercent: effectiveMixPercent,
|
||||||
deliveredCounts: delivered,
|
deliveredCounts: delivered,
|
||||||
|
random: random,
|
||||||
);
|
);
|
||||||
final List<Map<String, dynamic>> ordered =
|
final List<Map<String, dynamic>> ordered =
|
||||||
QuestionSourceMix.orderUnansweredByPreferredSource(
|
QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
@ -312,6 +306,7 @@ class QuestionsDb {
|
|||||||
String assignedUserId, {
|
String assignedUserId, {
|
||||||
required Map<String, int> effectiveMixPercent,
|
required Map<String, int> effectiveMixPercent,
|
||||||
Set<String>? allowedSourceIds,
|
Set<String>? allowedSourceIds,
|
||||||
|
Random? random,
|
||||||
}) async {
|
}) async {
|
||||||
List<Map<String, dynamic>> rows =
|
List<Map<String, dynamic>> rows =
|
||||||
await listUnansweredQuestions(assignedUserId);
|
await listUnansweredQuestions(assignedUserId);
|
||||||
@ -326,6 +321,7 @@ class QuestionsDb {
|
|||||||
final String preferred = QuestionSourceMix.pickNextSourceId(
|
final String preferred = QuestionSourceMix.pickNextSourceId(
|
||||||
effectiveMixPercent: effectiveMixPercent,
|
effectiveMixPercent: effectiveMixPercent,
|
||||||
deliveredCounts: delivered,
|
deliveredCounts: delivered,
|
||||||
|
random: random,
|
||||||
);
|
);
|
||||||
return QuestionSourceMix.orderUnansweredByPreferredSource(
|
return QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import 'symbol_obfuscator.dart';
|
|||||||
import 'trading_config.dart';
|
import 'trading_config.dart';
|
||||||
import 'trading_config_db.dart';
|
import 'trading_config_db.dart';
|
||||||
import 'user_trading_state_db.dart';
|
import 'user_trading_state_db.dart';
|
||||||
|
import '../question_source_preferences_db.dart';
|
||||||
|
|
||||||
/// Result of one [TradingPipeline.evaluate] cycle for a single user.
|
/// Result of one [TradingPipeline.evaluate] cycle for a single user.
|
||||||
class TradingEvaluationResult {
|
class TradingEvaluationResult {
|
||||||
@ -39,6 +40,7 @@ class TradingPipeline {
|
|||||||
required MarketDataDb marketDataDb,
|
required MarketDataDb marketDataDb,
|
||||||
required TradingConfigDb tradingConfigDb,
|
required TradingConfigDb tradingConfigDb,
|
||||||
required UserTradingStateDb tradingStateDb,
|
required UserTradingStateDb tradingStateDb,
|
||||||
|
QuestionSourcePreferencesDb? questionSourcePreferencesDb,
|
||||||
RuleEngine? ruleEngine,
|
RuleEngine? ruleEngine,
|
||||||
MarketHistoryQuery? marketHistoryQuery,
|
MarketHistoryQuery? marketHistoryQuery,
|
||||||
MarketHistoryEnv? marketHistoryEnv,
|
MarketHistoryEnv? marketHistoryEnv,
|
||||||
@ -51,6 +53,7 @@ class TradingPipeline {
|
|||||||
_marketDataDb = marketDataDb,
|
_marketDataDb = marketDataDb,
|
||||||
_tradingConfigDb = tradingConfigDb,
|
_tradingConfigDb = tradingConfigDb,
|
||||||
_tradingStateDb = tradingStateDb,
|
_tradingStateDb = tradingStateDb,
|
||||||
|
_questionSourcePreferencesDb = questionSourcePreferencesDb,
|
||||||
_ruleEngine = ruleEngine ?? RuleEngine(clock: clock),
|
_ruleEngine = ruleEngine ?? RuleEngine(clock: clock),
|
||||||
_marketHistoryQuery = marketHistoryQuery,
|
_marketHistoryQuery = marketHistoryQuery,
|
||||||
_marketHistoryEnv = marketHistoryEnv ?? MarketHistoryEnv.fromMap(<String, String>{}),
|
_marketHistoryEnv = marketHistoryEnv ?? MarketHistoryEnv.fromMap(<String, String>{}),
|
||||||
@ -64,6 +67,7 @@ class TradingPipeline {
|
|||||||
final MarketDataDb _marketDataDb;
|
final MarketDataDb _marketDataDb;
|
||||||
final TradingConfigDb _tradingConfigDb;
|
final TradingConfigDb _tradingConfigDb;
|
||||||
final UserTradingStateDb _tradingStateDb;
|
final UserTradingStateDb _tradingStateDb;
|
||||||
|
final QuestionSourcePreferencesDb? _questionSourcePreferencesDb;
|
||||||
final RuleEngine _ruleEngine;
|
final RuleEngine _ruleEngine;
|
||||||
final MarketHistoryQuery? _marketHistoryQuery;
|
final MarketHistoryQuery? _marketHistoryQuery;
|
||||||
final MarketHistoryEnv _marketHistoryEnv;
|
final MarketHistoryEnv _marketHistoryEnv;
|
||||||
@ -85,6 +89,15 @@ class TradingPipeline {
|
|||||||
final List<String> fired = <String>[];
|
final List<String> fired = <String>[];
|
||||||
final List<String> skipped = <String>[];
|
final List<String> skipped = <String>[];
|
||||||
|
|
||||||
|
if (_questionSourcePreferencesDb != null &&
|
||||||
|
!await _questionSourcePreferencesDb!.isSourceEnabled(firebaseUid, 'market_history')) {
|
||||||
|
return TradingEvaluationResult(
|
||||||
|
questionsCreated: 0,
|
||||||
|
rulesFired: fired,
|
||||||
|
rulesSkipped: skipped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final EffectiveTradingConfig? config =
|
final EffectiveTradingConfig? config =
|
||||||
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
|
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
|
||||||
if (config == null || !config.enabled) {
|
if (config == null || !config.enabled) {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:cyberhybridhub_server/db.dart';
|
import 'package:cyberhybridhub_server/db.dart';
|
||||||
import 'package:cyberhybridhub_server/question_source_preferences_db.dart';
|
import 'package:cyberhybridhub_server/question_source_preferences_db.dart';
|
||||||
@ -138,13 +139,14 @@ class TestDb {
|
|||||||
testMode: testMode,
|
testMode: testMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
QuestionDispatcher questionDispatcher({bool testMode = true}) {
|
QuestionDispatcher questionDispatcher({bool testMode = true, Random? random}) {
|
||||||
final QuestionPipeline pipeline = questionPipeline(testMode: testMode);
|
final QuestionPipeline pipeline = questionPipeline(testMode: testMode);
|
||||||
final QuestionDispatcher dispatcher = QuestionDispatcher(
|
final QuestionDispatcher dispatcher = QuestionDispatcher(
|
||||||
questionsDb: questionsDb,
|
questionsDb: questionsDb,
|
||||||
questionService: questionService(),
|
questionService: questionService(),
|
||||||
questionPipeline: pipeline,
|
questionPipeline: pipeline,
|
||||||
preferencesDb: questionSourcePreferencesDb,
|
preferencesDb: questionSourcePreferencesDb,
|
||||||
|
random: random ?? Random(1),
|
||||||
);
|
);
|
||||||
pipeline.attachQuestionDispatcher(dispatcher);
|
pipeline.attachQuestionDispatcher(dispatcher);
|
||||||
return dispatcher;
|
return dispatcher;
|
||||||
|
|||||||
@ -1,53 +1,48 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:cyberhybridhub_server/question_source_mix.dart';
|
import 'package:cyberhybridhub_server/question_source_mix.dart';
|
||||||
import 'package:cyberhybridhub_server/questions_db.dart';
|
import 'package:cyberhybridhub_server/questions_db.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('QuestionSourceMix.pickNextSourceId', () {
|
group('QuestionSourceMix.pickNextSourceId', () {
|
||||||
test('picks highest share when nothing delivered yet', () {
|
test('weighted random selection works deterministically with seeded random', () {
|
||||||
|
final Map<String, int> mix = <String, int>{
|
||||||
|
'market_history': 70,
|
||||||
|
'spelling': 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
var marketCount = 0;
|
||||||
|
var spellingCount = 0;
|
||||||
|
final Random random = Random(42);
|
||||||
|
|
||||||
|
for (var i = 0; i < 200; i++) {
|
||||||
|
final String pick = QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: mix,
|
||||||
|
deliveredCounts: <String, int>{},
|
||||||
|
random: random,
|
||||||
|
);
|
||||||
|
if (pick == 'market_history') {
|
||||||
|
marketCount++;
|
||||||
|
} else if (pick == 'spelling') {
|
||||||
|
spellingCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(marketCount, greaterThan(120));
|
||||||
|
expect(spellingCount, greaterThan(40));
|
||||||
|
expect(marketCount + spellingCount, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('always picks the only active source', () {
|
||||||
expect(
|
expect(
|
||||||
QuestionSourceMix.pickNextSourceId(
|
QuestionSourceMix.pickNextSourceId(
|
||||||
effectiveMixPercent: <String, int>{
|
effectiveMixPercent: <String, int>{'spelling': 100},
|
||||||
'market_history': 70,
|
|
||||||
'spelling': 30,
|
|
||||||
},
|
|
||||||
deliveredCounts: <String, int>{},
|
deliveredCounts: <String, int>{},
|
||||||
),
|
),
|
||||||
'market_history',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('picks spelling when market is ahead of target mix', () {
|
|
||||||
expect(
|
|
||||||
QuestionSourceMix.pickNextSourceId(
|
|
||||||
effectiveMixPercent: <String, int>{
|
|
||||||
'market_history': 50,
|
|
||||||
'spelling': 50,
|
|
||||||
},
|
|
||||||
deliveredCounts: <String, int>{
|
|
||||||
'market_history': 4,
|
|
||||||
'spelling': 0,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
'spelling',
|
'spelling',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('picks market when spelling is ahead of target mix', () {
|
|
||||||
expect(
|
|
||||||
QuestionSourceMix.pickNextSourceId(
|
|
||||||
effectiveMixPercent: <String, int>{
|
|
||||||
'market_history': 70,
|
|
||||||
'spelling': 30,
|
|
||||||
},
|
|
||||||
deliveredCounts: <String, int>{
|
|
||||||
'market_history': 1,
|
|
||||||
'spelling': 3,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
'market_history',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('QuestionSourceMix.orderUnansweredByPreferredSource', () {
|
group('QuestionSourceMix.orderUnansweredByPreferredSource', () {
|
||||||
|
|||||||
@ -140,4 +140,52 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
|
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Question Source config top bar appears when panel is open and configures source preferences', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
final QuestionsHubService hub = QuestionsHubService.instance;
|
||||||
|
hub.hasPendingQuestion.value = true;
|
||||||
|
hub.pendingQuestionCount.value = 1;
|
||||||
|
hub.questionPanelOpen.value = true;
|
||||||
|
|
||||||
|
addTearDown(() {
|
||||||
|
hub.hasPendingQuestion.value = false;
|
||||||
|
hub.pendingQuestionCount.value = 0;
|
||||||
|
hub.questionPanelOpen.value = false;
|
||||||
|
QuestionSourceConfigRepository.instance.resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const HomeScreen(
|
||||||
|
user: AppUser(uid: 'u1', displayName: 'Test User'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify config top bar is displayed when panel is open.
|
||||||
|
expect(find.byKey(const Key('question-source-config-top-bar')), findsOneWidget);
|
||||||
|
expect(find.text('Question source configuration'), findsOneWidget);
|
||||||
|
expect(find.text('Market question: 50%, Spelling: 50%'), findsOneWidget);
|
||||||
|
|
||||||
|
// Click on the Configure button to open settings dialog.
|
||||||
|
expect(find.byKey(const Key('question-source-config-top-bar-button')), findsOneWidget);
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-config-top-bar-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify settings dialog is open.
|
||||||
|
expect(find.byKey(const Key('question-source-settings-dialog')), findsOneWidget);
|
||||||
|
|
||||||
|
// Close settings dialog.
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-close')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('question-source-settings-dialog')), findsNothing);
|
||||||
|
|
||||||
|
// Close panel and verify top bar is gone.
|
||||||
|
hub.closeQuestionPanel();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
|
||||||
|
true,
|
||||||
|
);
|
||||||
expect(find.byType(SpellingQuestionTile), findsNothing);
|
expect(find.byType(SpellingQuestionTile), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -66,6 +70,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
expect(find.byType(SwipeQuestionTile), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.widget<SwipeQuestionTile>(find.byType(SwipeQuestionTile)).showGuidGlyph,
|
||||||
|
false,
|
||||||
|
);
|
||||||
expect(find.byType(SpellingQuestionTile), findsNothing);
|
expect(find.byType(SpellingQuestionTile), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
|
await tester.tap(find.byKey(const Key('spelling-submit-answer')));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(submitCount, 0);
|
expect(submitCount, 1);
|
||||||
final Text feedback = tester.widget<Text>(
|
final Text feedback = tester.widget<Text>(
|
||||||
find.byKey(const Key('spelling-feedback-word')),
|
find.byKey(const Key('spelling-feedback-word')),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user