add new vocabulary mode
This commit is contained in:
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user