change a bunch of stuff, seperate tracking for progress, updated custom srs layout
This commit is contained in:
@@ -4,13 +4,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/kanji_item.dart';
|
||||
import '../services/deck_repository.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;
|
||||
List<VocabularyItem> shuffledDeck = [];
|
||||
int currentIndex = 0;
|
||||
}
|
||||
|
||||
class VocabScreen extends StatefulWidget {
|
||||
const VocabScreen({super.key});
|
||||
|
||||
@@ -22,15 +35,14 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
late TabController _tabController;
|
||||
List<VocabularyItem> _deck = [];
|
||||
bool _loading = false;
|
||||
bool _isAnswering = false;
|
||||
String _status = 'Loading deck...';
|
||||
final DistractorGenerator _dg = DistractorGenerator();
|
||||
final _audioPlayer = AudioPlayer();
|
||||
|
||||
VocabularyItem? _current;
|
||||
List<String> _options = [];
|
||||
List<String> _correctAnswers = [];
|
||||
int _score = 0;
|
||||
int _asked = 0;
|
||||
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
||||
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
||||
|
||||
bool _playAudio = true;
|
||||
bool _playCorrectSound = true;
|
||||
bool _apiKeyMissing = false;
|
||||
@@ -40,8 +52,10 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
_nextQuestion();
|
||||
}
|
||||
setState(() {});
|
||||
_nextQuestion();
|
||||
});
|
||||
_loadSettings();
|
||||
_loadDeck();
|
||||
@@ -68,7 +82,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
});
|
||||
|
||||
try {
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
||||
await repo.loadApiKey();
|
||||
final apiKey = repo.apiKey;
|
||||
|
||||
@@ -96,7 +110,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
_apiKeyMissing = false;
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
for (var i = 0; i < _tabController.length; i++) {
|
||||
_nextQuestion(i);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
@@ -113,8 +129,8 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
VocabQuizMode get _mode {
|
||||
switch (_tabController.index) {
|
||||
VocabQuizMode _modeForIndex(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return VocabQuizMode.vocabToEnglish;
|
||||
case 1:
|
||||
@@ -126,70 +142,84 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
}
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
void _nextQuestion([int? index]) {
|
||||
if (_deck.isEmpty) return;
|
||||
|
||||
List<VocabularyItem> deck = _deck;
|
||||
if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
|
||||
if (deck.isEmpty) {
|
||||
final quizState = _quizStates[index ?? _tabController.index];
|
||||
final mode = _modeForIndex(index ?? _tabController.index);
|
||||
|
||||
List<VocabularyItem> currentDeckForMode = _deck;
|
||||
if (mode == VocabQuizMode.audioToEnglish) {
|
||||
currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
|
||||
if (currentDeckForMode.isEmpty) {
|
||||
setState(() {
|
||||
_status = 'No vocabulary with audio found.';
|
||||
_current = null;
|
||||
quizState.current = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
deck.sort((a, b) {
|
||||
final aSrsItem = a.srsItems[_mode.toString()] ??
|
||||
VocabSrsItem(vocabId: a.id, quizMode: _mode);
|
||||
final bSrsItem = b.srsItems[_mode.toString()] ??
|
||||
VocabSrsItem(vocabId: b.id, quizMode: _mode);
|
||||
// If it's a new session or we've gone through all shuffled items, re-shuffle
|
||||
if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) {
|
||||
quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy
|
||||
// Apply sorting based on SRS stages here, but only once per shuffle
|
||||
quizState.shuffledDeck.sort((a, b) {
|
||||
final aSrsItem = a.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: a.id, quizMode: mode);
|
||||
final bSrsItem = b.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: b.id, quizMode: mode);
|
||||
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
|
||||
if (stageComparison != 0) {
|
||||
return stageComparison;
|
||||
}
|
||||
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
|
||||
});
|
||||
quizState.currentIndex = 0;
|
||||
}
|
||||
|
||||
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
|
||||
if (stageComparison != 0) {
|
||||
return stageComparison;
|
||||
}
|
||||
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
|
||||
});
|
||||
quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck
|
||||
quizState.currentIndex++; // Advance index
|
||||
|
||||
_current = deck.first;
|
||||
if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
quizState.key = UniqueKey();
|
||||
if (mode == VocabQuizMode.audioToEnglish) {
|
||||
_playCurrentAudio();
|
||||
}
|
||||
|
||||
_correctAnswers = [];
|
||||
_options = [];
|
||||
quizState.correctAnswers = [];
|
||||
quizState.options = [];
|
||||
quizState.selectedOption = null;
|
||||
quizState.showResult = false;
|
||||
|
||||
switch (_mode) {
|
||||
switch (mode) {
|
||||
case VocabQuizMode.vocabToEnglish:
|
||||
case VocabQuizMode.audioToEnglish:
|
||||
_correctAnswers = [_current!.meanings.first];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateVocabMeanings(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.meanings.first];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateVocabMeanings(quizState.current!, _deck, 3)
|
||||
].map(_toTitleCase).toList()
|
||||
..shuffle();
|
||||
break;
|
||||
|
||||
case VocabQuizMode.englishToVocab:
|
||||
_correctAnswers = [_current!.characters];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateVocab(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.characters];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateVocab(quizState.current!, _deck, 3)
|
||||
]..shuffle();
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_isAnswering = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _playCurrentAudio() async {
|
||||
if (_current == null || _current!.pronunciationAudios.isEmpty) return;
|
||||
final current = _currentQuizState.current;
|
||||
if (current == null || current.pronunciationAudios.isEmpty) return;
|
||||
|
||||
final maleAudios = _current!.pronunciationAudios.where((a) => a.gender == 'male');
|
||||
final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : _current!.pronunciationAudios.first.url);
|
||||
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));
|
||||
@@ -199,31 +229,35 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
}
|
||||
|
||||
void _answer(String option) async {
|
||||
final isCorrect = _correctAnswers
|
||||
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<DeckRepository>(context, listen: false);
|
||||
final current = _current!;
|
||||
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
||||
final current = quizState.current!;
|
||||
|
||||
final srsKey = _mode.toString();
|
||||
final srsKey = mode.toString();
|
||||
|
||||
var srsItemNullable = current.srsItems[srsKey];
|
||||
final isNew = srsItemNullable == null;
|
||||
final srsItem =
|
||||
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode);
|
||||
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode);
|
||||
|
||||
setState(() {
|
||||
_asked += 1;
|
||||
if (isCorrect) {
|
||||
_score += 1;
|
||||
srsItem.srsStage += 1;
|
||||
} else {
|
||||
srsItem.srsStage = max(0, srsItem.srsStage - 1);
|
||||
}
|
||||
srsItem.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItem;
|
||||
});
|
||||
quizState.asked += 1;
|
||||
quizState.selectedOption = option;
|
||||
quizState.showResult = true;
|
||||
setState(() {}); // Trigger UI rebuild to show selected/correct colors
|
||||
|
||||
if (isCorrect) {
|
||||
quizState.score += 1;
|
||||
srsItem.srsStage += 1;
|
||||
} else {
|
||||
srsItem.srsStage = max(0, srsItem.srsStage - 1);
|
||||
}
|
||||
srsItem.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItem;
|
||||
|
||||
if (isNew) {
|
||||
await repo.insertVocabSrsItem(srsItem);
|
||||
@@ -231,9 +265,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
await repo.updateVocabSrsItem(srsItem);
|
||||
}
|
||||
|
||||
final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish)
|
||||
? _toTitleCase(_correctAnswers.first)
|
||||
: _correctAnswers.first;
|
||||
final correctDisplay = (mode == VocabQuizMode.vocabToEnglish)
|
||||
? _toTitleCase(quizState.correctAnswers.first)
|
||||
: quizState.correctAnswers.first;
|
||||
|
||||
final snack = SnackBar(
|
||||
content: Text(
|
||||
@@ -254,7 +288,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
if (_playCorrectSound) {
|
||||
await _audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
||||
}
|
||||
if (_playAudio && _mode != VocabQuizMode.audioToEnglish) {
|
||||
if (_playAudio && mode != VocabQuizMode.audioToEnglish) {
|
||||
final maleAudios =
|
||||
current.pronunciationAudios.where((a) => a.gender == 'male');
|
||||
if (maleAudios.isNotEmpty) {
|
||||
@@ -274,9 +308,13 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
// No fixed delay for incorrect answers
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isAnswering = true; // Disable input after showing result
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
}
|
||||
|
||||
@@ -289,7 +327,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
|
||||
const Text('WaniKani API key is not set.'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
@@ -306,31 +344,6 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
);
|
||||
}
|
||||
|
||||
Widget promptWidget;
|
||||
|
||||
if (_current == null) {
|
||||
promptWidget = const SizedBox.shrink();
|
||||
} else if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
promptWidget = IconButton(
|
||||
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64),
|
||||
onPressed: _playCurrentAudio,
|
||||
);
|
||||
} else {
|
||||
String promptText = '';
|
||||
switch (_mode) {
|
||||
case VocabQuizMode.vocabToEnglish:
|
||||
promptText = _current?.characters ?? '';
|
||||
break;
|
||||
case VocabQuizMode.englishToVocab:
|
||||
promptText = _current != null ? _toTitleCase(_current!.meanings.first) : '';
|
||||
break;
|
||||
case VocabQuizMode.audioToEnglish:
|
||||
// Handled above
|
||||
break;
|
||||
}
|
||||
promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Vocabulary Quiz'),
|
||||
@@ -343,63 +356,101 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
||||
],
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildQuizPage(0),
|
||||
_buildQuizPage(1),
|
||||
_buildQuizPage(2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuizPage(int index) {
|
||||
final quizState = _quizStates[index];
|
||||
final mode = _modeForIndex(index);
|
||||
|
||||
Widget promptWidget;
|
||||
|
||||
if (quizState.current == null) {
|
||||
promptWidget = const SizedBox.shrink();
|
||||
} else if (mode == VocabQuizMode.audioToEnglish) {
|
||||
promptWidget = IconButton(
|
||||
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64),
|
||||
onPressed: _playCurrentAudio,
|
||||
);
|
||||
} else {
|
||||
String promptText = '';
|
||||
switch (mode) {
|
||||
case VocabQuizMode.vocabToEnglish:
|
||||
promptText = quizState.current!.characters;
|
||||
break;
|
||||
case VocabQuizMode.englishToVocab:
|
||||
promptText = _toTitleCase(quizState.current!.meanings.first);
|
||||
break;
|
||||
case VocabQuizMode.audioToEnglish:
|
||||
// Handled above
|
||||
break;
|
||||
}
|
||||
promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white));
|
||||
}
|
||||
|
||||
return Padding(
|
||||
key: quizState.key,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
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,
|
||||
isDisabled: false,
|
||||
selectedOption: null,
|
||||
correctAnswers: [],
|
||||
showResult: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: ${quizState.score} / ${quizState.asked}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
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: '',
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: _options,
|
||||
onSelected: _answer,
|
||||
buttonColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: $_score / $_asked',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user