import 'dart:async'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/kanji_item.dart'; import '../api/wk_client.dart'; class DeckRepository { Database? _db; String? _apiKey; Future setApiKey(String apiKey) async { _apiKey = apiKey; await saveApiKey(apiKey); } String? get apiKey => _apiKey; Future _openDb() async { if (_db != null) return _db!; final dir = await getApplicationDocumentsDirectory(); final path = join(dir.path, 'wanikani_srs.db'); _db = await openDatabase( path, 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 { 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))'''); } }, ); return _db!; } Future saveApiKey(String apiKey) async { final db = await _openDb(); await db.insert( 'settings', {'key': 'apiKey', 'value': apiKey}, conflictAlgorithm: ConflictAlgorithm.replace, ); } Future loadApiKey() async { final db = await _openDb(); final rows = await db.query('settings', where: 'key = ?', whereArgs: ['apiKey']); if (rows.isNotEmpty) { _apiKey = rows.first['value'] as String; return _apiKey; } return null; } Future saveKanji(List items) async { final db = await _openDb(); final batch = db.batch(); for (final it in items) { batch.insert( 'kanji', { 'id': it.id, 'characters': it.characters, 'meanings': it.meanings.join('|'), 'onyomi': it.onyomi.join('|'), 'kunyomi': it.kunyomi.join('|'), }, conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); } Future> loadKanji() async { final db = await _openDb(); final rows = await db.query('kanji'); final kanjiItems = rows .map((r) => KanjiItem( id: r['id'] as int, characters: r['characters'] as String, meanings: (r['meanings'] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), onyomi: (r['onyomi'] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), kunyomi: (r['kunyomi'] as String) .split('|') .where((s) => s.isNotEmpty) .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 { final key = apiKey ?? _apiKey; if (key == null) throw Exception('API key not set'); final client = WkClient(key); final assignments = await client.fetchAllAssignments(subjectTypes: ['kanji']); 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'] == 'kanji' || (s['data'] != null && (s['data'] as Map)['object_type'] == 'kanji')) .map((s) => KanjiItem.fromSubject(s)) .where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty) .toList(); 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; } }