Compare commits
2 Commits
59fde3457d
...
68f6fa12bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68f6fa12bb | ||
|
|
61081ac8a4 |
@@ -30,6 +30,28 @@ class WkClient {
|
||||
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 {
|
||||
final out = <Map<String, dynamic>>[];
|
||||
const batch = 100;
|
||||
|
||||
@@ -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<String> meanings;
|
||||
final List<String> onyomi;
|
||||
final List<String> kunyomi;
|
||||
final Map<String, SrsItem> 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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> correctReadings;
|
||||
final String hint;
|
||||
@@ -65,11 +63,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<HomeScreen> {
|
||||
}
|
||||
|
||||
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<HomeScreen> {
|
||||
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<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(() {
|
||||
_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<HomeScreen> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
MaterialPageRoute(builder: (_) => HomeScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StartScreen> {
|
||||
Future<void> _checkApiKey() async {
|
||||
final repo = Provider.of<DeckRepository>(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<StartScreen> {
|
||||
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<StartScreen> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
302
lib/src/screens/vocab_screen.dart
Normal file
302
lib/src/screens/vocab_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<KanjiItem>> 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<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 {
|
||||
@@ -137,4 +212,128 @@ class DeckRepository {
|
||||
await saveKanji(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,54 @@ class DistractorGenerator {
|
||||
}
|
||||
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(' ');
|
||||
|
||||
Reference in New Issue
Block a user