import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/vocabulary_item.dart'; import '../models/srs_item.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/distractor_generator.dart'; import '../widgets/kanji_card.dart'; import '../widgets/options_grid.dart'; import 'package:audioplayers/audioplayers.dart'; import 'settings_screen.dart'; class _QuizState { VocabularyItem? current; List options = []; List correctAnswers = []; int score = 0; int asked = 0; Key key = UniqueKey(); String? selectedOption; bool showResult = false; Set wrongItems = {}; } class VocabScreen extends StatefulWidget { const VocabScreen({super.key}); @override State createState() => _VocabScreenState(); } class _VocabScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; List _deck = []; bool _loading = false; 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; bool _playNarrator = true; bool _apiKeyMissing = false; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { if (_tabController.index == 2 && !_tabController.indexIsChanging) { _playCurrentAudio(); } setState(() {}); }); _loadSettings(); _loadDeck(); } @override void dispose() { _tabController.dispose(); super.dispose(); } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); setState(() { _playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playNarrator = prefs.getBool('playNarrator') ?? true; }); } Future _loadDeck() async { setState(() { _loading = true; _status = 'Loading deck...'; }); try { final repo = Provider.of(context, listen: false); await repo.loadApiKey(); final apiKey = repo.apiKey; if (apiKey == null || apiKey.isEmpty) { setState(() { _apiKeyMissing = true; _loading = false; }); return; } var items = await repo.loadVocabulary(); if (items.isEmpty || items.every((item) => item.pronunciationAudios.isEmpty)) { setState(() { _status = 'Fetching deck...'; }); items = await repo.fetchAndCacheVocabularyFromWk(apiKey); } setState(() { _deck = items; _status = 'Loaded ${items.length} vocabulary'; _loading = false; _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); } } catch (e) { setState(() { _status = 'Error: $e'; _loading = false; }); } } String _toTitleCase(String s) { if (s.isEmpty) return s; return s .split(' ') .map((w) => w.isEmpty ? w : w[0].toUpperCase() + w.substring(1)) .join(' '); } QuizMode _modeForIndex(int index) { switch (index) { case 0: return QuizMode.vocabToEnglish; case 1: return QuizMode.englishToVocab; case 2: return QuizMode.audioToEnglish; default: return QuizMode.vocabToEnglish; } } void _nextQuestion([int? index]) { final tabIndex = index ?? _tabController.index; final quizState = _quizStates[tabIndex]; final sessionDeck = _sessionDecks[tabIndex]; final mode = _modeForIndex(tabIndex); if (sessionDeck == null || sessionDeck.isEmpty) { setState(() { quizState.current = null; _status = 'Quiz complete!'; }); return; } quizState.current = sessionDeck.removeAt(0); quizState.key = UniqueKey(); quizState.correctAnswers = []; quizState.options = []; quizState.selectedOption = null; quizState.showResult = false; switch (mode) { case QuizMode.vocabToEnglish: case QuizMode.audioToEnglish: quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.options = [ quizState.correctAnswers.first, ..._dg.generateVocabMeanings(quizState.current!, _deck, 3), ].map(_toTitleCase).toList()..shuffle(); break; case QuizMode.englishToVocab: quizState.correctAnswers = [quizState.current!.characters]; quizState.options = [ quizState.correctAnswers.first, ..._dg.generateVocab(quizState.current!, _deck, 3), ]..shuffle(); break; default: break; } setState(() { _isAnswering = false; }); if (mode == QuizMode.audioToEnglish) { _playCurrentAudio(playOnLoad: true); } } Future _playCurrentAudio({bool playOnLoad = false}) async { final current = _currentQuizState.current; if (current == null || current.pronunciationAudios.isEmpty) return; if (playOnLoad && !_playNarrator) return; final maleAudios = current.pronunciationAudios.where( (a) => a.gender == 'male', ); final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url); try { await _audioPlayer.play(UrlSource(audioUrl)); } finally {} } void _answer(String option) async { final quizState = _currentQuizState; final mode = _modeForIndex(_tabController.index); final isCorrect = quizState.correctAnswers .map((a) => a.toLowerCase().trim()) .contains(option.toLowerCase().trim()); final repo = Provider.of(context, listen: false); final current = quizState.current!; final tabIndex = _tabController.index; final sessionDeck = _sessionDecks[tabIndex]!; final srsKey = mode.toString(); var srsItemNullable = current.srsItems[srsKey]; final isNew = srsItemNullable == null; final srsItem = srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode); quizState.selectedOption = option; quizState.showResult = true; setState(() {}); if (isCorrect) { 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')); } } srsItem.lastAsked = DateTime.now(); current.srsItems[srsKey] = srsItem; if (isNew) { await repo.insertVocabSrsItem(srsItem); } else { await repo.updateVocabSrsItem(srsItem); } final correctDisplay = (mode == QuizMode.vocabToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : quizState.correctAnswers.first; if (!mounted) return; final snack = SnackBar( content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error, fontWeight: FontWeight.bold, ), ), backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 900), ); ScaffoldMessenger.of(context).showSnackBar(snack); if (isCorrect) { if (_playCorrectSound && !_playNarrator) { await _audioPlayer.play(AssetSource('sfx/correct.wav')); } else if (_playNarrator) { final maleAudios = current.pronunciationAudios.where( (a) => a.gender == 'male', ); if (maleAudios.isNotEmpty) { final completer = Completer(); final sub = _audioPlayer.onPlayerComplete.listen((event) { if (!completer.isCompleted) completer.complete(); }); try { await _audioPlayer.play(UrlSource(maleAudios.first.url)); await completer.future.timeout(const Duration(seconds: 5)); } finally { await sub.cancel(); } } } } setState(() { _isAnswering = true; }); Future.delayed(const Duration(milliseconds: 900), () { if (mounted) { _nextQuestion(); } }); } @override Widget build(BuildContext context) { if (_apiKeyMissing) { return Scaffold( appBar: AppBar(title: const Text('Vocabulary Quiz')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'WaniKani API key is not set.', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), const SizedBox(height: 16), ElevatedButton( onPressed: () async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ); if (!mounted) return; _loadDeck(); }, child: const Text('Go to Settings'), ), ], ), ), ); } 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'), bottom: TabBar( controller: _tabController, tabs: const [ Tab(text: 'Vocab→English'), Tab(text: 'English→Vocab'), Tab(text: 'Listening'), ], ), ), body: TabBarView( controller: _tabController, children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)], ), ); } Widget _buildQuizPage(int index) { 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) { promptWidget = const SizedBox.shrink(); } else if (mode == QuizMode.audioToEnglish) { promptWidget = IconButton( icon: Icon( Icons.volume_up, color: Theme.of(context).colorScheme.onSurface, size: 64, ), onPressed: _playCurrentAudio, ); } else { String promptText = ''; switch (mode) { case QuizMode.vocabToEnglish: promptText = quizState.current!.characters; break; case QuizMode.englishToVocab: promptText = _toTitleCase(quizState.current!.meanings.first); break; case QuizMode.audioToEnglish: break; default: break; } promptWidget = Text( promptText, style: TextStyle( fontSize: 48, color: Theme.of(context).colorScheme.onSurface, ), ); } return Padding( key: quizState.key, padding: const EdgeInsets.all(16.0), child: Column( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), ), 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), Expanded( flex: 3, child: Center( child: ConstrainedBox( constraints: const BoxConstraints( minWidth: 0, maxWidth: 500, minHeight: 150, ), child: KanjiCard(characterWidget: promptWidget, subtitle: ''), ), ), ), const SizedBox(height: 12), SafeArea( top: false, child: Column( children: [ OptionsGrid( options: quizState.options, onSelected: _isAnswering ? (option) {} : _answer, showResult: quizState.showResult, selectedOption: quizState.selectedOption, correctAnswers: quizState.correctAnswers, ), const SizedBox(height: 8), Text( 'Score: ${quizState.score} / ${quizState.asked}', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), ], ), ), ], ), ); } }