Merge pull request 'scoring_system' (#1) from scoring_system into master

Reviewed-on: Crylia/wanikani-kanji-srs#1
This commit was merged in pull request #1.
This commit is contained in:
2025-10-28 03:43:23 +01:00
8 changed files with 736 additions and 31 deletions

View File

@@ -30,6 +30,28 @@ class WkClient {
return out; return out;
} }
Future<List<Map<String, dynamic>>> fetchAllSubjects({List<String>? types}) async {
final out = <Map<String, dynamic>>[];
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<String, dynamic>;
out.addAll((j['data'] as List).cast<Map<String, dynamic>>());
final pages = j['pages'] as Map<String, dynamic>?;
if (pages != null && pages['next_url'] != null) {
url = pages['next_url'] as String;
} else {
break;
}
}
return out;
}
Future<List<Map<String, dynamic>>> fetchSubjectsByIds(List<int> ids) async { Future<List<Map<String, dynamic>>> fetchSubjectsByIds(List<int> ids) async {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
const batch = 100; const batch = 100;

View File

@@ -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 { class KanjiItem {
final int id; final int id;
final String characters; final String characters;
final List<String> meanings; final List<String> meanings;
final List<String> onyomi; final List<String> onyomi;
final List<String> kunyomi; final List<String> kunyomi;
final Map<String, SrsItem> srsItems = {};
KanjiItem({ KanjiItem({
required this.id, required this.id,
@@ -60,3 +79,61 @@ String _katakanaToHiragana(String input) {
} }
return buf.toString(); 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<String> meanings;
final List<String> readings;
final Map<String, VocabSrsItem> srsItems = {};
VocabularyItem({
required this.id,
required this.characters,
required this.meanings,
required this.readings,
});
factory VocabularyItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
final data = subj['data'] as Map<String, dynamic>;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> readings = <String>[];
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,
);
}
}

View File

@@ -8,8 +8,6 @@ import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum QuizMode { kanjiToEnglish, englishToKanji, reading }
class _ReadingInfo { class _ReadingInfo {
final List<String> correctReadings; final List<String> correctReadings;
final String hint; final String hint;
@@ -65,11 +63,13 @@ class _HomeScreenState extends State<HomeScreen> {
return; return;
} }
setState(() { var items = await repo.loadKanji();
_status = 'Fetching deck...'; if (items.isEmpty) {
}); setState(() {
_status = 'Fetching deck...';
final items = await repo.fetchAndCacheFromWk(apiKey); });
items = await repo.fetchAndCacheFromWk(apiKey);
}
setState(() { setState(() {
_deck = items; _deck = items;
@@ -108,8 +108,18 @@ class _HomeScreenState extends State<HomeScreen> {
} }
void _nextQuestion() { void _nextQuestion() {
if (_deck.isEmpty) return; _deck.sort((a, b) {
_current = (_deck..shuffle()).first; 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 = []; _correctAnswers = [];
_options = []; _options = [];
@@ -150,15 +160,42 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() {}); setState(() {});
} }
void _answer(String option) { void _answer(String option) async {
final isCorrect = _correctAnswers final isCorrect = _correctAnswers
.map((a) => a.toLowerCase().trim()) .map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim()); .contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(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(() { setState(() {
_asked += 1; _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) final correctDisplay = (_mode == QuizMode.kanjiToEnglish)
? _toTitleCase(_correctAnswers.first) ? _toTitleCase(_correctAnswers.first)
: (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first); : (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first);
@@ -174,7 +211,9 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: const Color(0xFF222222), backgroundColor: const Color(0xFF222222),
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
ScaffoldMessenger.of(context).showSnackBar(snack); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack);
}
Future.delayed(const Duration(milliseconds: 900), _nextQuestion); Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
} }
@@ -307,4 +346,4 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: const Color(0xFF1E1E1E),
); );
} }
} }

View File

@@ -32,7 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()), MaterialPageRoute(builder: (_) => HomeScreen()),
); );
} }
} }

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import 'settings_screen.dart';
import 'home_screen.dart'; import 'home_screen.dart';
import 'vocab_screen.dart';
class StartScreen extends StatefulWidget { class StartScreen extends StatefulWidget {
const StartScreen({super.key}); const StartScreen({super.key});
@@ -24,6 +24,11 @@ class _StartScreenState extends State<StartScreen> {
Future<void> _checkApiKey() async { Future<void> _checkApiKey() async {
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey(); 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(() { setState(() {
_hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty; _hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty;
_loading = false; _loading = false;
@@ -71,15 +76,9 @@ class _StartScreenState extends State<StartScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
if (_hasApiKey) { Navigator.of(context).push(
Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => HomeScreen()),
MaterialPageRoute(builder: (_) => const HomeScreen()), );
);
} else {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent, backgroundColor: Colors.blueAccent,
@@ -88,9 +87,28 @@ class _StartScreenState extends State<StartScreen> {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)), borderRadius: BorderRadius.circular(12)),
), ),
child: Text( child: const Text(
_hasApiKey ? 'Start Quiz' : 'Go to Settings', 'Kanji Quiz',
style: const TextStyle(fontSize: 18), 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),
);
}
}

View File

@@ -23,16 +23,39 @@ class DeckRepository {
_db = await openDatabase( _db = await openDatabase(
path, path,
version: 2, version: 5,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute( await db.execute(
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''');
await db.execute( await db.execute(
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'''); '''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 { onUpgrade: (db, oldVersion, newVersion) async {
await db.execute( if (oldVersion < 2) {
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)'''); 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<List<KanjiItem>> loadKanji() async { Future<List<KanjiItem>> loadKanji() async {
final db = await _openDb(); final db = await _openDb();
final rows = await db.query('kanji'); final rows = await db.query('kanji');
return rows final kanjiItems = rows
.map((r) => KanjiItem( .map((r) => KanjiItem(
id: r['id'] as int, id: r['id'] as int,
characters: r['characters'] as String, characters: r['characters'] as String,
@@ -99,6 +122,58 @@ class DeckRepository {
.toList(), .toList(),
)) ))
.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<List<SrsItem>> 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<void> 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<void> 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<List<KanjiItem>> fetchAndCacheFromWk([String? apiKey]) async { Future<List<KanjiItem>> fetchAndCacheFromWk([String? apiKey]) async {
@@ -137,4 +212,128 @@ class DeckRepository {
await saveKanji(items); await saveKanji(items);
return items; return items;
} }
Future<List<VocabSrsItem>> 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<void> 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<void> 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<void> saveVocabulary(List<VocabularyItem> 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<List<VocabularyItem>> 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<List<VocabularyItem>> 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 = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
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;
}
} }

View File

@@ -70,6 +70,54 @@ class DistractorGenerator {
} }
return out; return out;
} }
List<String> generateVocabMeanings(VocabularyItem correct, List<VocabularyItem> 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 = <String>[];
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 = <String>[];
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<String> generateVocab(VocabularyItem correct, List<VocabularyItem> pool, int needed) {
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList();
others.shuffle(_rnd);
final out = <String>[];
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(' '); String _toTitleCase(String s) => s.split(' ').map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1))).join(' ');