import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/kanji_item.dart'; import '../models/srs_item.dart'; import '../services/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 _ReadingInfo { final List correctReadings; final String hint; _ReadingInfo(this.correctReadings, this.hint); } class _QuizState { KanjiItem? current; List options = []; List correctAnswers = []; String readingHint = ''; int score = 0; int asked = 0; Key key = UniqueKey(); String? selectedOption; bool showResult = false; } class HomeScreen extends StatefulWidget { const HomeScreen({super.key, this.distractorGenerator}); final DistractorGenerator? distractorGenerator; @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; List _deck = []; bool _loading = false; bool _isAnswering = false; String _status = 'Loading deck...'; late final DistractorGenerator _dg; final Random _random = Random(); final _audioPlayer = AudioPlayer(); final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; _QuizState get _currentQuizState => _quizStates[_tabController.index]; bool _playCorrectSound = true; bool _apiKeyMissing = false; @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { setState(() {}); }); _dg = widget.distractorGenerator ?? DistractorGenerator(); _loadSettings(); _loadDeck(); } @override void dispose() { _tabController.dispose(); super.dispose(); } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); setState(() { _playCorrectSound = prefs.getBool('playCorrectSound') ?? 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.loadKanji(); if (items.isEmpty) { setState(() { _status = 'Fetching deck...'; }); items = await repo.fetchAndCacheFromWk(apiKey); } setState(() { _deck = items; _status = 'Loaded ${items.length} kanji'; _loading = false; _apiKeyMissing = false; }); 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(' '); } _ReadingInfo _pickReading(KanjiItem item) { final choices = []; if (item.onyomi.isNotEmpty) choices.add('onyomi'); if (item.kunyomi.isNotEmpty) choices.add('kunyomi'); if (choices.isEmpty) return _ReadingInfo([], ''); final pickedType = choices[_random.nextInt(choices.length)]; final readingsList = pickedType == 'onyomi' ? item.onyomi : item.kunyomi; final hint = 'Select the ${pickedType == 'onyomi' ? "on'yomi" : "kunyomi"}'; return _ReadingInfo(readingsList, hint); } QuizMode _modeForIndex(int index) { switch (index) { case 0: return QuizMode.kanjiToEnglish; case 1: return QuizMode.englishToKanji; case 2: return QuizMode.reading; default: return QuizMode.kanjiToEnglish; } } void _nextQuestion([int? index]) { if (_deck.isEmpty) return; final quizState = _quizStates[index ?? _tabController.index]; final mode = _modeForIndex(index ?? _tabController.index); _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.key = UniqueKey(); quizState.correctAnswers = []; quizState.options = []; quizState.readingHint = ''; quizState.selectedOption = null; quizState.showResult = false; switch (mode) { case QuizMode.kanjiToEnglish: quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.options = [ quizState.correctAnswers.first, ..._dg.generateMeanings(quizState.current!, _deck, 3), ].map(_toTitleCase).toList()..shuffle(); break; case QuizMode.englishToKanji: quizState.correctAnswers = [quizState.current!.characters]; quizState.options = [ quizState.correctAnswers.first, ..._dg.generateKanji(quizState.current!, _deck, 3), ]..shuffle(); break; case QuizMode.reading: final info = _pickReading(quizState.current!); quizState.correctAnswers = info.correctReadings; quizState.readingHint = info.hint; final readingsSource = quizState.readingHint.contains("on'yomi") ? _deck.expand((k) => k.onyomi) : _deck.expand((k) => k.kunyomi); final distractors = readingsSource .where((r) => !quizState.correctAnswers.contains(r)) .toSet() .toList() ..shuffle(); quizState.options = ([ quizState.correctAnswers[_random.nextInt( quizState.correctAnswers.length, )], ...distractors.take(3), ])..shuffle(); break; default: break; } setState(() { _isAnswering = false; }); } 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!; String readingType = ''; if (mode == QuizMode.reading) { readingType = quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; } final srsKey = mode.toString() + readingType; var srsItem = current.srsItems[srsKey]; final isNew = srsItem == null; final srsItemForUpdate = srsItem ??= SrsItem( subjectId: current.id, quizMode: mode, readingType: readingType, ); quizState.asked += 1; quizState.selectedOption = option; quizState.showResult = true; setState(() {}); if (isCorrect) { quizState.score += 1; srsItemForUpdate.srsStage += 1; if (_playCorrectSound) { _audioPlayer.play(AssetSource('sfx/confirm.mp3')); } } else { srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1); } srsItemForUpdate.lastAsked = DateTime.now(); current.srsItems[srsKey] = srsItemForUpdate; final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); if (isNew) { await repo.insertSrsItem(srsItemForUpdate); } else { await repo.updateSrsItem(srsItemForUpdate); } final correctDisplay = (mode == QuizMode.kanjiToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : (mode == QuizMode.reading ? quizState.correctAnswers.join(', ') : quizState.correctAnswers.first); final snack = SnackBar( content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( color: isCorrect ? theme.colorScheme.primary : theme.colorScheme.error, fontWeight: FontWeight.bold, ), ), backgroundColor: theme.colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 900), ); if (mounted) { scaffoldMessenger.showSnackBar(snack); } 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('Kanji 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()), ); _loadDeck(); }, child: const Text('Go to Settings'), ), ], ), ), ); } 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'), ], ), ), backgroundColor: Theme.of(context).colorScheme.surface, body: TabBarView( controller: _tabController, children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)], ), ); } Widget _buildQuizPage(int index) { final quizState = _quizStates[index]; final mode = _modeForIndex(index); String prompt = ''; String subtitle = ''; if (quizState.current != null) { switch (mode) { case QuizMode.kanjiToEnglish: prompt = quizState.current!.characters; break; case QuizMode.englishToKanji: prompt = _toTitleCase(quizState.current!.meanings.first); break; case QuizMode.reading: prompt = quizState.current!.characters; subtitle = quizState.readingHint; break; default: break; } } return Padding( key: quizState.key, padding: const EdgeInsets.all(16.0), child: Column( children: [ Row( children: [ Expanded( child: Text( _status, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), ), if (_loading) CircularProgressIndicator( color: 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( characters: prompt, subtitle: subtitle, backgroundColor: Theme.of(context).colorScheme.surface, textColor: Theme.of(context).colorScheme.onSurface, ), ), ), ), const SizedBox(height: 12), SafeArea( top: false, child: Column( children: [ OptionsGrid( options: quizState.options, onSelected: _isAnswering ? (option) {} : _answer, isDisabled: false, selectedOption: null, correctAnswers: [], showResult: false, ), const SizedBox(height: 8), Text( 'Score: ${quizState.score} / ${quizState.asked}', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), ], ), ), ], ), ); } }