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 3b57038..9b2cbce 100644 --- a/lib/src/models/kanji_item.dart +++ b/lib/src/models/kanji_item.dart @@ -1,9 +1,28 @@ +enum QuizMode { kanjiToEnglish, englishToKanji, reading } + +class SrsItem { + final int kanjiId; + final QuizMode quizMode; + final String? readingType; // 'onyomi' or 'kunyomi' + int srsStage; + DateTime lastAsked; + + SrsItem({ + required this.kanjiId, + required this.quizMode, + this.readingType, + this.srsStage = 0, + DateTime? lastAsked, + }) : lastAsked = lastAsked ?? DateTime.now(); +} + class KanjiItem { final int id; final String characters; final List meanings; final List onyomi; final List kunyomi; + final Map srsItems = {}; KanjiItem({ required this.id, @@ -60,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 d8b664d..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'; -enum QuizMode { kanjiToEnglish, englishToKanji, reading } - class _ReadingInfo { final List correctReadings; final String hint; @@ -65,11 +63,13 @@ class _HomeScreenState extends State { return; } - setState(() { - _status = 'Fetching deck...'; - }); - - final items = await repo.fetchAndCacheFromWk(apiKey); + var items = await repo.loadKanji(); + if (items.isEmpty) { + setState(() { + _status = 'Fetching deck...'; + }); + items = await repo.fetchAndCacheFromWk(apiKey); + } setState(() { _deck = items; @@ -108,8 +108,18 @@ class _HomeScreenState extends State { } void _nextQuestion() { - if (_deck.isEmpty) return; - _current = (_deck..shuffle()).first; + _deck.sort((a, b) { + final aSrsItem = a.srsItems[_mode.toString()] ?? SrsItem(kanjiId: a.id, quizMode: _mode); + final bSrsItem = b.srsItems[_mode.toString()] ?? SrsItem(kanjiId: 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 = []; @@ -150,15 +160,42 @@ class _HomeScreenState extends State { setState(() {}); } - void _answer(String option) { + 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!; + + String readingType = ''; + if (_mode == QuizMode.reading) { + readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; + } + final srsKey = _mode.toString() + readingType; + + var srsItem = current.srsItems[srsKey]; + final isNew = srsItem == null; + srsItem ??= SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType); + setState(() { _asked += 1; - if (isCorrect) _score += 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.insertSrsItem(srsItem); + } else { + await repo.updateSrsItem(srsItem); + } + final correctDisplay = (_mode == QuizMode.kanjiToEnglish) ? _toTitleCase(_correctAnswers.first) : (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first); @@ -174,7 +211,9 @@ class _HomeScreenState extends State { backgroundColor: const Color(0xFF222222), duration: const Duration(milliseconds: 900), ); - ScaffoldMessenger.of(context).showSnackBar(snack); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(snack); + } Future.delayed(const Duration(milliseconds: 900), _nextQuestion); } @@ -307,4 +346,4 @@ class _HomeScreenState extends State { backgroundColor: const Color(0xFF1E1E1E), ); } -} +} \ No newline at end of file diff --git a/lib/src/screens/settings_screen.dart b/lib/src/screens/settings_screen.dart index 7f261f3..e585eee 100644 --- a/lib/src/screens/settings_screen.dart +++ b/lib/src/screens/settings_screen.dart @@ -32,7 +32,7 @@ class _SettingsScreenState extends State { ); Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomeScreen()), + MaterialPageRoute(builder: (_) => HomeScreen()), ); } } diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart index bd46302..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}); @@ -24,6 +24,11 @@ class _StartScreenState extends State { Future _checkApiKey() async { final repo = Provider.of(context, listen: false); await repo.loadApiKey(); + // TODO: Remove this before release. This is for development purposes only. + if (repo.apiKey == null || repo.apiKey!.isEmpty) { + await repo.setApiKey('91932463-60d2-4552-95a7-4c23cf358189'); + } + setState(() { _hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty; _loading = false; @@ -71,15 +76,9 @@ class _StartScreenState extends State { const SizedBox(height: 32), ElevatedButton( onPressed: () { - if (_hasApiKey) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomeScreen()), - ); - } else { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), - ); - } + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => HomeScreen()), + ); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.blueAccent, @@ -88,9 +87,28 @@ class _StartScreenState extends State { 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 b4460d9..ab06dba 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -23,16 +23,39 @@ class DeckRepository { _db = await openDatabase( path, - version: 2, + version: 5, onCreate: (db, version) async { await db.execute( '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); await db.execute( '''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 { - await db.execute( - '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)'''); + if (oldVersion < 2) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)'''); + } + if (oldVersion < 3) { + // Migration from version 2 to 3 was flawed, so we just drop the columns if they exist + } + if (oldVersion < 4) { + await db.execute( + '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))'''); + // 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))'''); + } }, ); @@ -81,7 +104,7 @@ class DeckRepository { Future> loadKanji() async { final db = await _openDb(); final rows = await db.query('kanji'); - return rows + final kanjiItems = rows .map((r) => KanjiItem( id: r['id'] as int, characters: r['characters'] as String, @@ -99,6 +122,58 @@ class DeckRepository { .toList(), )) .toList(); + + for (final item in kanjiItems) { + final srsItems = await getSrsItems(item.id); + for (final srsItem in srsItems) { + final key = srsItem.quizMode.toString() + (srsItem.readingType ?? ''); + item.srsItems[key] = srsItem; + } + } + + return kanjiItems; + } + + Future> getSrsItems(int kanjiId) async { + final db = await _openDb(); + final rows = await db.query('srs_items', where: 'kanjiId = ?', whereArgs: [kanjiId]); + return rows.map((r) { + return SrsItem( + kanjiId: r['kanjiId'] as int, + quizMode: QuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String), + readingType: r['readingType'] as String?, + srsStage: r['srsStage'] as int, + lastAsked: DateTime.parse(r['lastAsked'] as String), + ); + }).toList(); + } + + Future updateSrsItem(SrsItem item) async { + final db = await _openDb(); + await db.update( + 'srs_items', + { + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, + where: 'kanjiId = ? AND quizMode = ? AND readingType = ?', + whereArgs: [item.kanjiId, item.quizMode.toString(), item.readingType], + ); + } + + Future insertSrsItem(SrsItem item) async { + final db = await _openDb(); + await db.insert( + 'srs_items', + { + 'kanjiId': item.kanjiId, + 'quizMode': item.quizMode.toString(), + 'readingType': item.readingType, + 'srsStage': item.srsStage, + 'lastAsked': item.lastAsked.toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } Future> fetchAndCacheFromWk([String? apiKey]) async { @@ -137,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(' ');