finish v3

This commit is contained in:
Rene Kievits
2025-11-02 19:00:17 +01:00
parent 16da0f04ac
commit 5f1b9ba12e
16 changed files with 396 additions and 285 deletions

View File

@@ -88,6 +88,7 @@ class WkClient {
} }
return out; return out;
} }
static Subject createSubjectFromMap(Map<String, dynamic> map) { static Subject createSubjectFromMap(Map<String, dynamic> map) {
final String object = map['object']; final String object = map['object'];
if (object == 'kanji') { if (object == 'kanji') {

View File

@@ -254,7 +254,7 @@ class _BrowseScreenState extends State<BrowseScreen>
backgroundColor: isSelected backgroundColor: isSelected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: isDisabled : isDisabled
? Theme.of(context).colorScheme.surfaceVariant ? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: isSelected foregroundColor: isSelected

View File

@@ -34,34 +34,37 @@ class CustomQuizScreen extends StatefulWidget {
State<CustomQuizScreen> createState() => CustomQuizScreenState(); State<CustomQuizScreen> createState() => CustomQuizScreenState();
} }
class _CustomQuizState {
CustomKanjiItem? current;
List<String> options = [];
List<String> correctAnswers = [];
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
Set<String> wrongItems = {};
}
class CustomQuizScreenState extends State<CustomQuizScreen> class CustomQuizScreenState extends State<CustomQuizScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
int _currentIndex = 0; final _quizState = _CustomQuizState();
List<CustomKanjiItem> _shuffledDeck = []; List<CustomKanjiItem> _shuffledDeck = [];
List<String> _options = []; int _sessionDeckSize = 0;
bool _answered = false; bool _isAnswering = false;
bool? _correct;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = [];
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
bool _playIncorrectSound = true; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _playNarrator = true; bool _playNarrator = true;
String? _selectedOption;
String? _correctAnswer;
bool _showResult = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_shuffledDeck = widget.deck.toList()..shuffle(); _shuffledDeck = widget.deck.toList()..shuffle();
if (_shuffledDeck.isNotEmpty) { _sessionDeckSize = _shuffledDeck.length;
_generateOptions();
}
_shakeController = AnimationController( _shakeController = AnimationController(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
vsync: this, vsync: this,
@@ -70,6 +73,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn), CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
); );
_loadSettings(); _loadSettings();
_nextQuestion();
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@@ -90,163 +94,53 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void didUpdateWidget(CustomQuizScreen oldWidget) { void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.deck != oldWidget.deck && !widget.isActive) { if (widget.deck != oldWidget.deck && !widget.isActive) {
setState(() { _shuffledDeck = widget.deck.toList()..shuffle();
_shuffledDeck = widget.deck.toList()..shuffle(); _sessionDeckSize = _shuffledDeck.length;
_currentIndex = 0; _nextQuestion();
_answered = false;
_correct = null;
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
});
} }
if (widget.useKanji != oldWidget.useKanji) { if (widget.useKanji != oldWidget.useKanji) {
setState(() { _nextQuestion();
_generateOptions();
});
} }
} }
void playAudio() async { void playAudio() async {
final quizState = _quizState;
if (widget.quizMode == CustomQuizMode.listeningComprehension && if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_currentIndex < _shuffledDeck.length && _playNarrator) { quizState.current != null &&
_playNarrator) {
final ttsService = Provider.of<TtsService>(context, listen: false); final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(_shuffledDeck[_currentIndex].characters); await ttsService.speak(quizState.current!.characters);
} }
} }
@override @override
void dispose() { void dispose() {
_shakeController.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
void _generateOptions() { void _answer(String option) async {
final currentItem = _shuffledDeck[_currentIndex]; final quizState = _quizState;
if (widget.quizMode == CustomQuizMode.listeningComprehension || final current = quizState.current!;
widget.quizMode == CustomQuizMode.japaneseToEnglish) { final isCorrect = quizState.correctAnswers
_options = [currentItem.meaning]; .map((a) => a.toLowerCase().trim())
} .contains(option.toLowerCase().trim());
else {
_options = [
widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters,
];
}
final otherItems = widget.deck
.where((item) => item.characters != currentItem.characters)
.toList();
otherItems.shuffle();
for (var i = 0; i < min(3, otherItems.length); i++) {
if (widget.quizMode == CustomQuizMode.listeningComprehension ||
widget.quizMode == CustomQuizMode.japaneseToEnglish) {
_options.add(otherItems[i].meaning);
} else {
_options.add(
widget.useKanji && otherItems[i].kanji != null
? otherItems[i].kanji!
: otherItems[i].characters,
);
}
}
while (_options.length < 4) {
_options.add('---');
}
_options.shuffle();
}
void _checkAnswer(String answer) async {
final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters)
: currentItem.meaning;
final isCorrect = answer == correctAnswer;
setState(() { setState(() {
_selectedOption = answer; quizState.selectedOption = option;
_correctAnswer = correctAnswer; quizState.showResult = true;
_showResult = true; _isAnswering = true;
}); });
int currentSrsLevel = 0; // Initialize with a default value if (current.useInterval) {
if (currentItem.useInterval) { _updateSrsLevel(current, isCorrect);
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentSrsLevel = currentItem.srsData.japaneseToEnglish;
break;
case CustomQuizMode.englishToJapanese:
currentSrsLevel = currentItem.srsData.englishToJapanese;
break;
case CustomQuizMode.listeningComprehension:
currentSrsLevel = currentItem.srsData.listeningComprehension;
break;
}
if (isCorrect) {
if (_incorrectlyAnsweredItems.contains(currentItem.characters)) {
_incorrectlyAnsweredItems.remove(currentItem.characters);
} else {
currentSrsLevel++;
}
final interval = pow(2, currentSrsLevel).toInt();
final newNextReview = DateTime.now().add(Duration(hours: interval));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break;
}
} else {
if (!_incorrectlyAnsweredItems.contains(currentItem.characters)) {
_incorrectlyAnsweredItems.add(currentItem.characters);
}
currentSrsLevel = max(0, currentSrsLevel - 1);
final newNextReview = DateTime.now().add(const Duration(hours: 1));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break;
}
}
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentItem.srsData.japaneseToEnglish = currentSrsLevel;
break;
case CustomQuizMode.englishToJapanese:
currentItem.srsData.englishToJapanese = currentSrsLevel;
break;
case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehension = currentSrsLevel;
break;
}
widget.onCardReviewed(currentItem);
} }
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese) final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && currentItem.kanji != null ? (widget.useKanji && current.kanji != null
? currentItem.kanji! ? current.kanji!
: currentItem.characters) : current.characters)
: currentItem.meaning; : current.meaning;
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
@@ -266,52 +160,23 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
if (isCorrect) { if (isCorrect) {
if (_incorrectlyAnsweredItems.contains(currentItem.characters)) { quizState.asked += 1;
_incorrectlyAnsweredItems.remove(currentItem.characters); if (!quizState.wrongItems.contains(current.characters)) {
} else { quizState.score += 1;
currentSrsLevel++;
}
final interval = pow(2, currentSrsLevel).toInt();
final newNextReview = DateTime.now().add(Duration(hours: interval));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break;
} }
if (_playCorrectSound && !_playNarrator) { if (_playCorrectSound && !_playNarrator) {
await _audioPlayer.play(AssetSource('sfx/correct.wav')); await _audioPlayer.play(AssetSource('sfx/correct.wav'));
} else if (_playNarrator) { } else if (_playNarrator) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish || if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.englishToJapanese) { widget.quizMode == CustomQuizMode.englishToJapanese) {
await _speak(currentItem.characters); await _speak(current.characters);
} }
} }
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} else { } else {
if (!_incorrectlyAnsweredItems.contains(currentItem.characters)) { quizState.wrongItems.add(current.characters);
_incorrectlyAnsweredItems.add(currentItem.characters); _shuffledDeck.add(current);
} _shuffledDeck.shuffle();
currentSrsLevel = max(0, currentSrsLevel - 1);
final newNextReview = DateTime.now().add(const Duration(hours: 1));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break;
}
if (_playIncorrectSound) { if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav')); await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
} }
@@ -319,24 +184,132 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
await Future.delayed(const Duration(milliseconds: 900)); await Future.delayed(const Duration(milliseconds: 900));
} }
_nextQuestion(); Future.delayed(const Duration(milliseconds: 900), () {
} if (mounted) {
_nextQuestion();
Future<void> _nextQuestion() async {
setState(() {
_currentIndex++;
_answered = false;
_correct = null;
_selectedOption = null;
_correctAnswer = null;
_showResult = false;
if (_currentIndex < _shuffledDeck.length) {
_generateOptions();
} }
}); });
if (_currentIndex < _shuffledDeck.length && }
void _updateSrsLevel(CustomKanjiItem item, bool isCorrect) {
int currentSrsLevel = 0;
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentSrsLevel = item.srsData.japaneseToEnglish;
break;
case CustomQuizMode.englishToJapanese:
currentSrsLevel = item.srsData.englishToJapanese;
break;
case CustomQuizMode.listeningComprehension:
currentSrsLevel = item.srsData.listeningComprehension;
break;
}
if (isCorrect) {
currentSrsLevel++;
final interval = pow(2, currentSrsLevel).toInt();
final newNextReview = DateTime.now().add(Duration(hours: interval));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehensionNextReview = newNextReview;
break;
}
} else {
currentSrsLevel = max(0, currentSrsLevel - 1);
final newNextReview = DateTime.now().add(const Duration(hours: 1));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehensionNextReview = newNextReview;
break;
}
}
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglish = currentSrsLevel;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapanese = currentSrsLevel;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehension = currentSrsLevel;
break;
}
widget.onCardReviewed(item);
}
void _nextQuestion() {
final quizState = _quizState;
if (_shuffledDeck.isEmpty) {
setState(() {
quizState.current = null;
});
return;
}
quizState.current = _shuffledDeck.removeAt(0);
quizState.key = UniqueKey();
quizState.correctAnswers = [];
quizState.options = [];
quizState.selectedOption = null;
quizState.showResult = false;
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.listeningComprehension) { widget.quizMode == CustomQuizMode.listeningComprehension) {
await _speak(_shuffledDeck[_currentIndex].characters); quizState.correctAnswers = [quizState.current!.meaning];
quizState.options = [quizState.correctAnswers.first];
} else {
quizState.correctAnswers = [
widget.useKanji && quizState.current!.kanji != null
? quizState.current!.kanji!
: quizState.current!.characters,
];
quizState.options = [quizState.correctAnswers.first];
}
final otherItems = widget.deck
.where((item) => item.characters != quizState.current!.characters)
.toList();
otherItems.shuffle();
for (var i = 0; i < min(3, otherItems.length); i++) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.listeningComprehension) {
quizState.options.add(otherItems[i].meaning);
} else {
quizState.options.add(
widget.useKanji && otherItems[i].kanji != null
? otherItems[i].kanji!
: otherItems[i].characters,
);
}
}
while (quizState.options.length < 4) {
quizState.options.add('---');
}
quizState.options.shuffle();
setState(() {
_isAnswering = false;
});
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(quizState.current!.characters);
} }
} }
@@ -346,25 +319,29 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
void _onOptionSelected(String option) { void _onOptionSelected(String option) {
if (!(_answered && _correct!)) { if (!_isAnswering) {
_checkAnswer(option); _answer(option);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) { final quizState = _quizState;
return Center(child: Text('Review session complete!', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
if (quizState.current == null) {
return Center(
child: Text(
'Review session complete!',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
);
} }
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = quizState.current!;
final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning
: (widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters);
Widget promptWidget; Widget promptWidget;
String subtitle = '';
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (widget.quizMode == CustomQuizMode.listeningComprehension) {
promptWidget = IconButton( promptWidget = IconButton(
icon: const Icon(Icons.volume_up, size: 64), icon: const Icon(Icons.volume_up, size: 64),
@@ -372,25 +349,60 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
); );
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) { } else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
promptWidget = Text( promptWidget = Text(
question, currentItem.meaning,
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
); );
} else { } else {
final promptText = widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters;
promptWidget = GestureDetector( promptWidget = GestureDetector(
onTap: () => _speak(question), onTap: () => _speak(promptText),
child: Text( child: Text(
question, promptText,
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
); );
} }
return Padding( return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${quizState.asked} / $_sessionDeckSize',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: _sessionDeckSize > 0
? quizState.asked / _sessionDeckSize
: 0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 18), const SizedBox(height: 18),
Expanded( Expanded(
flex: 3, flex: 3,
@@ -401,7 +413,10 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
maxWidth: 500, maxWidth: 500,
minHeight: 150, minHeight: 150,
), ),
child: KanjiCard(characterWidget: promptWidget, subtitle: ''), child: KanjiCard(
characterWidget: promptWidget,
subtitle: subtitle,
),
), ),
), ),
), ),
@@ -419,11 +434,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
); );
}, },
child: OptionsGrid( child: OptionsGrid(
options: _options, options: quizState.options,
onSelected: _onOptionSelected, onSelected: _isAnswering ? (option) {} : _onOptionSelected,
correctAnswers: [], selectedOption: quizState.selectedOption,
showResult: false, correctAnswers: quizState.correctAnswers,
isDisabled: false, showResult: quizState.showResult,
),
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],

View File

@@ -128,8 +128,11 @@ class _HomeScreenState extends State<HomeScreen>
} }
itemsByLevel.forEach((level, items) { itemsByLevel.forEach((level, items) {
final allSrsItems = items.expand((item) => item.srsItems.values).toList(); final allSrsItems = items
if (allSrsItems.isNotEmpty && allSrsItems.every((srs) => srs.disabled)) { .expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level); disabledLevels.add(level);
} }
}); });
@@ -144,9 +147,11 @@ class _HomeScreenState extends State<HomeScreen>
if (mode == QuizMode.reading) { if (mode == QuizMode.reading) {
final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi']; final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi'];
final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi']; final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi'];
final hasOnyomi = item.onyomi.isNotEmpty && final hasOnyomi =
item.onyomi.isNotEmpty &&
(onyomiSrs == null || !onyomiSrs.disabled); (onyomiSrs == null || !onyomiSrs.disabled);
final hasKunyomi = item.kunyomi.isNotEmpty && final hasKunyomi =
item.kunyomi.isNotEmpty &&
(kunyomiSrs == null || !kunyomiSrs.disabled); (kunyomiSrs == null || !kunyomiSrs.disabled);
return hasOnyomi || hasKunyomi; return hasOnyomi || hasKunyomi;
} }
@@ -289,8 +294,9 @@ class _HomeScreenState extends State<HomeScreen>
String readingType = ''; String readingType = '';
if (mode == QuizMode.reading) { if (mode == QuizMode.reading) {
readingType = readingType = quizState.readingHint.contains("on'yomi")
quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; ? 'onyomi'
: 'kunyomi';
} }
final srsKey = mode.toString() + readingType; final srsKey = mode.toString() + readingType;
@@ -341,8 +347,8 @@ class _HomeScreenState extends State<HomeScreen>
final correctDisplay = (mode == QuizMode.kanjiToEnglish) final correctDisplay = (mode == QuizMode.kanjiToEnglish)
? _toTitleCase(quizState.correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: (mode == QuizMode.reading : (mode == QuizMode.reading
? quizState.correctAnswers.join(', ') ? quizState.correctAnswers.join(', ')
: quizState.correctAnswers.first); : quizState.correctAnswers.first);
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
@@ -449,7 +455,9 @@ class _HomeScreenState extends State<HomeScreen>
child: Text( child: Text(
_status, _status,
style: TextStyle( style: TextStyle(
fontSize: 24, color: Theme.of(context).colorScheme.onSurface), fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
), ),
); );
} }
@@ -495,10 +503,12 @@ class _HomeScreenState extends State<HomeScreen>
value: (_sessionDeckSizes[index] ?? 0) > 0 value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1) ? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0, : 0,
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.surfaceContainerHighest, context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary), Theme.of(context).colorScheme.primary,
),
), ),
], ],
), ),

View File

@@ -17,9 +17,9 @@ class StartScreen extends StatelessWidget {
IconButton( IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
onPressed: () { onPressed: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const SettingsScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
}, },
), ),
], ],
@@ -48,9 +48,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.extension, icon: Icons.extension,
description: 'Test your knowledge of kanji characters.', description: 'Test your knowledge of kanji characters.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const HomeScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const HomeScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -59,9 +59,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.school, icon: Icons.school,
description: 'Practice vocabulary from your WaniKani deck.', description: 'Practice vocabulary from your WaniKani deck.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const VocabScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const VocabScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -70,9 +70,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.grid_view, icon: Icons.grid_view,
description: 'Look through your kanji and vocabulary decks.', description: 'Look through your kanji and vocabulary decks.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const BrowseScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const BrowseScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -92,7 +92,8 @@ class StartScreen extends StatelessWidget {
); );
} }
Widget _buildModeCard(BuildContext context, { Widget _buildModeCard(
BuildContext context, {
required String title, required String title,
required IconData icon, required IconData icon,
required String description, required String description,
@@ -109,13 +110,17 @@ class StartScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary), Icon(
icon,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@@ -123,8 +123,11 @@ class _VocabScreenState extends State<VocabScreen>
} }
itemsByLevel.forEach((level, items) { itemsByLevel.forEach((level, items) {
final allSrsItems = items.expand((item) => item.srsItems.values).toList(); final allSrsItems = items
if (allSrsItems.isNotEmpty && allSrsItems.every((srs) => srs.disabled)) { .expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level); disabledLevels.add(level);
} }
}); });
@@ -349,7 +352,7 @@ class _VocabScreenState extends State<VocabScreen>
if (mounted) { if (mounted) {
_nextQuestion(); _nextQuestion();
} }
});; });
} }
@override @override
@@ -429,7 +432,9 @@ class _VocabScreenState extends State<VocabScreen>
child: Text( child: Text(
_status, _status,
style: TextStyle( style: TextStyle(
fontSize: 24, color: Theme.of(context).colorScheme.onSurface), fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
), ),
); );
} }
@@ -491,10 +496,12 @@ class _VocabScreenState extends State<VocabScreen>
value: (_sessionDeckSizes[index] ?? 0) > 0 value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1) ? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0, : 0,
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.surfaceContainerHighest, context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary), Theme.of(context).colorScheme.primary,
),
), ),
], ],
), ),

View File

@@ -1,4 +1,3 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
@@ -29,7 +28,9 @@ class CustomDeckRepository {
Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async { Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async {
final deck = await getCustomDeck(); final deck = await getCustomDeck();
for (var item in itemsToUpdate) { for (var item in itemsToUpdate) {
final index = deck.indexWhere((element) => element.characters == item.characters); final index = deck.indexWhere(
(element) => element.characters == item.characters,
);
if (index != -1) { if (index != -1) {
deck[index] = item; deck[index] = item;
} }

View File

@@ -1,4 +1,3 @@
class DbConstants { class DbConstants {
static const String settingsTable = 'settings'; static const String settingsTable = 'settings';
static const String kanjiTable = 'kanji'; static const String kanjiTable = 'kanji';

View File

@@ -52,8 +52,12 @@ class DatabaseHelper {
}, },
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) { if (oldVersion < 8) {
await db.execute('ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0'); await db.execute(
await db.execute('ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0'); 'ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
);
await db.execute(
'ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
);
} }
}, },
); );

View File

@@ -123,7 +123,8 @@ class DeckRepository {
final db = await DatabaseHelper().db; final db = await DatabaseHelper().db;
final batch = db.batch(); final batch = db.batch();
for (final item in items) { for (final item in items) {
var where = '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?'; var where =
'${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?';
final whereArgs = [item.subjectId, item.quizMode.toString()]; final whereArgs = [item.subjectId, item.quizMode.toString()];
if (item.readingType != null) { if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?'; where += ' AND ${DbConstants.readingTypeColumn} = ?';
@@ -148,7 +149,8 @@ class DeckRepository {
Future<void> updateSrsItem(SrsItem item) async { Future<void> updateSrsItem(SrsItem item) async {
final db = await DatabaseHelper().db; final db = await DatabaseHelper().db;
var where = '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?'; var where =
'${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?';
final whereArgs = [item.subjectId, item.quizMode.toString()]; final whereArgs = [item.subjectId, item.quizMode.toString()];
if (item.readingType != null) { if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?'; where += ' AND ${DbConstants.readingTypeColumn} = ?';

View File

@@ -5,14 +5,26 @@ import 'dart:math';
class DistractorGenerator { class DistractorGenerator {
final Random _rnd = Random(); final Random _rnd = Random();
List<String> generateMeanings(KanjiItem correct, List<KanjiItem> pool, int needed) { List<String> generateMeanings(
KanjiItem correct,
List<KanjiItem> pool,
int needed,
) {
final correctMeaning = correct.meanings.first; final correctMeaning = correct.meanings.first;
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final tokens = correctMeaning
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
final candidates = <String>[]; final candidates = <String>[];
for (final k in pool) { for (final k in pool) {
if (k.id == correct.id) continue; if (k.id == correct.id) continue;
for (final m in k.meanings) { for (final m in k.meanings) {
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) { if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m); candidates.add(m);
} }
@@ -39,8 +51,15 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateKanji(KanjiItem correct, List<KanjiItem> pool, int needed) { List<String> generateKanji(
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList(); KanjiItem correct,
List<KanjiItem> pool,
int needed,
) {
final others = pool
.map((k) => k.characters)
.where((c) => c != correct.characters)
.toList();
others.shuffle(_rnd); others.shuffle(_rnd);
final out = <String>[]; final out = <String>[];
for (final o in others) { for (final o in others) {
@@ -53,7 +72,11 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateReadings(String correct, List<KanjiItem> pool, int needed) { List<String> generateReadings(
String correct,
List<KanjiItem> pool,
int needed,
) {
final poolReadings = <String>[]; final poolReadings = <String>[];
for (final k in pool) { for (final k in pool) {
poolReadings.addAll(k.onyomi); poolReadings.addAll(k.onyomi);
@@ -72,14 +95,26 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateVocabMeanings(VocabularyItem correct, List<VocabularyItem> pool, int needed) { List<String> generateVocabMeanings(
VocabularyItem correct,
List<VocabularyItem> pool,
int needed,
) {
final correctMeaning = correct.meanings.first; final correctMeaning = correct.meanings.first;
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final tokens = correctMeaning
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
final candidates = <String>[]; final candidates = <String>[];
for (final k in pool) { for (final k in pool) {
if (k.id == correct.id) continue; if (k.id == correct.id) continue;
for (final m in k.meanings) { for (final m in k.meanings) {
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) { if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m); candidates.add(m);
} }
@@ -106,8 +141,15 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateVocab(VocabularyItem correct, List<VocabularyItem> pool, int needed) { List<String> generateVocab(
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList(); VocabularyItem correct,
List<VocabularyItem> pool,
int needed,
) {
final others = pool
.map((k) => k.characters)
.where((c) => c != correct.characters)
.toList();
others.shuffle(_rnd); others.shuffle(_rnd);
final out = <String>[]; final out = <String>[];
for (final o in others) { for (final o in others) {
@@ -121,4 +163,7 @@ class DistractorGenerator {
} }
} }
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(' ');

View File

@@ -26,7 +26,6 @@ class VocabDeckRepository {
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
Future<String?> loadApiKey() async { Future<String?> loadApiKey() async {
String? envApiKey; String? envApiKey;
try { try {

View File

@@ -38,7 +38,8 @@ extension CustomTheme on ThemeData {
level8: Color(0xFF4FC3F7), // light blue level8: Color(0xFF4FC3F7), // light blue
level9: Color(0xFF7986CB), // indigo level9: Color(0xFF7986CB), // indigo
); );
} else if (colorScheme.primary == const Color(0xFF7B6D53)) { // Nier theme } else if (colorScheme.primary == const Color(0xFF7B6D53)) {
// Nier theme
return const SrsColors( return const SrsColors(
level1: Color(0xFFB71C1C), // dark red level1: Color(0xFFB71C1C), // dark red
level2: Color(0xFFD84315), // deep orange level2: Color(0xFFD84315), // deep orange
@@ -50,7 +51,8 @@ extension CustomTheme on ThemeData {
level8: Color(0xFF0277BD), // light blue level8: Color(0xFF0277BD), // light blue
level9: Color(0xFF283593), // indigo level9: Color(0xFF283593), // indigo
); );
} else { // Light theme } else {
// Light theme
return const SrsColors( return const SrsColors(
level1: Colors.red, level1: Colors.red,
level2: Colors.orange, level2: Colors.orange,

View File

@@ -19,13 +19,19 @@ class KanjiCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final bgColor = backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface; final bgColor =
final fgColor = textColor ?? theme.textTheme.bodyMedium?.color ?? theme.colorScheme.onSurface; backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface;
final fgColor =
textColor ??
theme.textTheme.bodyMedium?.color ??
theme.colorScheme.onSurface;
return Card( return Card(
elevation: theme.cardTheme.elevation ?? 12, elevation: theme.cardTheme.elevation ?? 12,
color: bgColor, color: bgColor,
shape: theme.cardTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape:
theme.cardTheme.shape ??
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox( child: SizedBox(
width: 360, width: 360,
height: 240, height: 240,

View File

@@ -39,7 +39,11 @@ class OptionsGrid extends StatelessWidget {
Color currentTextColor = fg; Color currentTextColor = fg;
if (showResult) { if (showResult) {
if (correctAnswers != null && correctAnswers!.contains(o)) { final normalizedOption = o.trim().toLowerCase();
if (correctAnswers != null &&
correctAnswers!
.map((e) => e.trim().toLowerCase())
.contains(normalizedOption)) {
currentButtonColor = theme.colorScheme.tertiary; currentButtonColor = theme.colorScheme.tertiary;
} }
} }
@@ -51,6 +55,8 @@ class OptionsGrid extends StatelessWidget {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: currentButtonColor, backgroundColor: currentButtonColor,
foregroundColor: currentTextColor, foregroundColor: currentTextColor,
disabledBackgroundColor:
theme.colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -58,7 +64,9 @@ class OptionsGrid extends StatelessWidget {
), ),
child: Text( child: Text(
o, o,
style: theme.textTheme.titleMedium?.copyWith(color: currentTextColor), style: theme.textTheme.titleMedium?.copyWith(
color: currentTextColor,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),