549 lines
16 KiB
Dart
549 lines
16 KiB
Dart
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<String> options = [];
|
|
List<String> correctAnswers = [];
|
|
int score = 0;
|
|
int asked = 0;
|
|
Key key = UniqueKey();
|
|
String? selectedOption;
|
|
bool showResult = false;
|
|
Set<int> wrongItems = {};
|
|
}
|
|
|
|
class VocabScreen extends StatefulWidget {
|
|
const VocabScreen({super.key});
|
|
|
|
@override
|
|
State<VocabScreen> createState() => _VocabScreenState();
|
|
}
|
|
|
|
class _VocabScreenState extends State<VocabScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
List<VocabularyItem> _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 = <int, List<VocabularyItem>>{};
|
|
final _sessionDeckSizes = <int, int>{};
|
|
|
|
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<void> _loadSettings() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
setState(() {
|
|
_playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
|
|
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
|
|
_playNarrator = prefs.getBool('playNarrator') ?? true;
|
|
});
|
|
}
|
|
|
|
Future<void> _loadDeck() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_status = 'Loading deck...';
|
|
});
|
|
|
|
try {
|
|
final repo = Provider.of<VocabDeckRepository>(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 = <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);
|
|
}
|
|
} 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<void> _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<VocabDeckRepository>(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<void>();
|
|
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<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(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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|