scoring_system #1
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var items = await repo.loadKanji();
|
||||||
|
if (items.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = 'Fetching deck...';
|
_status = 'Fetching deck...';
|
||||||
});
|
});
|
||||||
|
items = await repo.fetchAndCacheFromWk(apiKey);
|
||||||
final 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),
|
||||||
);
|
);
|
||||||
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(snack);
|
ScaffoldMessenger.of(context).showSnackBar(snack);
|
||||||
|
}
|
||||||
|
|
||||||
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
|
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
MaterialPageRoute(builder: (_) => HomeScreen()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).pushReplacement(
|
|
||||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
MaterialPageRoute(builder: (_) => HomeScreen()),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
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(
|
_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 {
|
||||||
|
if (oldVersion < 2) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''');
|
'''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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
|
|||||||
Reference in New Issue
Block a user