From 68f6fa12bbded84b0c08a5c991a3266a28d42ce3 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Tue, 28 Oct 2025 03:42:16 +0100 Subject: [PATCH] add new vocabulary mode --- lib/src/api/wk_client.dart | 22 ++ lib/src/models/kanji_item.dart | 58 ++++ lib/src/screens/home_screen.dart | 4 +- lib/src/screens/start_screen.dart | 42 ++- lib/src/screens/vocab_screen.dart | 302 +++++++++++++++++++++ lib/src/services/deck_repository.dart | 136 +++++++++- lib/src/services/distractor_generator.dart | 48 ++++ 7 files changed, 593 insertions(+), 19 deletions(-) create mode 100644 lib/src/screens/vocab_screen.dart diff --git a/lib/src/api/wk_client.dart b/lib/src/api/wk_client.dart index fd93a03..39adcd3 100644 --- a/lib/src/api/wk_client.dart +++ b/lib/src/api/wk_client.dart @@ -30,6 +30,28 @@ class WkClient { return out; } + Future>> fetchAllSubjects({List? types}) async { + final out = >[]; + String url = '$base/subjects'; + if (types != null && types.isNotEmpty) { + url += '?types=${types.join(',')}'; + } + + while (url.isNotEmpty) { + final resp = await http.get(Uri.parse(url), headers: headers); + if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}'); + final j = json.decode(resp.body) as Map; + out.addAll((j['data'] as List).cast>()); + final pages = j['pages'] as Map?; + if (pages != null && pages['next_url'] != null) { + url = pages['next_url'] as String; + } else { + break; + } + } + return out; + } + Future>> fetchSubjectsByIds(List ids) async { final out = >[]; const batch = 100; diff --git a/lib/src/models/kanji_item.dart b/lib/src/models/kanji_item.dart index 2794cdf..9b2cbce 100644 --- a/lib/src/models/kanji_item.dart +++ b/lib/src/models/kanji_item.dart @@ -79,3 +79,61 @@ String _katakanaToHiragana(String input) { } return buf.toString(); } + +enum VocabQuizMode { vocabToEnglish, englishToVocab } + +class VocabSrsItem { + final int vocabId; + final VocabQuizMode quizMode; + int srsStage; + DateTime lastAsked; + + VocabSrsItem({ + required this.vocabId, + required this.quizMode, + this.srsStage = 0, + DateTime? lastAsked, + }) : lastAsked = lastAsked ?? DateTime.now(); +} + +class VocabularyItem { + final int id; + final String characters; + final List meanings; + final List readings; + final Map srsItems = {}; + + VocabularyItem({ + required this.id, + required this.characters, + required this.meanings, + required this.readings, + }); + + factory VocabularyItem.fromSubject(Map subj) { + final int id = subj['id'] as int; + final data = subj['data'] as Map; + final String characters = (data['characters'] ?? '') as String; + final List meanings = []; + final List readings = []; + + if (data['meanings'] != null) { + for (final m in data['meanings'] as List) { + meanings.add((m['meaning'] as String).toLowerCase()); + } + } + + if (data['readings'] != null) { + for (final r in data['readings'] as List) { + readings.add(r['reading'] as String); + } + } + + return VocabularyItem( + id: id, + characters: characters, + meanings: meanings, + readings: readings, + ); + } +} diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 09356c0..0a90d4e 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -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 correctReadings; final String hint; @@ -104,7 +102,7 @@ class _HomeScreenState extends State { 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); } diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart index 37ab6de..3eda768 100644 --- a/lib/src/screens/start_screen.dart +++ b/lib/src/screens/start_screen.dart @@ -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 { 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), ), ), ], diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart new file mode 100644 index 0000000..1f53898 --- /dev/null +++ b/lib/src/screens/vocab_screen.dart @@ -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 createState() => _VocabScreenState(); +} + +class _VocabScreenState extends State { + List _deck = []; + bool _loading = false; + String _status = 'Loading deck...'; + final DistractorGenerator _dg = DistractorGenerator(); + final Random _random = Random(); + + VocabQuizMode _mode = VocabQuizMode.vocabToEnglish; + VocabularyItem? _current; + List _options = []; + List _correctAnswers = []; + int _score = 0; + int _asked = 0; + + @override + void initState() { + super.initState(); + _loadDeck(); + } + + 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) { + 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(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), + ); + } +} diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 52973ae..ab06dba 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -23,7 +23,7 @@ class DeckRepository { _db = await openDatabase( path, - version: 4, + version: 5, onCreate: (db, version) async { await db.execute( '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); @@ -31,6 +31,10 @@ class DeckRepository { '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'''); await db.execute( '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))'''); + await db.execute( + '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)'''); + await db.execute( + '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))'''); }, onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { @@ -46,6 +50,12 @@ class DeckRepository { // We are not migrating the old srs data, as it was not mode-specific. // Old columns will be dropped. } + if (oldVersion < 5) { + await db.execute( + '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)'''); + await db.execute( + '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))'''); + } }, ); @@ -202,4 +212,128 @@ class DeckRepository { await saveKanji(items); return items; } + + Future> getVocabSrsItems(int vocabId) async { + final db = await _openDb(); + final rows = await db.query('srs_vocab_items', where: 'vocabId = ?', whereArgs: [vocabId]); + return rows.map((r) { + return VocabSrsItem( + vocabId: r['vocabId'] as int, + quizMode: VocabQuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String), + srsStage: r['srsStage'] as int, + lastAsked: DateTime.parse(r['lastAsked'] as String), + ); + }).toList(); + } + + Future updateVocabSrsItem(VocabSrsItem item) async { + final db = await _openDb(); + await db.update( + 'srs_vocab_items', + { + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, + where: 'vocabId = ? AND quizMode = ?', + whereArgs: [item.vocabId, item.quizMode.toString()], + ); + } + + Future insertVocabSrsItem(VocabSrsItem item) async { + final db = await _openDb(); + await db.insert( + 'srs_vocab_items', + { + 'vocabId': item.vocabId, + 'quizMode': item.quizMode.toString(), + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future saveVocabulary(List items) async { + final db = await _openDb(); + final batch = db.batch(); + for (final it in items) { + batch.insert( + 'vocabulary', + { + 'id': it.id, + 'characters': it.characters, + 'meanings': it.meanings.join('|'), + 'readings': it.readings.join('|'), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + Future> loadVocabulary() async { + final db = await _openDb(); + final rows = await db.query('vocabulary'); + final vocabItems = rows + .map((r) => VocabularyItem( + id: r['id'] as int, + characters: r['characters'] as String, + meanings: (r['meanings'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + readings: (r['readings'] as String) + .split('|') + .where((s) => s.isNotEmpty) + .toList(), + )) + .toList(); + + for (final item in vocabItems) { + final srsItems = await getVocabSrsItems(item.id); + for (final srsItem in srsItems) { + final key = srsItem.quizMode.toString(); + item.srsItems[key] = srsItem; + } + } + + return vocabItems; + } + + Future> fetchAndCacheVocabularyFromWk([String? apiKey]) async { + final key = apiKey ?? _apiKey; + if (key == null) throw Exception('API key not set'); + + final client = WkClient(key); + final assignments = + await client.fetchAllAssignments(subjectTypes: ['vocabulary']); + + final unlocked = {}; + for (final a in assignments) { + final data = a['data'] as Map; + final sidRaw = data['subject_id']; + if (sidRaw == null) continue; + final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString()); + if (sid == null) continue; + final started = data['started_at']; + final srs = data['srs_stage']; + final isUnlocked = (started != null) || (srs != null && (srs as int) > 0); + if (isUnlocked) unlocked.add(sid); + } + + if (unlocked.isEmpty) return []; + + final subjects = await client.fetchSubjectsByIds(unlocked.toList()); + final items = subjects + .where((s) => + s['object'] == 'vocabulary' || + (s['data'] != null && + (s['data'] as Map)['object_type'] == 'vocabulary')) + .map((s) => VocabularyItem.fromSubject(s)) + .where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty) + .toList(); + + await saveVocabulary(items); + return items; + } } diff --git a/lib/src/services/distractor_generator.dart b/lib/src/services/distractor_generator.dart index d681daa..5c9f03a 100644 --- a/lib/src/services/distractor_generator.dart +++ b/lib/src/services/distractor_generator.dart @@ -70,6 +70,54 @@ class DistractorGenerator { } return out; } + + List generateVocabMeanings(VocabularyItem correct, List pool, int needed) { + final correctMeaning = correct.meanings.first; + final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); + final candidates = []; + for (final k in pool) { + if (k.id == correct.id) continue; + for (final m in k.meanings) { + final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); + if (mTokens.intersection(tokens).isNotEmpty) { + candidates.add(m); + } + } + } + if (candidates.length < needed) { + for (final k in pool) { + if (k.id == correct.id) continue; + for (final m in k.meanings) { + if (!candidates.contains(m)) candidates.add(m); + } + } + } + candidates.shuffle(_rnd); + final out = []; + for (final c in candidates) { + if (out.length >= needed) break; + if (c.toLowerCase() == correctMeaning.toLowerCase()) continue; + out.add(_toTitleCase(c)); + } + while (out.length < needed) { + out.add('(no more)'); + } + return out; + } + + List generateVocab(VocabularyItem correct, List pool, int needed) { + final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList(); + others.shuffle(_rnd); + final out = []; + for (final o in others) { + if (out.length >= needed) break; + out.add(o); + } + while (out.length < needed) { + out.add('—'); + } + return out; + } } String _toTitleCase(String s) => s.split(' ').map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1))).join(' ');