From de3501c3e492c5cf06c59d2ca7536b558c5260d6 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Fri, 31 Oct 2025 17:18:33 +0100 Subject: [PATCH] themes and some refractoring --- lib/src/api/wk_client.dart | 12 ++ lib/src/models/kanji_item.dart | 139 ++---------- lib/src/models/srs_item.dart | 17 ++ lib/src/models/subject.dart | 38 ++++ lib/src/models/subject_factory.dart | 15 ++ lib/src/models/vocabulary_item.dart | 56 +++++ lib/src/screens/browse_screen.dart | 22 +- lib/src/screens/home_screen.dart | 14 +- lib/src/screens/vocab_screen.dart | 93 +++++--- lib/src/services/custom_deck_repository.dart | 7 +- lib/src/services/database_constants.dart | 27 +++ lib/src/services/database_helper.dart | 92 ++++++++ lib/src/services/deck_repository.dart | 214 ++++++------------- lib/src/services/distractor_generator.dart | 1 + lib/src/services/vocab_deck_repository.dart | 110 ++-------- 15 files changed, 443 insertions(+), 414 deletions(-) create mode 100644 lib/src/models/srs_item.dart create mode 100644 lib/src/models/subject.dart create mode 100644 lib/src/models/subject_factory.dart create mode 100644 lib/src/models/vocabulary_item.dart create mode 100644 lib/src/services/database_constants.dart create mode 100644 lib/src/services/database_helper.dart diff --git a/lib/src/api/wk_client.dart b/lib/src/api/wk_client.dart index fddd46c..2b21854 100644 --- a/lib/src/api/wk_client.dart +++ b/lib/src/api/wk_client.dart @@ -1,5 +1,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../models/subject.dart'; +import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; class WkClient { final String apiKey; @@ -85,4 +88,13 @@ class WkClient { } return out; } + static Subject createSubjectFromMap(Map map) { + final String object = map['object']; + if (object == 'kanji') { + return KanjiItem.fromSubject(map); + } else if (object == 'vocabulary') { + return VocabularyItem.fromSubject(map); + } + throw Exception('Unknown subject type: $object'); + } } diff --git a/lib/src/models/kanji_item.dart b/lib/src/models/kanji_item.dart index f7e028b..0d696c8 100644 --- a/lib/src/models/kanji_item.dart +++ b/lib/src/models/kanji_item.dart @@ -1,54 +1,24 @@ -enum QuizMode { kanjiToEnglish, englishToKanji, reading } +import 'subject.dart'; -class SrsItem { - final int kanjiId; - final QuizMode quizMode; - final String? readingType; - 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 int level; - final String characters; - final List meanings; +class KanjiItem extends Subject { final List onyomi; final List kunyomi; - final Map srsItems = {}; KanjiItem({ - required this.id, - required this.level, - required this.characters, - required this.meanings, + required super.id, + required super.level, + required super.characters, + required super.meanings, required this.onyomi, required this.kunyomi, }); factory KanjiItem.fromSubject(Map subj) { - final int id = subj['id'] as int; - final data = subj['data'] as Map; - final int level = data['level'] as int; - final String characters = (data['characters'] ?? '') as String; - final List meanings = []; + final commonFields = Subject.parseCommonFields(subj); + final data = commonFields['data'] as Map; final List onyomi = []; final List kunyomi = []; - 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) { final typ = r['type'] as String? ?? ''; @@ -62,10 +32,10 @@ class KanjiItem { } return KanjiItem( - id: id, - level: level, - characters: characters, - meanings: meanings, + id: commonFields['id'] as int, + level: commonFields['level'] as int, + characters: commonFields['characters'] as String, + meanings: commonFields['meanings'] as List, onyomi: onyomi, kunyomi: kunyomi, ); @@ -83,88 +53,3 @@ String _katakanaToHiragana(String input) { } return buf.toString(); } - -enum VocabQuizMode { vocabToEnglish, englishToVocab, audioToEnglish } - -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 PronunciationAudio { - final String url; - final String gender; - - PronunciationAudio({required this.url, required this.gender}); -} - -class VocabularyItem { - final int id; - final int level; - final String characters; - final List meanings; - final List readings; - final List pronunciationAudios; - final Map srsItems = {}; - - VocabularyItem({ - required this.id, - required this.level, - required this.characters, - required this.meanings, - required this.readings, - required this.pronunciationAudios, - }); - - factory VocabularyItem.fromSubject(Map subj) { - final int id = subj['id'] as int; - final data = subj['data'] as Map; - final int level = data['level'] as int; - final String characters = (data['characters'] ?? '') as String; - final List meanings = []; - final List readings = []; - final List pronunciationAudios = []; - - 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); - } - } - - if (data['pronunciation_audios'] != null) { - for (final audio in data['pronunciation_audios'] as List) { - final url = audio['url'] as String?; - final metadata = audio['metadata'] as Map?; - final gender = metadata?['gender'] as String?; - - if (url != null && gender != null) { - pronunciationAudios.add(PronunciationAudio(url: url, gender: gender)); - } - } - } - - return VocabularyItem( - id: id, - level: level, - characters: characters, - meanings: meanings, - readings: readings, - pronunciationAudios: pronunciationAudios, - ); - } -} diff --git a/lib/src/models/srs_item.dart b/lib/src/models/srs_item.dart new file mode 100644 index 0000000..913234d --- /dev/null +++ b/lib/src/models/srs_item.dart @@ -0,0 +1,17 @@ +enum QuizMode { kanjiToEnglish, englishToKanji, reading, vocabToEnglish, englishToVocab, audioToEnglish } + +class SrsItem { + final int subjectId; + final QuizMode quizMode; + final String? readingType; + int srsStage; + DateTime lastAsked; + + SrsItem({ + required this.subjectId, + required this.quizMode, + this.readingType, + this.srsStage = 0, + DateTime? lastAsked, + }) : lastAsked = lastAsked ?? DateTime.now(); +} diff --git a/lib/src/models/subject.dart b/lib/src/models/subject.dart new file mode 100644 index 0000000..36aac3b --- /dev/null +++ b/lib/src/models/subject.dart @@ -0,0 +1,38 @@ +import 'srs_item.dart'; + +abstract class Subject { + final int id; + final int level; + final String characters; + final List meanings; + final Map srsItems = {}; + + Subject({ + required this.id, + required this.level, + required this.characters, + required this.meanings, + }); + + static Map parseCommonFields(Map subj) { + final int id = subj['id'] as int; + final data = subj['data'] as Map; + final int level = data['level'] as int; + final String characters = (data['characters'] ?? '') as String; + final List meanings = []; + + if (data['meanings'] != null) { + for (final m in data['meanings'] as List) { + meanings.add((m['meaning'] as String).toLowerCase()); + } + } + + return { + 'id': id, + 'level': level, + 'characters': characters, + 'meanings': meanings, + 'data': data, + }; + } +} \ No newline at end of file diff --git a/lib/src/models/subject_factory.dart b/lib/src/models/subject_factory.dart new file mode 100644 index 0000000..888863c --- /dev/null +++ b/lib/src/models/subject_factory.dart @@ -0,0 +1,15 @@ +import 'kanji_item.dart'; +import 'vocabulary_item.dart'; +import 'subject.dart'; + +class SubjectFactory { + static Subject fromMap(Map map) { + final String object = map['object']; + if (object == 'kanji') { + return KanjiItem.fromSubject(map); + } else if (object == 'vocabulary') { + return VocabularyItem.fromSubject(map); + } + throw Exception('Unknown subject type: $object'); + } +} diff --git a/lib/src/models/vocabulary_item.dart b/lib/src/models/vocabulary_item.dart new file mode 100644 index 0000000..fa333cb --- /dev/null +++ b/lib/src/models/vocabulary_item.dart @@ -0,0 +1,56 @@ +import 'subject.dart'; + +class PronunciationAudio { + final String url; + final String gender; + + PronunciationAudio({required this.url, required this.gender}); +} + +class VocabularyItem extends Subject { + final List readings; + final List pronunciationAudios; + + VocabularyItem({ + required super.id, + required super.level, + required super.characters, + required super.meanings, + required this.readings, + required this.pronunciationAudios, + }); + + factory VocabularyItem.fromSubject(Map subj) { + final commonFields = Subject.parseCommonFields(subj); + final data = commonFields['data'] as Map; + final List readings = []; + final List pronunciationAudios = []; + + if (data['readings'] != null) { + for (final r in data['readings'] as List) { + readings.add(r['reading'] as String); + } + } + + if (data['pronunciation_audios'] != null) { + for (final audio in data['pronunciation_audios'] as List) { + final url = audio['url'] as String?; + final metadata = audio['metadata'] as Map?; + final gender = metadata?['gender'] as String?; + + if (url != null && gender != null) { + pronunciationAudios.add(PronunciationAudio(url: url, gender: gender)); + } + } + } + + return VocabularyItem( + id: commonFields['id'] as int, + level: commonFields['level'] as int, + characters: commonFields['characters'] as String, + meanings: commonFields['meanings'] as List, + readings: readings, + pronunciationAudios: pronunciationAudios, + ); + } +} diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index c9c8528..ea3a3a7 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -4,6 +4,8 @@ import 'package:hirameki_srs/src/themes.dart'; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import '../services/deck_repository.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/custom_deck_repository.dart'; @@ -264,9 +266,9 @@ class _BrowseScreenState extends State Widget _buildVocabListTile(VocabularyItem item) { final requiredModes = [ - VocabQuizMode.vocabToEnglish.toString(), - VocabQuizMode.englishToVocab.toString(), - VocabQuizMode.audioToEnglish.toString(), + QuizMode.vocabToEnglish.toString(), + QuizMode.englishToVocab.toString(), + QuizMode.audioToEnglish.toString(), ]; int minSrsStage = 9; @@ -422,6 +424,8 @@ class _BrowseScreenState extends State srsScores['Reading (kunyomi)'] = srsItem.srsStage; } break; + default: + break; } } @@ -670,7 +674,8 @@ class _BrowseScreenState extends State setState(() { if (_selectedItems.length == _customDeck.length) { _selectedItems.clear(); - } else { + } + else { _selectedItems = List.from(_customDeck); } }); @@ -968,15 +973,17 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { for (final entry in widget.vocab.srsItems.entries) { final srsItem = entry.value; switch (srsItem.quizMode) { - case VocabQuizMode.vocabToEnglish: + case QuizMode.vocabToEnglish: srsScores['JP -> EN'] = srsItem.srsStage; break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: srsScores['EN -> JP'] = srsItem.srsStage; break; - case VocabQuizMode.audioToEnglish: + case QuizMode.audioToEnglish: srsScores['Audio'] = srsItem.srsStage; break; + default: + break; } } @@ -1052,3 +1059,4 @@ void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) { }, ); } + diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 06db77d..4725dea 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/kanji_item.dart'; +import '../models/srs_item.dart'; import '../services/deck_repository.dart'; import '../services/distractor_generator.dart'; import '../widgets/kanji_card.dart'; @@ -61,9 +62,6 @@ class _HomeScreenState extends State super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { - if (_tabController.indexIsChanging) { - _nextQuestion(); - } setState(() {}); }); _dg = widget.distractorGenerator ?? DistractorGenerator(); @@ -266,6 +264,10 @@ class _HomeScreenState extends State ...distractors.take(3), ])..shuffle(); break; + default: + // Handle other QuizMode cases if necessary, or throw an error + // if these modes are not expected in this context. + break; } setState(() { @@ -294,7 +296,7 @@ class _HomeScreenState extends State var srsItem = current.srsItems[srsKey]; final isNew = srsItem == null; final srsItemForUpdate = srsItem ??= SrsItem( - kanjiId: current.id, + subjectId: current.id, quizMode: mode, readingType: readingType, ); @@ -432,6 +434,10 @@ class _HomeScreenState extends State prompt = quizState.current!.characters; subtitle = quizState.readingHint; break; + default: + // Handle other QuizMode cases if necessary, or throw an error + // if these modes are not expected in this context. + break; } } diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart index a8fa15d..14a1cea 100644 --- a/lib/src/screens/vocab_screen.dart +++ b/lib/src/screens/vocab_screen.dart @@ -3,7 +3,8 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/distractor_generator.dart'; import '../widgets/kanji_card.dart'; @@ -53,9 +54,6 @@ class _VocabScreenState extends State super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { - if (_tabController.indexIsChanging) { - _nextQuestion(); - } setState(() {}); }); _loadSettings(); @@ -130,16 +128,16 @@ class _VocabScreenState extends State .join(' '); } - VocabQuizMode _modeForIndex(int index) { + QuizMode _modeForIndex(int index) { switch (index) { case 0: - return VocabQuizMode.vocabToEnglish; + return QuizMode.vocabToEnglish; case 1: - return VocabQuizMode.englishToVocab; + return QuizMode.englishToVocab; case 2: - return VocabQuizMode.audioToEnglish; + return QuizMode.audioToEnglish; default: - return VocabQuizMode.vocabToEnglish; + return QuizMode.vocabToEnglish; } } @@ -150,7 +148,7 @@ class _VocabScreenState extends State final mode = _modeForIndex(index ?? _tabController.index); List currentDeckForMode = _deck; - if (mode == VocabQuizMode.audioToEnglish) { + if (mode == QuizMode.audioToEnglish) { currentDeckForMode = _deck .where((item) => item.pronunciationAudios.isNotEmpty) .toList(); @@ -169,10 +167,10 @@ class _VocabScreenState extends State quizState.shuffledDeck.sort((a, b) { final aSrsItem = a.srsItems[mode.toString()] ?? - VocabSrsItem(vocabId: a.id, quizMode: mode); + SrsItem(subjectId: a.id, quizMode: mode); final bSrsItem = b.srsItems[mode.toString()] ?? - VocabSrsItem(vocabId: b.id, quizMode: mode); + SrsItem(subjectId: b.id, quizMode: mode); final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage); if (stageComparison != 0) { return stageComparison; @@ -186,18 +184,14 @@ class _VocabScreenState extends State quizState.currentIndex++; quizState.key = UniqueKey(); - if (mode == VocabQuizMode.audioToEnglish) { - _playCurrentAudio(); - } - quizState.correctAnswers = []; quizState.options = []; quizState.selectedOption = null; quizState.showResult = false; switch (mode) { - case VocabQuizMode.vocabToEnglish: - case VocabQuizMode.audioToEnglish: + case QuizMode.vocabToEnglish: + case QuizMode.audioToEnglish: quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.options = [ quizState.correctAnswers.first, @@ -205,13 +199,15 @@ class _VocabScreenState extends State ].map(_toTitleCase).toList()..shuffle(); break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: quizState.correctAnswers = [quizState.current!.characters]; quizState.options = [ quizState.correctAnswers.first, ..._dg.generateVocab(quizState.current!, _deck, 3), ]..shuffle(); break; + default: + break; } setState(() { @@ -219,10 +215,12 @@ class _VocabScreenState extends State }); } - Future _playCurrentAudio() async { + Future _playCurrentAudio({bool playOnLoad = false}) async { final current = _currentQuizState.current; if (current == null || current.pronunciationAudios.isEmpty) return; + if (playOnLoad && !_playAudio) return; + final maleAudios = current.pronunciationAudios.where( (a) => a.gender == 'male', ); @@ -250,7 +248,7 @@ class _VocabScreenState extends State var srsItemNullable = current.srsItems[srsKey]; final isNew = srsItemNullable == null; final srsItem = - srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode); + srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode); quizState.asked += 1; quizState.selectedOption = option; @@ -272,7 +270,7 @@ class _VocabScreenState extends State await repo.updateVocabSrsItem(srsItem); } - final correctDisplay = (mode == VocabQuizMode.vocabToEnglish) + final correctDisplay = (mode == QuizMode.vocabToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : quizState.correctAnswers.first; @@ -281,7 +279,9 @@ class _VocabScreenState extends State content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( - color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error, + color: isCorrect + ? Theme.of(context).colorScheme.tertiary + : Theme.of(context).colorScheme.error, fontWeight: FontWeight.bold, ), ), @@ -294,7 +294,7 @@ class _VocabScreenState extends State if (_playCorrectSound) { await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); } - if (_playAudio && mode != VocabQuizMode.audioToEnglish) { + if (_playAudio) { final maleAudios = current.pronunciationAudios.where( (a) => a.gender == 'male', ); @@ -330,7 +330,12 @@ class _VocabScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('WaniKani API key is not set.', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + Text( + 'WaniKani API key is not set.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), const SizedBox(height: 16), ElevatedButton( onPressed: () async { @@ -375,26 +380,35 @@ class _VocabScreenState extends State if (quizState.current == null) { promptWidget = const SizedBox.shrink(); - } else if (mode == VocabQuizMode.audioToEnglish) { + } else if (mode == QuizMode.audioToEnglish) { promptWidget = IconButton( - icon: Icon(Icons.volume_up, color: Theme.of(context).colorScheme.onSurface, size: 64), + icon: Icon( + Icons.volume_up, + color: Theme.of(context).colorScheme.onSurface, + size: 64, + ), onPressed: _playCurrentAudio, ); } else { String promptText = ''; switch (mode) { - case VocabQuizMode.vocabToEnglish: + case QuizMode.vocabToEnglish: promptText = quizState.current!.characters; break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: promptText = _toTitleCase(quizState.current!.meanings.first); break; - case VocabQuizMode.audioToEnglish: + case QuizMode.audioToEnglish: + break; + default: break; } promptWidget = Text( promptText, - style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + fontSize: 48, + color: Theme.of(context).colorScheme.onSurface, + ), ); } @@ -405,9 +419,18 @@ class _VocabScreenState extends State children: [ Row( children: [ - Expanded(child: Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.onSurface))), + Expanded( + child: Text( + _status, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ), if (_loading) - CircularProgressIndicator(color: Theme.of(context).colorScheme.primary), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), ], ), const SizedBox(height: 18), @@ -440,7 +463,9 @@ class _VocabScreenState extends State const SizedBox(height: 8), Text( 'Score: ${quizState.score} / ${quizState.asked}', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ], ), diff --git a/lib/src/services/custom_deck_repository.dart b/lib/src/services/custom_deck_repository.dart index 0e21da4..b61790f 100644 --- a/lib/src/services/custom_deck_repository.dart +++ b/lib/src/services/custom_deck_repository.dart @@ -23,12 +23,7 @@ class CustomDeckRepository { } Future updateCard(CustomKanjiItem item) async { - final deck = await getCustomDeck(); - final index = deck.indexWhere((element) => element.characters == item.characters); - if (index != -1) { - deck[index] = item; - await saveDeck(deck); - } + await updateCards([item]); } Future updateCards(List itemsToUpdate) async { diff --git a/lib/src/services/database_constants.dart b/lib/src/services/database_constants.dart new file mode 100644 index 0000000..85f4132 --- /dev/null +++ b/lib/src/services/database_constants.dart @@ -0,0 +1,27 @@ + +class DbConstants { + static const String settingsTable = 'settings'; + static const String kanjiTable = 'kanji'; + static const String srsItemsTable = 'srs_items'; + static const String vocabularyTable = 'vocabulary'; + static const String srsVocabItemsTable = 'srs_vocab_items'; + + static const String keyColumn = 'key'; + static const String valueColumn = 'value'; + + static const String idColumn = 'id'; + static const String levelColumn = 'level'; + static const String charactersColumn = 'characters'; + static const String meaningsColumn = 'meanings'; + static const String onyomiColumn = 'onyomi'; + static const String kunyomiColumn = 'kunyomi'; + static const String readingsColumn = 'readings'; + static const String pronunciationAudiosColumn = 'pronunciation_audios'; + + static const String kanjiIdColumn = 'kanjiId'; + static const String vocabIdColumn = 'vocabId'; + static const String quizModeColumn = 'quizMode'; + static const String readingTypeColumn = 'readingType'; + static const String srsStageColumn = 'srsStage'; + static const String lastAskedColumn = 'lastAsked'; +} diff --git a/lib/src/services/database_helper.dart b/lib/src/services/database_helper.dart new file mode 100644 index 0000000..88da2f5 --- /dev/null +++ b/lib/src/services/database_helper.dart @@ -0,0 +1,92 @@ +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'database_constants.dart'; + +class DatabaseHelper { + static final DatabaseHelper _instance = DatabaseHelper._internal(); + static Database? _db; + + factory DatabaseHelper() { + return _instance; + } + + DatabaseHelper._internal(); + + Future get db async { + if (_db != null) return _db!; + _db = await _openDb(); + return _db!; + } + + Future close() async { + if (_db != null) { + await _db!.close(); + _db = null; + } + } + + Future _openDb() async { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, 'wanikani_srs.db'); + + return openDatabase( + path, + version: 7, + onCreate: (db, version) async { + await db.execute( + '''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', + ); + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', + ); + } + if (oldVersion < 4) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', + ); + } + if (oldVersion < 5) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', + ); + } + if (oldVersion < 6) { + try { + await db.execute( + 'ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.pronunciationAudiosColumn} TEXT', + ); + } catch (_) { + // Ignore error, column might already exist + } + } + if (oldVersion < 7) { + try { + await db.execute('ALTER TABLE ${DbConstants.kanjiTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); + await db.execute('ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); + } catch (_) { + // Ignore error, column might already exist + } + } + }, + ); + } +} diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 859be03..2a2db35 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -1,14 +1,14 @@ 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 '../models/srs_item.dart'; import '../api/wk_client.dart'; +import 'database_constants.dart'; +import 'database_helper.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; class DeckRepository { - Database? _db; String? _apiKey; Future setApiKey(String apiKey) async { @@ -18,146 +18,75 @@ class DeckRepository { 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, + final db = await DatabaseHelper().db; + await db.insert(DbConstants.settingsTable, { + DbConstants.keyColumn: 'apiKey', + DbConstants.valueColumn: apiKey, }, conflictAlgorithm: ConflictAlgorithm.replace); } Future loadApiKey() async { - String? envApiKey; - try { - envApiKey = dotenv.env['WANIKANI_API_KEY']; - } catch (e) { - envApiKey = null; - } - - if (envApiKey != null && envApiKey.isNotEmpty) { - _apiKey = envApiKey; - return _apiKey; - } - - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query( - 'settings', - where: 'key = ?', + DbConstants.settingsTable, + where: '${DbConstants.keyColumn} = ?', whereArgs: ['apiKey'], ); + if (rows.isNotEmpty) { - _apiKey = rows.first['value'] as String; + _apiKey = rows.first[DbConstants.valueColumn] as String; return _apiKey; } + + try { + final envApiKey = dotenv.env['WANIKANI_API_KEY']; + if (envApiKey != null && envApiKey.isNotEmpty) { + await saveApiKey(envApiKey); + _apiKey = envApiKey; + return _apiKey; + } + } catch (e) { + // dotenv is not initialized + } + return null; } Future saveKanji(List items) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; 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('|'), + batch.insert(DbConstants.kanjiTable, { + DbConstants.idColumn: it.id, + DbConstants.levelColumn: it.level, + DbConstants.charactersColumn: it.characters, + DbConstants.meaningsColumn: it.meanings.join('|'), + DbConstants.onyomiColumn: it.onyomi.join('|'), + DbConstants.kunyomiColumn: 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 db = await DatabaseHelper().db; + final rows = await db.query(DbConstants.kanjiTable); 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) + id: r[DbConstants.idColumn] as int, + level: r[DbConstants.levelColumn] as int? ?? 0, + characters: r[DbConstants.charactersColumn] as String, + meanings: (r[DbConstants.meaningsColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), - onyomi: (r['onyomi'] as String) + onyomi: (r[DbConstants.onyomiColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), - kunyomi: (r['kunyomi'] as String) + kunyomi: (r[DbConstants.kunyomiColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), @@ -165,8 +94,23 @@ class DeckRepository { ) .toList(); + final srsRows = await db.query(DbConstants.srsItemsTable); + final srsItemsByKanjiId = >{}; + for (final r in srsRows) { + final srsItem = SrsItem( + subjectId: r[DbConstants.kanjiIdColumn] as int, + quizMode: QuizMode.values.firstWhere( + (e) => e.toString() == r[DbConstants.quizModeColumn] as String, + ), + readingType: r[DbConstants.readingTypeColumn] as String?, + srsStage: r[DbConstants.srsStageColumn] as int, + lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String), + ); + srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem); + } + for (final item in kanjiItems) { - final srsItems = await getSrsItems(item.id); + final srsItems = srsItemsByKanjiId[item.id] ?? []; for (final srsItem in srsItems) { final key = srsItem.quizMode.toString() + (srsItem.readingType ?? ''); item.srsItems[key] = srsItem; @@ -176,47 +120,27 @@ class DeckRepository { 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(); + final db = await DatabaseHelper().db; await db.update( - 'srs_items', + DbConstants.srsItemsTable, { - 'srsStage': item.srsStage, - 'lastAsked': item.lastAsked.toIso8601String(), + DbConstants.srsStageColumn: item.srsStage, + DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), }, - where: 'kanjiId = ? AND quizMode = ? AND readingType = ?', - whereArgs: [item.kanjiId, item.quizMode.toString(), item.readingType], + where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', + whereArgs: [item.subjectId, 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(), + final db = await DatabaseHelper().db; + await db.insert(DbConstants.srsItemsTable, { + DbConstants.kanjiIdColumn: item.subjectId, + DbConstants.quizModeColumn: item.quizMode.toString(), + DbConstants.readingTypeColumn: item.readingType, + DbConstants.srsStageColumn: item.srsStage, + DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), }, conflictAlgorithm: ConflictAlgorithm.replace); } diff --git a/lib/src/services/distractor_generator.dart b/lib/src/services/distractor_generator.dart index 5c9f03a..984c9ed 100644 --- a/lib/src/services/distractor_generator.dart +++ b/lib/src/services/distractor_generator.dart @@ -1,4 +1,5 @@ import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; import 'dart:math'; class DistractorGenerator { diff --git a/lib/src/services/vocab_deck_repository.dart b/lib/src/services/vocab_deck_repository.dart index ecbb2dd..7766532 100644 --- a/lib/src/services/vocab_deck_repository.dart +++ b/lib/src/services/vocab_deck_repository.dart @@ -1,15 +1,14 @@ 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 '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import '../api/wk_client.dart'; +import 'database_helper.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; class VocabDeckRepository { - Database? _db; String? _apiKey; Future setApiKey(String apiKey) async { @@ -19,86 +18,15 @@ class VocabDeckRepository { 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(); + final db = await DatabaseHelper().db; await db.insert('settings', { 'key': 'apiKey', 'value': apiKey, }, conflictAlgorithm: ConflictAlgorithm.replace); } + Future loadApiKey() async { String? envApiKey; try { @@ -112,7 +40,7 @@ class VocabDeckRepository { return _apiKey; } - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query( 'settings', where: 'key = ?', @@ -125,17 +53,17 @@ class VocabDeckRepository { return null; } - Future> getVocabSrsItems(int vocabId) async { - final db = await _openDb(); + Future> getVocabSrsItems(int vocabId) async { + final db = await DatabaseHelper().db; 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( + return SrsItem( + subjectId: r['vocabId'] as int, + quizMode: QuizMode.values.firstWhere( (e) => e.toString() == r['quizMode'] as String, ), srsStage: r['srsStage'] as int, @@ -144,8 +72,8 @@ class VocabDeckRepository { }).toList(); } - Future updateVocabSrsItem(VocabSrsItem item) async { - final db = await _openDb(); + Future updateVocabSrsItem(SrsItem item) async { + final db = await DatabaseHelper().db; await db.update( 'srs_vocab_items', { @@ -153,14 +81,14 @@ class VocabDeckRepository { 'lastAsked': item.lastAsked.toIso8601String(), }, where: 'vocabId = ? AND quizMode = ?', - whereArgs: [item.vocabId, item.quizMode.toString()], + whereArgs: [item.subjectId, item.quizMode.toString()], ); } - Future insertVocabSrsItem(VocabSrsItem item) async { - final db = await _openDb(); + Future insertVocabSrsItem(SrsItem item) async { + final db = await DatabaseHelper().db; await db.insert('srs_vocab_items', { - 'vocabId': item.vocabId, + 'vocabId': item.subjectId, 'quizMode': item.quizMode.toString(), 'srsStage': item.srsStage, 'lastAsked': item.lastAsked.toIso8601String(), @@ -168,7 +96,7 @@ class VocabDeckRepository { } Future saveVocabulary(List items) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; final batch = db.batch(); for (final it in items) { final audios = it.pronunciationAudios @@ -187,7 +115,7 @@ class VocabDeckRepository { } Future> loadVocabulary() async { - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query('vocabulary'); final vocabItems = rows.map((r) { final audiosRaw = r['pronunciation_audios'] as String?;