add new vocabulary mode

This commit is contained in:
Rene Kievits
2025-10-28 03:42:16 +01:00
parent 61081ac8a4
commit 68f6fa12bb
7 changed files with 593 additions and 19 deletions

View File

@@ -8,8 +8,6 @@ import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart';
import 'settings_screen.dart';
import '../models/kanji_item.dart';
class _ReadingInfo {
final List<String> correctReadings;
final String hint;
@@ -104,7 +102,7 @@ class _HomeScreenState extends State<HomeScreen> {
final pickedType = choices[_random.nextInt(choices.length)];
final readingsList = pickedType == 'onyomi' ? item.onyomi : item.kunyomi;
final hint = 'Select the ${pickedType == 'onyomi' ? "on\'yomi" : "kunyomi"}';
final hint = 'Select the ${pickedType == 'onyomi' ? "on'yomi" : "kunyomi"}';
return _ReadingInfo(readingsList, hint);
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/deck_repository.dart';
import 'settings_screen.dart';
import 'home_screen.dart';
import 'vocab_screen.dart';
class StartScreen extends StatefulWidget {
const StartScreen({super.key});
@@ -76,27 +76,39 @@ class _StartScreenState extends State<StartScreen> {
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
if (_hasApiKey) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => HomeScreen()),
);
} else {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => HomeScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: Text(
_hasApiKey ? 'Start Quiz' : 'Go to Settings',
style: const TextStyle(fontSize: 18),
child: const Text(
'Kanji Quiz',
style: TextStyle(fontSize: 18),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const VocabScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text(
'Vocabulary Quiz',
style: TextStyle(fontSize: 18),
),
),
],

View File

@@ -0,0 +1,302 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/kanji_item.dart';
import '../services/deck_repository.dart';
import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart';
import 'settings_screen.dart';
class VocabScreen extends StatefulWidget {
const VocabScreen({super.key});
State<VocabScreen> createState() => _VocabScreenState();
}
class _VocabScreenState extends State<VocabScreen> {
List<VocabularyItem> _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
int _score = 0;
int _asked = 0;
@override
void initState() {
super.initState();
_loadDeck();
}
Future<void> _loadDeck() async {
setState(() {
_loading = true;
_status = 'Loading deck...';
});
try {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
return;
}
var items = await repo.loadVocabulary();
if (items.isEmpty) {
setState(() {
_status = 'Fetching deck...';
});
items = await repo.fetchAndCacheVocabularyFromWk(apiKey);
}
setState(() {
_deck = items;
_status = 'Loaded ${items.length} vocabulary';
_loading = false;
});
_nextQuestion();
} 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(' ');
}
void _nextQuestion() {
if (_deck.isEmpty) 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);
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) {
return stageComparison;
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
});
_current = _deck.first;
_correctAnswers = [];
_options = [];
switch (_mode) {
case VocabQuizMode.vocabToEnglish:
_correctAnswers = [_current!.meanings.first];
_options = [
_correctAnswers.first,
..._dg.generateVocabMeanings(_current!, _deck, 3)
].map(_toTitleCase).toList()
..shuffle();
break;
case VocabQuizMode.englishToVocab:
_correctAnswers = [_current!.characters];
_options = [
_correctAnswers.first,
..._dg.generateVocab(_current!, _deck, 3)
]..shuffle();
break;
}
setState(() {});
}
void _answer(String option) async {
final isCorrect = _correctAnswers
.map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false);
final current = _current!;
final srsKey = _mode.toString();
var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null;
srsItem ??= 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;
});
if (isNew) {
await repo.insertVocabSrsItem(srsItem);
} else {
await repo.updateVocabSrsItem(srsItem);
}
final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish)
? _toTitleCase(_correctAnswers.first)
: _correctAnswers.first;
final snack = SnackBar(
content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color(0xFF222222),
duration: const Duration(milliseconds: 900),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack);
}
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
}
@override
Widget build(BuildContext context) {
String prompt = '';
switch (_mode) {
case VocabQuizMode.vocabToEnglish:
prompt = _current?.characters ?? '';
break;
case VocabQuizMode.englishToVocab:
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : '';
break;
}
return Scaffold(
backgroundColor: const Color(0xFF121212),
appBar: AppBar(
title: const Text('WaniKani Vocabulary SRS'),
backgroundColor: const Color(0xFF1F1F1F),
foregroundColor: Colors.white,
elevation: 2,
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
)
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
_status,
style: const TextStyle(color: Colors.white),
),
),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.center,
children: [
_buildChoiceChip('Vocab→English', VocabQuizMode.vocabToEnglish),
_buildChoiceChip('English→Vocab', VocabQuizMode.englishToVocab),
],
),
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: '',
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),
),
],
),
),
],
),
),
);
}
ChoiceChip _buildChoiceChip(String label, VocabQuizMode mode) {
final selected = _mode == mode;
return ChoiceChip(
label: Text(
label,
style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
),
selected: selected,
onSelected: (v) {
setState(() => _mode = mode);
_nextQuestion();
},
selectedColor: Colors.blueAccent,
backgroundColor: const Color(0xFF1E1E1E),
);
}
}