working percentage
This commit is contained in:
parent
59b039a3c0
commit
78e5db6775
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../models/question_source_preference.dart';
|
import '../models/question_source_preference.dart';
|
||||||
import '../repositories/question_source_config_repository.dart';
|
import '../repositories/question_source_config_repository.dart';
|
||||||
@ -53,9 +54,191 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
).map((QuestionSourcePreference source) => source.copyWith()).toList();
|
).map((QuestionSourcePreference source) => source.copyWith()).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateSource(int index, QuestionSourcePreference next) {
|
List<int> _distribute(List<double> targetProportions, int targetSum) {
|
||||||
|
final List<double> values = targetProportions.map((double p) => p * targetSum).toList();
|
||||||
|
final List<int> floorValues = values.map((double v) => v.floor()).toList();
|
||||||
|
final int currentSum = floorValues.fold(0, (int sum, int val) => sum + val);
|
||||||
|
final int remainder = targetSum - currentSum;
|
||||||
|
|
||||||
|
if (remainder > 0) {
|
||||||
|
final List<int> indices = List<int>.generate(values.length, (int i) => i);
|
||||||
|
indices.sort((int a, int b) {
|
||||||
|
final double fracA = values[a] - floorValues[a];
|
||||||
|
final double fracB = values[b] - floorValues[b];
|
||||||
|
return fracB.compareTo(fracA);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < remainder; i++) {
|
||||||
|
floorValues[indices[i % indices.length]] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return floorValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateRatio(int targetIndex, int newRatio) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_draft[index] = next;
|
newRatio = newRatio.clamp(0, 100);
|
||||||
|
|
||||||
|
if (newRatio == 100) {
|
||||||
|
for (int i = 0; i < _draft.length; i++) {
|
||||||
|
if (i == targetIndex) {
|
||||||
|
_draft[i] = _draft[i].copyWith(enabled: true, ratio: 100);
|
||||||
|
} else {
|
||||||
|
_draft[i] = _draft[i].copyWith(enabled: false, ratio: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validationMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<MapEntry<int, QuestionSourcePreference>> otherActiveSources = _draft
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((MapEntry<int, QuestionSourcePreference> entry) =>
|
||||||
|
entry.key != targetIndex &&
|
||||||
|
!entry.value.comingSoon &&
|
||||||
|
entry.value.enabled)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (otherActiveSources.isEmpty) {
|
||||||
|
final List<int> disabledNonComingSoonIndices = _draft
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((MapEntry<int, QuestionSourcePreference> entry) =>
|
||||||
|
entry.key != targetIndex && !entry.value.comingSoon)
|
||||||
|
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (disabledNonComingSoonIndices.isNotEmpty) {
|
||||||
|
final int freedPercentage = 100 - newRatio;
|
||||||
|
final List<double> proportions = List<double>.filled(
|
||||||
|
disabledNonComingSoonIndices.length,
|
||||||
|
1.0 / disabledNonComingSoonIndices.length,
|
||||||
|
);
|
||||||
|
final List<int> distributed = _distribute(proportions, freedPercentage);
|
||||||
|
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio);
|
||||||
|
for (int i = 0; i < disabledNonComingSoonIndices.length; i++) {
|
||||||
|
final int idx = disabledNonComingSoonIndices[i];
|
||||||
|
_draft[idx] = _draft[idx].copyWith(enabled: true, ratio: distributed[i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100);
|
||||||
|
}
|
||||||
|
_validationMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int targetOtherSum = 100 - newRatio;
|
||||||
|
final int currentOtherSum = otherActiveSources.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, MapEntry<int, QuestionSourcePreference> entry) => sum + entry.value.ratio,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<int> otherIndices = otherActiveSources.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key).toList();
|
||||||
|
final List<double> proportions = <double>[];
|
||||||
|
if (currentOtherSum > 0) {
|
||||||
|
for (final int idx in otherIndices) {
|
||||||
|
proportions.add(_draft[idx].ratio / currentOtherSum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < otherIndices.length; i++) {
|
||||||
|
proportions.add(1.0 / otherIndices.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<int> distributed = _distribute(proportions, targetOtherSum);
|
||||||
|
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: newRatio);
|
||||||
|
for (int i = 0; i < otherIndices.length; i++) {
|
||||||
|
final int idx = otherIndices[i];
|
||||||
|
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
|
||||||
|
}
|
||||||
|
_validationMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSourceEnabled(int targetIndex, bool enabled) {
|
||||||
|
setState(() {
|
||||||
|
if (enabled) {
|
||||||
|
final List<int> otherActiveIndices = _draft
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((MapEntry<int, QuestionSourcePreference> entry) =>
|
||||||
|
entry.key != targetIndex &&
|
||||||
|
!entry.value.comingSoon &&
|
||||||
|
entry.value.enabled)
|
||||||
|
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (otherActiveIndices.isEmpty) {
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: 100);
|
||||||
|
} else {
|
||||||
|
final int newCount = otherActiveIndices.length + 1;
|
||||||
|
final int targetRatio = (100 / newCount).round();
|
||||||
|
final int remainingRatio = 100 - targetRatio;
|
||||||
|
|
||||||
|
final int currentOtherSum = otherActiveIndices.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, int idx) => sum + _draft[idx].ratio,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<double> proportions = <double>[];
|
||||||
|
if (currentOtherSum > 0) {
|
||||||
|
for (final int idx in otherActiveIndices) {
|
||||||
|
proportions.add(_draft[idx].ratio / currentOtherSum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < otherActiveIndices.length; i++) {
|
||||||
|
proportions.add(1.0 / otherActiveIndices.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<int> distributed = _distribute(proportions, remainingRatio);
|
||||||
|
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: true, ratio: targetRatio);
|
||||||
|
for (int i = 0; i < otherActiveIndices.length; i++) {
|
||||||
|
final int idx = otherActiveIndices[i];
|
||||||
|
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_draft[targetIndex] = _draft[targetIndex].copyWith(enabled: false, ratio: 0);
|
||||||
|
|
||||||
|
final List<int> otherActiveIndices = _draft
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.where((MapEntry<int, QuestionSourcePreference> entry) =>
|
||||||
|
entry.key != targetIndex &&
|
||||||
|
!entry.value.comingSoon &&
|
||||||
|
entry.value.enabled)
|
||||||
|
.map((MapEntry<int, QuestionSourcePreference> entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (otherActiveIndices.isNotEmpty) {
|
||||||
|
final int currentOtherSum = otherActiveIndices.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, int idx) => sum + _draft[idx].ratio,
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<double> proportions = <double>[];
|
||||||
|
if (currentOtherSum > 0) {
|
||||||
|
for (final int idx in otherActiveIndices) {
|
||||||
|
proportions.add(_draft[idx].ratio / currentOtherSum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < otherActiveIndices.length; i++) {
|
||||||
|
proportions.add(1.0 / otherActiveIndices.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<int> distributed = _distribute(proportions, 100);
|
||||||
|
for (int i = 0; i < otherActiveIndices.length; i++) {
|
||||||
|
final int idx = otherActiveIndices[i];
|
||||||
|
_draft[idx] = _draft[idx].copyWith(ratio: distributed[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_validationMessage = null;
|
_validationMessage = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -197,8 +380,8 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
key: Key('question-source-row-${_draft[index].id}'),
|
key: Key('question-source-row-${_draft[index].id}'),
|
||||||
source: _draft[index],
|
source: _draft[index],
|
||||||
effectivePercent: mix[_draft[index].id],
|
effectivePercent: mix[_draft[index].id],
|
||||||
onChanged: (QuestionSourcePreference next) =>
|
onRatioChanged: (int ratio) => _updateRatio(index, ratio),
|
||||||
_updateSource(index, next),
|
onEnabledChanged: (bool enabled) => _toggleSourceEnabled(index, enabled),
|
||||||
onEditSpelling: _draft[index].id == 'spelling'
|
onEditSpelling: _draft[index].id == 'spelling'
|
||||||
? () => _openSpellingWordList(context)
|
? () => _openSpellingWordList(context)
|
||||||
: null,
|
: null,
|
||||||
@ -281,12 +464,13 @@ class _QuestionSourceSettingsSheetState extends State<QuestionSourceSettingsShee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuestionSourceRow extends StatelessWidget {
|
class _QuestionSourceRow extends StatefulWidget {
|
||||||
const _QuestionSourceRow({
|
const _QuestionSourceRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.source,
|
required this.source,
|
||||||
required this.effectivePercent,
|
required this.effectivePercent,
|
||||||
required this.onChanged,
|
required this.onRatioChanged,
|
||||||
|
required this.onEnabledChanged,
|
||||||
this.onEditSpelling,
|
this.onEditSpelling,
|
||||||
this.onResetSpelling,
|
this.onResetSpelling,
|
||||||
this.resetSpellingBusy = false,
|
this.resetSpellingBusy = false,
|
||||||
@ -294,11 +478,73 @@ class _QuestionSourceRow extends StatelessWidget {
|
|||||||
|
|
||||||
final QuestionSourcePreference source;
|
final QuestionSourcePreference source;
|
||||||
final int? effectivePercent;
|
final int? effectivePercent;
|
||||||
final ValueChanged<QuestionSourcePreference> onChanged;
|
final ValueChanged<int> onRatioChanged;
|
||||||
|
final ValueChanged<bool> onEnabledChanged;
|
||||||
final VoidCallback? onEditSpelling;
|
final VoidCallback? onEditSpelling;
|
||||||
final VoidCallback? onResetSpelling;
|
final VoidCallback? onResetSpelling;
|
||||||
final bool resetSpellingBusy;
|
final bool resetSpellingBusy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_QuestionSourceRow> createState() => _QuestionSourceRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuestionSourceRowState extends State<_QuestionSourceRow> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
late final FocusNode _focusNode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: '${widget.source.ratio}');
|
||||||
|
_focusNode = FocusNode();
|
||||||
|
_focusNode.addListener(_onFocusChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _QuestionSourceRow oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.source.ratio != widget.source.ratio) {
|
||||||
|
final int? parsed = int.tryParse(_controller.text);
|
||||||
|
if (parsed != widget.source.ratio) {
|
||||||
|
if (_controller.text.isEmpty && widget.source.ratio == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_controller.text = '${widget.source.ratio}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.removeListener(_onFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFocusChange() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
if (_controller.text.isEmpty) {
|
||||||
|
_controller.text = '0';
|
||||||
|
widget.onRatioChanged(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChange(String text) {
|
||||||
|
if (text.isEmpty) {
|
||||||
|
widget.onRatioChanged(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int? value = int.tryParse(text);
|
||||||
|
if (value != null) {
|
||||||
|
final int clamped = value.clamp(0, 100);
|
||||||
|
if (clamped != widget.source.ratio) {
|
||||||
|
widget.onRatioChanged(clamped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
@ -324,11 +570,11 @@ class _QuestionSourceRow extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
source.label,
|
widget.source.label,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (source.comingSoon) ...<Widget>[
|
if (widget.source.comingSoon) ...<Widget>[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
DecoratedBox(
|
DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -358,61 +604,116 @@ class _QuestionSourceRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
source.description,
|
widget.source.description,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onEditSpelling != null)
|
if (widget.onEditSpelling != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('question-source-edit-spelling'),
|
key: const Key('question-source-edit-spelling'),
|
||||||
tooltip: 'Edit spelling word list',
|
tooltip: 'Edit spelling word list',
|
||||||
onPressed: resetSpellingBusy ? null : onEditSpelling,
|
onPressed: widget.resetSpellingBusy ? null : widget.onEditSpelling,
|
||||||
icon: const Icon(Icons.edit, size: 20),
|
icon: const Icon(Icons.edit, size: 20),
|
||||||
),
|
),
|
||||||
if (onResetSpelling != null)
|
if (widget.onResetSpelling != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('question-source-reset-spelling'),
|
key: const Key('question-source-reset-spelling'),
|
||||||
tooltip: 'Reset spelling progress',
|
tooltip: 'Reset spelling progress',
|
||||||
onPressed: resetSpellingBusy ? null : onResetSpelling,
|
onPressed: widget.resetSpellingBusy ? null : widget.onResetSpelling,
|
||||||
icon: const Icon(Icons.restart_alt, size: 20),
|
icon: const Icon(Icons.restart_alt, size: 20),
|
||||||
),
|
),
|
||||||
Switch(
|
Switch(
|
||||||
key: Key('question-source-enabled-${source.id}'),
|
key: Key('question-source-enabled-${widget.source.id}'),
|
||||||
value: source.enabled,
|
value: widget.source.enabled,
|
||||||
onChanged: source.comingSoon
|
onChanged: widget.source.comingSoon
|
||||||
? null
|
? null
|
||||||
: (bool enabled) =>
|
: (bool enabled) => widget.onEnabledChanged(enabled),
|
||||||
onChanged(source.copyWith(enabled: enabled)),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (source.enabled && !source.comingSoon) ...<Widget>[
|
if (widget.source.enabled && !widget.source.comingSoon) ...<Widget>[
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Text('Weight'),
|
const Text('Weight'),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
SizedBox(
|
||||||
effectivePercent == null
|
width: 70,
|
||||||
? '${source.ratio}'
|
height: 36,
|
||||||
: '${source.ratio} (${effectivePercent}%)',
|
child: TextField(
|
||||||
|
key: Key('question-source-input-${widget.source.id}'),
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.accent,
|
color: AppColors.accent,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
suffixText: '%',
|
||||||
|
suffixStyle: const TextStyle(
|
||||||
|
color: AppColors.accent,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: AppColors.accent.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputFormatters: <TextInputFormatter>[
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
onChanged: (String text) {
|
||||||
|
final int? val = int.tryParse(text);
|
||||||
|
if (val != null) {
|
||||||
|
if (val > 100) {
|
||||||
|
_controller.text = '100';
|
||||||
|
_controller.selection = TextSelection.fromPosition(
|
||||||
|
const TextPosition(offset: 3),
|
||||||
|
);
|
||||||
|
widget.onRatioChanged(100);
|
||||||
|
} else {
|
||||||
|
_onTextChange(text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_onTextChange(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Slider(
|
Slider(
|
||||||
key: Key('question-source-ratio-${source.id}'),
|
key: Key('question-source-ratio-${widget.source.id}'),
|
||||||
value: source.ratio.toDouble(),
|
value: widget.source.ratio.toDouble(),
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
divisions: 20,
|
divisions: 100,
|
||||||
label: '${source.ratio}',
|
label: '${widget.source.ratio}',
|
||||||
onChanged: (double value) =>
|
onChanged: (double value) =>
|
||||||
onChanged(source.copyWith(ratio: value.round())),
|
widget.onRatioChanged(value.round()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@ -147,6 +148,7 @@ Handler questionsHandler({
|
|||||||
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
(nextQuestion['unansweredCount'] as num?)?.toInt() ??
|
||||||
unansweredCount;
|
unansweredCount;
|
||||||
}
|
}
|
||||||
|
unawaited(questionDispatcher.topUpQueue(firebaseUid));
|
||||||
final bool isMarketAnswer =
|
final bool isMarketAnswer =
|
||||||
QuestionsDb.resolveSourceId(updated) == 'market_history';
|
QuestionsDb.resolveSourceId(updated) == 'market_history';
|
||||||
final Map<String, dynamic>? score = isMarketAnswer
|
final Map<String, dynamic>? score = isMarketAnswer
|
||||||
@ -232,6 +234,7 @@ Handler questionsHandler({
|
|||||||
firebaseUid: firebaseUid,
|
firebaseUid: firebaseUid,
|
||||||
deferredQuestionId: id,
|
deferredQuestionId: id,
|
||||||
);
|
);
|
||||||
|
unawaited(questionDispatcher.topUpQueue(firebaseUid));
|
||||||
return _jsonResponse(200, <String, dynamic>{
|
return _jsonResponse(200, <String, dynamic>{
|
||||||
'question': updated,
|
'question': updated,
|
||||||
'unansweredCount': unansweredCount,
|
'unansweredCount': unansweredCount,
|
||||||
|
|||||||
@ -496,6 +496,11 @@ class QuestionPipeline {
|
|||||||
await _startSpellingPipeline(firebaseUid);
|
await _startSpellingPipeline(firebaseUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Force enqueues a new spelling question (e.g. after a deferral).
|
||||||
|
Future<void> forceEnqueueSpellingQuestion(String firebaseUid) async {
|
||||||
|
await _startSpellingPipeline(firebaseUid, force: true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _loadContext(
|
Future<Map<String, dynamic>> _loadContext(
|
||||||
String firebaseUid,
|
String firebaseUid,
|
||||||
String pipelineKey,
|
String pipelineKey,
|
||||||
|
|||||||
@ -104,25 +104,12 @@ 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);
|
||||||
@ -150,17 +137,49 @@ class QuestionDispatcher {
|
|||||||
required String firebaseUid,
|
required String firebaseUid,
|
||||||
required String deferredQuestionId,
|
required String deferredQuestionId,
|
||||||
}) async {
|
}) async {
|
||||||
|
final List<ResolvedQuestionSourcePreference> prefs =
|
||||||
|
await _preferencesDb.getResolved(firebaseUid);
|
||||||
|
final Map<String, int> mix = QuestionSourceMix.effectiveMixFromResolved(prefs);
|
||||||
|
final Map<String, int> delivered =
|
||||||
|
await _questionsDb.countDeliveredBySourceId(firebaseUid);
|
||||||
|
|
||||||
|
final String preferred = QuestionSourceMix.pickNextSourceId(
|
||||||
|
effectiveMixPercent: mix,
|
||||||
|
deliveredCounts: delivered,
|
||||||
|
random: _random,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If spelling was rolled/preferred, but the only spelling question is the deferred one,
|
||||||
|
// force enqueue a new spelling question (so the user gets a different word).
|
||||||
|
if (preferred == 'spelling') {
|
||||||
|
final List<Map<String, dynamic>> currentRows =
|
||||||
|
await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
|
final bool hasOtherSpelling = currentRows.any((Map<String, dynamic> row) =>
|
||||||
|
QuestionsDb.resolveSourceId(row) == 'spelling' &&
|
||||||
|
row['id'] != deferredQuestionId);
|
||||||
|
if (!hasOtherSpelling && QuestionSourcePreferences.isEnabled(prefs, 'spelling')) {
|
||||||
|
await _questionPipeline.forceEnqueueSpellingQuestion(firebaseUid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final List<Map<String, dynamic>> rows =
|
final List<Map<String, dynamic>> rows =
|
||||||
await _listEnabledUnansweredOrdered(firebaseUid);
|
await _listEnabledUnansweredOrdered(firebaseUid);
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
return dispatchNextQuestion(firebaseUid);
|
return dispatchNextQuestion(firebaseUid);
|
||||||
}
|
}
|
||||||
for (final Map<String, dynamic> row in rows) {
|
|
||||||
|
final List<Map<String, dynamic>> ordered =
|
||||||
|
QuestionSourceMix.orderUnansweredByPreferredSource(
|
||||||
|
rows,
|
||||||
|
preferredSourceId: preferred,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final Map<String, dynamic> row in ordered) {
|
||||||
if (row['id'] != deferredQuestionId) {
|
if (row['id'] != deferredQuestionId) {
|
||||||
return _toClientPayload(row, rows.length);
|
return _toClientPayload(row, rows.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _toClientPayload(rows.first, rows.length);
|
return _toClientPayload(ordered.first, rows.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks.
|
/// Fills the queue up to [maxQueuedQuestions] using mix-sequential source picks.
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
@Tags(<String>['integration', 'postgres'])
|
@Tags(<String>['integration', 'postgres'])
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
import 'package:cyberhybridhub_server/question_dispatcher.dart';
|
||||||
import 'package:cyberhybridhub_server/questions_db.dart';
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
import '../helpers/test_db.dart';
|
import '../helpers/test_db.dart';
|
||||||
@ -145,4 +146,153 @@ void main() {
|
|||||||
expect(panel.first['sourceId'], 'spelling');
|
expect(panel.first['sourceId'], 'spelling');
|
||||||
expect(panel.first['presentation'], 'spelling_word');
|
expect(panel.first['presentation'], 'spelling_word');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nextQuestionAfterAnswer respects random roll for spelling mix when spelling is 50%',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-50-next-answer-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 50},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a market question and a spelling question.
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Market question',
|
||||||
|
correctAnswer: 5,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
pipelineKey: 'trading',
|
||||||
|
);
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Spelling question',
|
||||||
|
correctAnswer: 0,
|
||||||
|
sourceTag: 'spelling:word_quiz',
|
||||||
|
pipelineKey: 'spelling',
|
||||||
|
pipelineStep: 'spelling_word_quiz',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up a custom mock Random where nextInt(100) returns 80 (which is >= 50, so spelling).
|
||||||
|
final QuestionDispatcher spellingFavoredDispatcher = testDb!.questionDispatcher(
|
||||||
|
random: _SequentialRandom(<int>[80]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up a custom mock Random where the first nextInt(100) returns 80 (prefers spelling ordering)
|
||||||
|
// and the second nextInt(100) returns 10 (< 50, selecting market_history).
|
||||||
|
final QuestionDispatcher marketFavoredDispatcher = testDb!.questionDispatcher(
|
||||||
|
random: _SequentialRandom(<int>[80, 10]),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, dynamic> fakeAnswered = <String, dynamic>{
|
||||||
|
'sourceTag': 'spelling:word_quiz',
|
||||||
|
'pipelineKey': 'spelling',
|
||||||
|
'pipelineStep': 'spelling_word_quiz',
|
||||||
|
};
|
||||||
|
|
||||||
|
// When roll is 10 (spelling favored): next should be spelling question.
|
||||||
|
final Map<String, dynamic>? nextSpelling = await spellingFavoredDispatcher.nextQuestionAfterAnswer(
|
||||||
|
firebaseUid: uid,
|
||||||
|
answeredQuestion: fakeAnswered,
|
||||||
|
);
|
||||||
|
expect(nextSpelling, isNotNull);
|
||||||
|
expect(nextSpelling!['sourceId'], 'spelling');
|
||||||
|
|
||||||
|
// When roll is 80 (market/mix favored): next should be market question (since it's at the head).
|
||||||
|
final Map<String, dynamic>? nextMarket = await marketFavoredDispatcher.nextQuestionAfterAnswer(
|
||||||
|
firebaseUid: uid,
|
||||||
|
answeredQuestion: fakeAnswered,
|
||||||
|
);
|
||||||
|
expect(nextMarket, isNotNull);
|
||||||
|
expect(nextMarket!['sourceId'], 'market_history');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextQuestionAfterDefer respects mix roll and enqueues new spelling if spelling is rolled',
|
||||||
|
() async {
|
||||||
|
if (testDb == null) {
|
||||||
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const String uid = 'spelling-50-defer-uid';
|
||||||
|
await testDb!.seedUser(uid);
|
||||||
|
await testDb!.questionSourcePreferencesDb.save(
|
||||||
|
uid,
|
||||||
|
<Map<String, dynamic>>[
|
||||||
|
<String, dynamic>{'id': 'market_history', 'enabled': true, 'ratio': 50},
|
||||||
|
<String, dynamic>{'id': 'spelling', 'enabled': true, 'ratio': 50},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a market question and a spelling question.
|
||||||
|
await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Market question',
|
||||||
|
correctAnswer: 5,
|
||||||
|
sourceTag: 'market_history:prospective',
|
||||||
|
pipelineKey: 'trading',
|
||||||
|
);
|
||||||
|
final Map<String, dynamic> spellingQ = await testDb!.questionsDb.createQuestion(
|
||||||
|
assignedUserId: uid,
|
||||||
|
questionText: 'Spelling question',
|
||||||
|
correctAnswer: 0,
|
||||||
|
sourceTag: 'spelling:word_quiz',
|
||||||
|
pipelineKey: 'spelling',
|
||||||
|
pipelineStep: 'spelling_word_quiz',
|
||||||
|
);
|
||||||
|
|
||||||
|
// spellingFavoredDispatcher rolls 80 (>= 50), which selects spelling.
|
||||||
|
final QuestionDispatcher spellingFavoredDispatcher = testDb!.questionDispatcher(
|
||||||
|
random: _SequentialRandom(<int>[80]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// marketFavoredDispatcher rolls 10 (< 50), which selects market_history.
|
||||||
|
final QuestionDispatcher marketFavoredDispatcher = testDb!.questionDispatcher(
|
||||||
|
random: _SequentialRandom(<int>[10]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. When spelling is rolled: next should be a NEW spelling question.
|
||||||
|
final Map<String, dynamic>? nextSpelling = await spellingFavoredDispatcher.nextQuestionAfterDefer(
|
||||||
|
firebaseUid: uid,
|
||||||
|
deferredQuestionId: spellingQ['id'] as String,
|
||||||
|
);
|
||||||
|
expect(nextSpelling, isNotNull);
|
||||||
|
expect(nextSpelling!['sourceId'], 'spelling');
|
||||||
|
expect(nextSpelling['id'], isNot(spellingQ['id'])); // Verifies it is a new question
|
||||||
|
|
||||||
|
// 2. When market is rolled: next should be the market question.
|
||||||
|
final Map<String, dynamic>? nextMarket = await marketFavoredDispatcher.nextQuestionAfterDefer(
|
||||||
|
firebaseUid: uid,
|
||||||
|
deferredQuestionId: spellingQ['id'] as String,
|
||||||
|
);
|
||||||
|
expect(nextMarket, isNotNull);
|
||||||
|
expect(nextMarket!['sourceId'], 'market_history');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SequentialRandom implements Random {
|
||||||
|
_SequentialRandom(this._values);
|
||||||
|
final List<int> _values;
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int nextInt(int max) {
|
||||||
|
final int val = _values[_index % _values.length];
|
||||||
|
_index++;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool nextBool() => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double nextDouble() => 0.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,4 +188,88 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing);
|
expect(find.byKey(const Key('question-source-config-top-bar')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('proportional slider balancing and manual text inputs', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: buildAppTheme(),
|
||||||
|
home: const HomeScreen(
|
||||||
|
user: AppUser(uid: 'u1', displayName: 'Test User'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open configuration dialog.
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-button')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify initial state: both sources enabled and ratio is 50.
|
||||||
|
final Finder marketInput = find.byKey(const Key('question-source-input-market_history'));
|
||||||
|
final Finder spellingInput = find.byKey(const Key('question-source-input-spelling'));
|
||||||
|
|
||||||
|
expect(marketInput, findsOneWidget);
|
||||||
|
expect(spellingInput, findsOneWidget);
|
||||||
|
|
||||||
|
expect(tester.widget<TextField>(marketInput).controller?.text, '50');
|
||||||
|
expect(tester.widget<TextField>(spellingInput).controller?.text, '50');
|
||||||
|
|
||||||
|
// 1. Enter 80 in market_history input.
|
||||||
|
await tester.enterText(marketInput, '80');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify spelling proportionally reduced to 20.
|
||||||
|
expect(tester.widget<TextField>(marketInput).controller?.text, '80');
|
||||||
|
expect(tester.widget<TextField>(spellingInput).controller?.text, '20');
|
||||||
|
|
||||||
|
// 2. Put market_history to 100.
|
||||||
|
await tester.enterText(marketInput, '100');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify spelling is auto-disabled.
|
||||||
|
final Switch spellingSwitch = tester.widget<Switch>(
|
||||||
|
find.byKey(const Key('question-source-enabled-spelling')),
|
||||||
|
);
|
||||||
|
expect(spellingSwitch.value, isFalse);
|
||||||
|
expect(find.byKey(const Key('question-source-input-spelling')), findsNothing);
|
||||||
|
|
||||||
|
// 3. Reduce market_history to 70.
|
||||||
|
await tester.enterText(marketInput, '70');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify spelling auto-enabled and gets 30%.
|
||||||
|
final Switch spellingSwitchAfter = tester.widget<Switch>(
|
||||||
|
find.byKey(const Key('question-source-enabled-spelling')),
|
||||||
|
);
|
||||||
|
expect(spellingSwitchAfter.value, isTrue);
|
||||||
|
expect(find.byKey(const Key('question-source-input-spelling')), findsOneWidget);
|
||||||
|
expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '30');
|
||||||
|
|
||||||
|
// 4. Manually disable spelling.
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-enabled-spelling')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify spelling is disabled and market_history automatically becomes 100%.
|
||||||
|
expect(tester.widget<TextField>(marketInput).controller?.text, '100');
|
||||||
|
|
||||||
|
// 5. Manually enable spelling.
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-enabled-spelling')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify spelling gets enabled and shares the ratio evenly (50/50).
|
||||||
|
expect(tester.widget<TextField>(marketInput).controller?.text, '50');
|
||||||
|
expect(tester.widget<TextField>(find.byKey(const Key('question-source-input-spelling'))).controller?.text, '50');
|
||||||
|
|
||||||
|
// 6. Enter a value > 100 (e.g. 150) in market_history input.
|
||||||
|
await tester.enterText(marketInput, '150');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify it clamps to 100.
|
||||||
|
expect(tester.widget<TextField>(marketInput).controller?.text, '100');
|
||||||
|
|
||||||
|
// Close dialog.
|
||||||
|
await tester.tap(find.byKey(const Key('question-source-settings-close')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user