From 16da0f04ac83c9c553ae0b6dcb53ae5c47296d6e Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Sun, 2 Nov 2025 17:07:23 +0100 Subject: [PATCH] possible to exclude levels and change how questions are served --- lib/src/models/srs_item.dart | 2 + lib/src/screens/browse_screen.dart | 187 +++++++++++++++---- lib/src/screens/custom_quiz_screen.dart | 20 ++- lib/src/screens/home_screen.dart | 190 ++++++++++++-------- lib/src/screens/vocab_screen.dart | 171 ++++++++++++------ lib/src/services/database_constants.dart | 1 + lib/src/services/database_helper.dart | 12 +- lib/src/services/deck_repository.dart | 44 ++++- lib/src/services/vocab_deck_repository.dart | 21 +++ lib/src/widgets/options_grid.dart | 4 +- 10 files changed, 477 insertions(+), 175 deletions(-) diff --git a/lib/src/models/srs_item.dart b/lib/src/models/srs_item.dart index 913234d..c3fd7a1 100644 --- a/lib/src/models/srs_item.dart +++ b/lib/src/models/srs_item.dart @@ -6,6 +6,7 @@ class SrsItem { final String? readingType; int srsStage; DateTime lastAsked; + bool disabled; SrsItem({ required this.subjectId, @@ -13,5 +14,6 @@ class SrsItem { this.readingType, this.srsStage = 0, DateTime? lastAsked, + this.disabled = false, }) : lastAsked = lastAsked ?? DateTime.now(); } diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index bfcf510..b5875ab 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -123,9 +123,14 @@ class _BrowseScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(color: Theme.of(context).colorScheme.primary), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(height: 16), - Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + Text( + _status, + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), ], ), ); @@ -151,6 +156,7 @@ class _BrowseScreenState extends State List sortedLevels, PageController pageController, Widget Function(List) buildPageContent, + dynamic repository, ) { if (sortedLevels.isEmpty) { return Center( @@ -167,18 +173,34 @@ class _BrowseScreenState extends State itemBuilder: (context, index) { final level = sortedLevels[index]; final levelItems = groupedItems[level]!; + final bool isDisabled = levelItems.every( + (item) => (item as dynamic).srsItems.values.every( + (srs) => (srs as SrsItem).disabled, + ), + ); return Column( children: [ Padding( padding: const EdgeInsets.all(16.0), - child: Text( - 'Level $level', - style: TextStyle( - fontSize: 24, - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Level $level', + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + Checkbox( + value: !isDisabled, + onChanged: (value) { + _toggleLevelExclusion(level, repository); + }, + ), + ], ), ), Expanded(child: buildPageContent(levelItems)), @@ -199,31 +221,55 @@ class _BrowseScreenState extends State return Container( padding: const EdgeInsets.symmetric(vertical: 8.0), color: Theme.of(context).colorScheme.surfaceContainer, - height: 60, child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(levels.length, (index) { final level = levels[index]; final isSelected = index == currentPage; + final items = isKanji ? _kanjiByLevel[level] : _vocabByLevel[level]; + final bool isDisabled = + items?.every( + (item) => (item as dynamic).srsItems.values.every( + (srs) => (srs as SrsItem).disabled, + ), + ) ?? + false; + return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ElevatedButton( onPressed: () { controller.animateToPage( index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, ); }, + style: ElevatedButton.styleFrom( backgroundColor: isSelected ? Theme.of(context).colorScheme.primary + : isDisabled + ? Theme.of(context).colorScheme.surfaceVariant : Theme.of(context).colorScheme.surfaceContainerHighest, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + + foregroundColor: isSelected + ? Theme.of(context).colorScheme.onPrimary + : isDisabled + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurface, + + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(12), ), + child: Text(level.toString()), ), ), @@ -295,7 +341,10 @@ class _BrowseScreenState extends State Expanded( child: Text( item.characters, - style: TextStyle(fontSize: 24, color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurface, + ), ), ), const SizedBox(width: 16), @@ -306,7 +355,9 @@ class _BrowseScreenState extends State children: [ Text( item.meanings.join(', '), - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), @@ -355,7 +406,10 @@ class _BrowseScreenState extends State children: [ Text( item.characters, - style: TextStyle(fontSize: 32, color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + fontSize: 32, + color: Theme.of(context).colorScheme.onSurface, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), @@ -375,7 +429,9 @@ class _BrowseScreenState extends State height: 10, child: LinearProgressIndicator( value: level / 9.0, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation( _getColorForSrsLevel(level), ), @@ -444,29 +500,39 @@ class _BrowseScreenState extends State children: [ Text( 'Level: ${kanji.level}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), const SizedBox(height: 16), if (kanji.meanings.isNotEmpty) Text( 'Meanings: ${kanji.meanings.join(', ')}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), const SizedBox(height: 16), if (kanji.onyomi.isNotEmpty) Text( 'On\'yomi: ${kanji.onyomi.join(', ')}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), if (kanji.kunyomi.isNotEmpty) Text( 'Kun\'yomi: ${kanji.kunyomi.join(', ')}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) Text( 'No readings available.', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), const SizedBox(height: 16), Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), @@ -481,7 +547,9 @@ class _BrowseScreenState extends State ...srsScores.entries.map( (entry) => Text( ' ${entry.key}: ${entry.value}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ), ], @@ -568,6 +636,42 @@ class _BrowseScreenState extends State _vocabSortedLevels = _vocabByLevel.keys.toList()..sort(); } + Future _toggleLevelExclusion(int level, dynamic repository) async { + final List itemsToUpdate = []; + final bool currentlyDisabled; + + if (repository is DeckRepository) { + final items = _kanjiByLevel[level] ?? []; + currentlyDisabled = items.every( + (item) => item.srsItems.values.every((srs) => srs.disabled), + ); + for (final item in items) { + for (final srsItem in item.srsItems.values) { + itemsToUpdate.add(srsItem); + } + } + } else if (repository is VocabDeckRepository) { + final items = _vocabByLevel[level] ?? []; + currentlyDisabled = items.every( + (item) => item.srsItems.values.every((srs) => srs.disabled), + ); + for (final item in items) { + for (final srsItem in item.srsItems.values) { + itemsToUpdate.add(srsItem); + } + } + } else { + return; + } + + for (final item in itemsToUpdate) { + item.disabled = !currentlyDisabled; + } + + await repository.updateSrsItems(itemsToUpdate); + _loadDecks(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -586,6 +690,7 @@ class _BrowseScreenState extends State _kanjiSortedLevels, _kanjiPageController, (items) => _buildGridView(items.cast()), + Provider.of(context, listen: false), ), ), _buildWaniKaniTab( @@ -594,6 +699,7 @@ class _BrowseScreenState extends State _vocabSortedLevels, _vocabPageController, (items) => _buildListView(items.cast()), + Provider.of(context, listen: false), ), ), _buildCustomSrsTab(), @@ -673,8 +779,7 @@ class _BrowseScreenState extends State setState(() { if (_selectedItems.length == _customDeck.length) { _selectedItems.clear(); - } - else { + } else { _selectedItems = List.from(_customDeck); } }); @@ -799,12 +904,17 @@ class _BrowseScreenState extends State child: Card( shape: RoundedRectangleBorder( side: isSelected - ? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0) + ? BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ) : BorderSide.none, borderRadius: BorderRadius.circular(12.0), ), color: isSelected - ? Theme.of(context).colorScheme.primary.withAlpha((255 * 0.5).round()) + ? Theme.of( + context, + ).colorScheme.primary.withAlpha((255 * 0.5).round()) : Theme.of(context).colorScheme.surfaceContainer, child: Stack( children: [ @@ -854,7 +964,11 @@ class _BrowseScreenState extends State Positioned( top: 4, right: 4, - child: Icon(Icons.timer, color: Theme.of(context).colorScheme.tertiary, size: 16), + child: Icon( + Icons.timer, + color: Theme.of(context).colorScheme.tertiary, + size: 16, + ), ), ], ), @@ -912,11 +1026,15 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { children: [ Text( japaneseWord, - style: TextStyle(color: widget.theme.colorScheme.onSurface), + style: TextStyle( + color: widget.theme.colorScheme.onSurface, + ), ), Text( englishDefinition, - style: TextStyle(color: widget.theme.colorScheme.onSurfaceVariant), + style: TextStyle( + color: widget.theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 8), ], @@ -1012,7 +1130,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { const SizedBox(height: 16), Text( 'SRS Scores:', - style: TextStyle(color: widget.theme.colorScheme.onSurface, fontWeight: FontWeight.bold), + style: TextStyle( + color: widget.theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), ), ...srsScores.entries.map( (entry) => Text( @@ -1025,7 +1146,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { const SizedBox(height: 16), Text( 'Example Sentences:', - style: TextStyle(color: widget.theme.colorScheme.onSurface, fontWeight: FontWeight.bold), + style: TextStyle( + color: widget.theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), ), ..._exampleSentences, ], @@ -1059,4 +1183,3 @@ void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) { }, ); } - diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart index 306dbad..bae38a7 100644 --- a/lib/src/screens/custom_quiz_screen.dart +++ b/lib/src/screens/custom_quiz_screen.dart @@ -50,6 +50,10 @@ class CustomQuizScreenState extends State bool _playCorrectSound = true; bool _playNarrator = true; + String? _selectedOption; + String? _correctAnswer; + bool _showResult = false; + @override void initState() { super.initState(); @@ -148,6 +152,9 @@ class CustomQuizScreenState extends State ); } } + while (_options.length < 4) { + _options.add('---'); + } _options.shuffle(); } @@ -160,6 +167,12 @@ class CustomQuizScreenState extends State : currentItem.meaning; final isCorrect = answer == correctAnswer; + setState(() { + _selectedOption = answer; + _correctAnswer = correctAnswer; + _showResult = true; + }); + int currentSrsLevel = 0; // Initialize with a default value if (currentItem.useInterval) { switch (widget.quizMode) { @@ -239,7 +252,9 @@ class CustomQuizScreenState extends State content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( - color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error, + color: isCorrect + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.error, fontWeight: FontWeight.bold, ), ), @@ -312,6 +327,9 @@ class CustomQuizScreenState extends State _currentIndex++; _answered = false; _correct = null; + _selectedOption = null; + _correctAnswer = null; + _showResult = false; if (_currentIndex < _shuffledDeck.length) { _generateOptions(); } diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 17c2fe9..aa0b472 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -29,6 +29,7 @@ class _QuizState { Key key = UniqueKey(); String? selectedOption; bool showResult = false; + Set wrongItems = {}; } class HomeScreen extends StatefulWidget { @@ -52,6 +53,8 @@ class _HomeScreenState extends State final _audioPlayer = AudioPlayer(); final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; + final _sessionDecks = >{}; + final _sessionDeckSizes = {}; _QuizState get _currentQuizState => _quizStates[_tabController.index]; bool _playIncorrectSound = true; @@ -118,6 +121,44 @@ class _HomeScreenState extends State _apiKeyMissing = false; }); + final disabledLevels = {}; + final itemsByLevel = >{}; + 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); + final filteredDeck = _deck.where((item) { + if (disabledLevels.contains(item.level)) { + return false; + } + + if (mode == QuizMode.reading) { + final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi']; + final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi']; + final hasOnyomi = item.onyomi.isNotEmpty && + (onyomiSrs == null || !onyomiSrs.disabled); + final hasKunyomi = item.kunyomi.isNotEmpty && + (kunyomiSrs == null || !kunyomiSrs.disabled); + return hasOnyomi || hasKunyomi; + } + final srsItem = item.srsItems[mode.toString()]; + return srsItem == null || !srsItem.disabled; + }).toList(); + + filteredDeck.shuffle(_random); + _sessionDecks[i] = filteredDeck; + _sessionDeckSizes[i] = filteredDeck.length; + } + for (var i = 0; i < _tabController.length; i++) { _nextQuestion(i); } @@ -164,61 +205,20 @@ class _HomeScreenState extends State } 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); + if (sessionDeck == null || sessionDeck.isEmpty) { + setState(() { + quizState.current = null; + _status = 'Quiz complete!'; + }); + return; + } - _deck.sort((a, b) { - int getSrsStage(KanjiItem item) { - if (mode == QuizMode.reading) { - final onyomiStage = - item.srsItems['${QuizMode.reading}onyomi']?.srsStage; - final kunyomiStage = - item.srsItems['${QuizMode.reading}kunyomi']?.srsStage; - - if (onyomiStage != null && kunyomiStage != null) { - return min(onyomiStage, kunyomiStage); - } - return onyomiStage ?? kunyomiStage ?? 0; - } - return item.srsItems[mode.toString()]?.srsStage ?? 0; - } - - DateTime getLastAsked(KanjiItem item) { - if (mode == QuizMode.reading) { - final onyomiLastAsked = - item.srsItems['${QuizMode.reading}onyomi']?.lastAsked; - final kunyomiLastAsked = - item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked; - - if (onyomiLastAsked != null && kunyomiLastAsked != null) { - return onyomiLastAsked.isBefore(kunyomiLastAsked) - ? onyomiLastAsked - : kunyomiLastAsked; - } - return onyomiLastAsked ?? - kunyomiLastAsked ?? - DateTime.fromMillisecondsSinceEpoch(0); - } - return item.srsItems[mode.toString()]?.lastAsked ?? - DateTime.fromMillisecondsSinceEpoch(0); - } - - final aStage = getSrsStage(a); - final bStage = getSrsStage(b); - - if (aStage != bStage) { - return aStage.compareTo(bStage); - } - - final aLastAsked = getLastAsked(a); - final bLastAsked = getLastAsked(b); - - return aLastAsked.compareTo(bLastAsked); - }); - - quizState.current = _deck.first; + quizState.current = sessionDeck.removeAt(0); quizState.key = UniqueKey(); quizState.correctAnswers = []; @@ -284,12 +284,13 @@ class _HomeScreenState extends State final repo = Provider.of(context, listen: false); final current = quizState.current!; + final tabIndex = _tabController.index; + final sessionDeck = _sessionDecks[tabIndex]!; String readingType = ''; if (mode == QuizMode.reading) { - readingType = quizState.readingHint.contains("on'yomi") - ? 'onyomi' - : 'kunyomi'; + readingType = + quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; } final srsKey = mode.toString() + readingType; @@ -301,8 +302,6 @@ class _HomeScreenState extends State readingType: readingType, ); - quizState.asked += 1; - quizState.selectedOption = option; quizState.showResult = true; @@ -310,13 +309,19 @@ class _HomeScreenState extends State setState(() {}); if (isCorrect) { - quizState.score += 1; + quizState.asked += 1; + if (!quizState.wrongItems.contains(current.id)) { + quizState.score += 1; + } srsItemForUpdate.srsStage += 1; if (_playCorrectSound) { _audioPlayer.play(AssetSource('sfx/correct.wav')); } } else { srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1); + sessionDeck.add(current); + sessionDeck.shuffle(_random); + quizState.wrongItems.add(current.id); if (_playIncorrectSound) { _audioPlayer.play(AssetSource('sfx/incorrect.wav')); } @@ -336,8 +341,8 @@ class _HomeScreenState extends State final correctDisplay = (mode == QuizMode.kanjiToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : (mode == QuizMode.reading - ? quizState.correctAnswers.join(', ') - : quizState.correctAnswers.first); + ? quizState.correctAnswers.join(', ') + : quizState.correctAnswers.first); final snack = SnackBar( content: Text( @@ -398,6 +403,23 @@ class _HomeScreenState extends State ); } + if (_loading) { + return Scaffold( + appBar: AppBar( + title: const Text('Kanji Quiz'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Kanji→English'), + Tab(text: 'English→Kanji'), + Tab(text: 'Reading'), + ], + ), + ), + body: const Center(child: CircularProgressIndicator()), + ); + } + return Scaffold( appBar: AppBar( title: const Text('Kanji Quiz'), @@ -422,6 +444,16 @@ class _HomeScreenState extends State 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), + ), + ); + } + String prompt = ''; String subtitle = ''; @@ -447,20 +479,27 @@ class _HomeScreenState extends State 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( + Theme.of(context).colorScheme.primary), + ), ], ), const SizedBox(height: 18), @@ -490,10 +529,9 @@ class _HomeScreenState extends State 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( diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart index c120f22..abbbc59 100644 --- a/lib/src/screens/vocab_screen.dart +++ b/lib/src/screens/vocab_screen.dart @@ -21,8 +21,7 @@ class _QuizState { Key key = UniqueKey(); String? selectedOption; bool showResult = false; - List shuffledDeck = []; - int currentIndex = 0; + Set wrongItems = {}; } class VocabScreen extends StatefulWidget { @@ -40,10 +39,13 @@ class _VocabScreenState extends State 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 = >{}; + final _sessionDeckSizes = {}; bool _playIncorrectSound = true; bool _playCorrectSound = true; @@ -114,6 +116,40 @@ class _VocabScreenState extends State _apiKeyMissing = false; }); + final disabledLevels = {}; + final itemsByLevel = >{}; + 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 } 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 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 setState(() { _isAnswering = false; }); + + if (mode == QuizMode.audioToEnglish) { + _playCurrentAudio(playOnLoad: true); + } } Future _playCurrentAudio({bool playOnLoad = false}) async { @@ -247,6 +260,8 @@ class _VocabScreenState extends State final repo = Provider.of(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 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 _isAnswering = true; }); - _nextQuestion(); + Future.delayed(const Duration(milliseconds: 900), () { + if (mounted) { + _nextQuestion(); + } + });; } @override @@ -360,6 +384,23 @@ class _VocabScreenState extends State ); } + 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 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 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( + Theme.of(context).colorScheme.primary), + ), ], ), const SizedBox(height: 18), @@ -462,10 +520,9 @@ class _VocabScreenState extends State 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( diff --git a/lib/src/services/database_constants.dart b/lib/src/services/database_constants.dart index 85f4132..68baa40 100644 --- a/lib/src/services/database_constants.dart +++ b/lib/src/services/database_constants.dart @@ -24,4 +24,5 @@ class DbConstants { static const String readingTypeColumn = 'readingType'; static const String srsStageColumn = 'srsStage'; static const String lastAskedColumn = 'lastAsked'; + static const String disabledColumn = 'disabled'; } diff --git a/lib/src/services/database_helper.dart b/lib/src/services/database_helper.dart index 1a0939b..96d5eec 100644 --- a/lib/src/services/database_helper.dart +++ b/lib/src/services/database_helper.dart @@ -32,7 +32,7 @@ class DatabaseHelper { return openDatabase( path, - version: 7, + version: 8, onCreate: (db, version) async { await db.execute( '''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''', @@ -41,15 +41,21 @@ class DatabaseHelper { '''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', ); await db.execute( - '''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', + '''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', ); await db.execute( '''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''', ); await db.execute( - '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', + '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', ); }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 8) { + await db.execute('ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0'); + await db.execute('ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0'); + } + }, ); } } diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 433dd1a..c75c073 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -103,6 +103,7 @@ class DeckRepository { readingType: r[DbConstants.readingTypeColumn] as String?, srsStage: r[DbConstants.srsStageColumn] as int, lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String), + disabled: (r[DbConstants.disabledColumn] as int? ?? 0) == 1, ); srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem); } @@ -118,17 +119,53 @@ class DeckRepository { return kanjiItems; } + Future updateSrsItems(List items) async { + final db = await DatabaseHelper().db; + final batch = db.batch(); + for (final item in items) { + var where = '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?'; + final whereArgs = [item.subjectId, item.quizMode.toString()]; + if (item.readingType != null) { + where += ' AND ${DbConstants.readingTypeColumn} = ?'; + whereArgs.add(item.readingType!); + } else { + where += ' AND ${DbConstants.readingTypeColumn} IS NULL'; + } + + batch.update( + DbConstants.srsItemsTable, + { + DbConstants.srsStageColumn: item.srsStage, + DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), + DbConstants.disabledColumn: item.disabled ? 1 : 0, + }, + where: where, + whereArgs: whereArgs, + ); + } + await batch.commit(noResult: true); + } + Future updateSrsItem(SrsItem item) async { final db = await DatabaseHelper().db; + var where = '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?'; + final whereArgs = [item.subjectId, item.quizMode.toString()]; + if (item.readingType != null) { + where += ' AND ${DbConstants.readingTypeColumn} = ?'; + whereArgs.add(item.readingType!); + } else { + where += ' AND ${DbConstants.readingTypeColumn} IS NULL'; + } + await db.update( DbConstants.srsItemsTable, { DbConstants.srsStageColumn: item.srsStage, DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), + DbConstants.disabledColumn: item.disabled ? 1 : 0, }, - where: - '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', - whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType], + where: where, + whereArgs: whereArgs, ); } @@ -140,6 +177,7 @@ class DeckRepository { DbConstants.readingTypeColumn: item.readingType, DbConstants.srsStageColumn: item.srsStage, DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), + DbConstants.disabledColumn: item.disabled ? 1 : 0, }, conflictAlgorithm: ConflictAlgorithm.replace); } diff --git a/lib/src/services/vocab_deck_repository.dart b/lib/src/services/vocab_deck_repository.dart index 7766532..22fae25 100644 --- a/lib/src/services/vocab_deck_repository.dart +++ b/lib/src/services/vocab_deck_repository.dart @@ -68,10 +68,29 @@ class VocabDeckRepository { ), srsStage: r['srsStage'] as int, lastAsked: DateTime.parse(r['lastAsked'] as String), + disabled: (r['disabled'] as int? ?? 0) == 1, ); }).toList(); } + Future updateSrsItems(List items) async { + final db = await DatabaseHelper().db; + final batch = db.batch(); + for (final item in items) { + batch.update( + 'srs_vocab_items', + { + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + 'disabled': item.disabled ? 1 : 0, + }, + where: 'vocabId = ? AND quizMode = ?', + whereArgs: [item.subjectId, item.quizMode.toString()], + ); + } + await batch.commit(noResult: true); + } + Future updateVocabSrsItem(SrsItem item) async { final db = await DatabaseHelper().db; await db.update( @@ -79,6 +98,7 @@ class VocabDeckRepository { { 'srsStage': item.srsStage, 'lastAsked': item.lastAsked.toIso8601String(), + 'disabled': item.disabled ? 1 : 0, }, where: 'vocabId = ? AND quizMode = ?', whereArgs: [item.subjectId, item.quizMode.toString()], @@ -92,6 +112,7 @@ class VocabDeckRepository { 'quizMode': item.quizMode.toString(), 'srsStage': item.srsStage, 'lastAsked': item.lastAsked.toIso8601String(), + 'disabled': item.disabled ? 1 : 0, }, conflictAlgorithm: ConflictAlgorithm.replace); } diff --git a/lib/src/widgets/options_grid.dart b/lib/src/widgets/options_grid.dart index ee61642..d12fdd6 100644 --- a/lib/src/widgets/options_grid.dart +++ b/lib/src/widgets/options_grid.dart @@ -41,15 +41,13 @@ class OptionsGrid extends StatelessWidget { if (showResult) { if (correctAnswers != null && correctAnswers!.contains(o)) { currentButtonColor = theme.colorScheme.tertiary; - } else if (o == selectedOption) { - currentButtonColor = theme.colorScheme.error; } } return SizedBox( width: 160, child: ElevatedButton( - onPressed: isDisabled ? null : () => onSelected(o), + onPressed: isDisabled || o == '---' ? null : () => onSelected(o), style: ElevatedButton.styleFrom( backgroundColor: currentButtonColor, foregroundColor: currentTextColor,