import 'dart:async'; import 'dart:convert'; 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'; import 'package:flutter_dotenv/flutter_dotenv.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: 7, onCreate: (db, version) async { await db.execute( '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, 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, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios 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))''', ); } if (oldVersion < 6) { try { await db.execute( 'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT', ); } catch (_) { // Ignore error, column might already exist } } if (oldVersion < 7) { try { await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER'); await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER'); } catch (_) { // Ignore error, column might already exist } } }, ); 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 envApiKey = dotenv.env['WANIKANI_API_KEY']; if (envApiKey != null && envApiKey.isNotEmpty) { _apiKey = envApiKey; return _apiKey; } 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, 'level': it.level, '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, level: r['level'] as int? ?? 0, 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) { final audios = it.pronunciationAudios .map((a) => {'url': a.url, 'gender': a.gender}) .toList(); batch.insert('vocabulary', { 'id': it.id, 'level': it.level, 'characters': it.characters, 'meanings': it.meanings.join('|'), 'readings': it.readings.join('|'), 'pronunciation_audios': jsonEncode(audios), }, 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) { final audiosRaw = r['pronunciation_audios'] as String?; final List audios = []; if (audiosRaw != null && audiosRaw.isNotEmpty) { try { final decoded = jsonDecode(audiosRaw) as List; for (final audioData in decoded) { audios.add( PronunciationAudio( url: audioData['url'] as String, gender: audioData['gender'] as String, ), ); } } catch (e) { // Error decoding, so we'll just have no audio for this item } } return VocabularyItem( id: r['id'] as int, level: r['level'] as int? ?? 0, 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(), pronunciationAudios: audios, ); }).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; } }