possible to exclude levels and change how questions are served

This commit is contained in:
Rene Kievits
2025-11-02 17:07:23 +01:00
parent e9f115a32a
commit 16da0f04ac
10 changed files with 477 additions and 175 deletions

View File

@@ -21,8 +21,7 @@ class _QuizState {
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
List<VocabularyItem> shuffledDeck = [];
int currentIndex = 0;
Set<int> wrongItems = {};
}
class VocabScreen extends StatefulWidget {
@@ -40,10 +39,13 @@ class _VocabScreenState extends State<VocabScreen>
bool _isAnswering = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
final _audioPlayer = AudioPlayer();
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
_QuizState get _currentQuizState => _quizStates[_tabController.index];
final _sessionDecks = <int, List<VocabularyItem>>{};
final _sessionDeckSizes = <int, int>{};
bool _playIncorrectSound = true;
bool _playCorrectSound = true;
@@ -114,6 +116,40 @@ class _VocabScreenState extends State<VocabScreen>
_apiKeyMissing = false;
});
final disabledLevels = <int>{};
final itemsByLevel = <int, List<VocabularyItem>>{};
for (final item in _deck) {
(itemsByLevel[item.level] ??= []).add(item);
}
itemsByLevel.forEach((level, items) {
final allSrsItems = items.expand((item) => item.srsItems.values).toList();
if (allSrsItems.isNotEmpty && allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
for (var i = 0; i < _tabController.length; i++) {
final mode = _modeForIndex(i);
var filteredDeck = _deck.where((item) {
if (disabledLevels.contains(item.level)) {
return false;
}
final srsItem = item.srsItems[mode.toString()];
return srsItem == null || !srsItem.disabled;
}).toList();
if (mode == QuizMode.audioToEnglish) {
filteredDeck = filteredDeck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
}
filteredDeck.shuffle(_random);
_sessionDecks[i] = filteredDeck;
_sessionDeckSizes[i] = filteredDeck.length;
}
for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i);
}
@@ -147,47 +183,20 @@ class _VocabScreenState extends State<VocabScreen>
}
void _nextQuestion([int? index]) {
if (_deck.isEmpty) return;
final tabIndex = index ?? _tabController.index;
final quizState = _quizStates[tabIndex];
final sessionDeck = _sessionDecks[tabIndex];
final mode = _modeForIndex(tabIndex);
final quizState = _quizStates[index ?? _tabController.index];
final mode = _modeForIndex(index ?? _tabController.index);
List<VocabularyItem> currentDeckForMode = _deck;
if (mode == QuizMode.audioToEnglish) {
currentDeckForMode = _deck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
if (currentDeckForMode.isEmpty) {
setState(() {
_status = 'No vocabulary with audio found.';
quizState.current = null;
});
return;
}
}
if (quizState.shuffledDeck.isEmpty ||
quizState.currentIndex >= quizState.shuffledDeck.length) {
quizState.shuffledDeck = currentDeckForMode.toList();
quizState.shuffledDeck.sort((a, b) {
final aSrsItem =
a.srsItems[mode.toString()] ??
SrsItem(subjectId: a.id, quizMode: mode);
final bSrsItem =
b.srsItems[mode.toString()] ??
SrsItem(subjectId: b.id, quizMode: mode);
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) {
return stageComparison;
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
if (sessionDeck == null || sessionDeck.isEmpty) {
setState(() {
quizState.current = null;
_status = 'Quiz complete!';
});
quizState.currentIndex = 0;
return;
}
quizState.current = quizState.shuffledDeck[quizState.currentIndex];
quizState.currentIndex++;
quizState.current = sessionDeck.removeAt(0);
quizState.key = UniqueKey();
quizState.correctAnswers = [];
quizState.options = [];
@@ -218,6 +227,10 @@ class _VocabScreenState extends State<VocabScreen>
setState(() {
_isAnswering = false;
});
if (mode == QuizMode.audioToEnglish) {
_playCurrentAudio(playOnLoad: true);
}
}
Future<void> _playCurrentAudio({bool playOnLoad = false}) async {
@@ -247,6 +260,8 @@ class _VocabScreenState extends State<VocabScreen>
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
final current = quizState.current!;
final tabIndex = _tabController.index;
final sessionDeck = _sessionDecks[tabIndex]!;
final srsKey = mode.toString();
@@ -255,16 +270,21 @@ class _VocabScreenState extends State<VocabScreen>
final srsItem =
srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
quizState.asked += 1;
quizState.selectedOption = option;
quizState.showResult = true;
setState(() {});
if (isCorrect) {
quizState.score += 1;
quizState.asked += 1;
if (!quizState.wrongItems.contains(current.id)) {
quizState.score += 1;
}
srsItem.srsStage += 1;
} else {
srsItem.srsStage = max(0, srsItem.srsStage - 1);
sessionDeck.add(current);
sessionDeck.shuffle(_random);
quizState.wrongItems.add(current.id);
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
@@ -325,7 +345,11 @@ class _VocabScreenState extends State<VocabScreen>
_isAnswering = true;
});
_nextQuestion();
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});;
}
@override
@@ -360,6 +384,23 @@ class _VocabScreenState extends State<VocabScreen>
);
}
if (_loading) {
return Scaffold(
appBar: AppBar(
title: const Text('Vocabulary Quiz'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Vocab→English'),
Tab(text: 'English→Vocab'),
Tab(text: 'Listening'),
],
),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Vocabulary Quiz'),
@@ -383,6 +424,16 @@ class _VocabScreenState extends State<VocabScreen>
final quizState = _quizStates[index];
final mode = _modeForIndex(index);
if (quizState.current == null) {
return Center(
child: Text(
_status,
style: TextStyle(
fontSize: 24, color: Theme.of(context).colorScheme.onSurface),
),
);
}
Widget promptWidget;
if (quizState.current == null) {
@@ -424,20 +475,27 @@ class _VocabScreenState extends State<VocabScreen>
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
_status,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
Text(
'${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (_loading)
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
],
),
const SizedBox(height: 18),
@@ -462,10 +520,9 @@ class _VocabScreenState extends State<VocabScreen>
OptionsGrid(
options: quizState.options,
onSelected: _isAnswering ? (option) {} : _answer,
isDisabled: false,
selectedOption: null,
correctAnswers: [],
showResult: false,
showResult: quizState.showResult,
selectedOption: quizState.selectedOption,
correctAnswers: quizState.correctAnswers,
),
const SizedBox(height: 8),
Text(