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;
}
static Subject createSubjectFromMap(Map<String, dynamic> map) {
final String object = map['object'];
if (object == 'kanji') {

View File

@@ -35,4 +35,4 @@ abstract class Subject {
'data': data,
};
}
}
}

View File

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

View File

@@ -34,34 +34,37 @@ class CustomQuizScreen extends StatefulWidget {
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>
with TickerProviderStateMixin {
int _currentIndex = 0;
final _quizState = _CustomQuizState();
List<CustomKanjiItem> _shuffledDeck = [];
List<String> _options = [];
bool _answered = false;
bool? _correct;
int _sessionDeckSize = 0;
bool _isAnswering = false;
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = [];
final _audioPlayer = AudioPlayer();
bool _playIncorrectSound = true;
bool _playCorrectSound = true;
bool _playNarrator = true;
String? _selectedOption;
String? _correctAnswer;
bool _showResult = false;
@override
void initState() {
super.initState();
_shuffledDeck = widget.deck.toList()..shuffle();
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
_sessionDeckSize = _shuffledDeck.length;
_shakeController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
@@ -70,6 +73,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
);
_loadSettings();
_nextQuestion();
}
Future<void> _loadSettings() async {
@@ -90,163 +94,53 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.deck != oldWidget.deck && !widget.isActive) {
setState(() {
_shuffledDeck = widget.deck.toList()..shuffle();
_currentIndex = 0;
_answered = false;
_correct = null;
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
});
_shuffledDeck = widget.deck.toList()..shuffle();
_sessionDeckSize = _shuffledDeck.length;
_nextQuestion();
}
if (widget.useKanji != oldWidget.useKanji) {
setState(() {
_generateOptions();
});
_nextQuestion();
}
}
void playAudio() async {
final quizState = _quizState;
if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_currentIndex < _shuffledDeck.length && _playNarrator) {
quizState.current != null &&
_playNarrator) {
final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(_shuffledDeck[_currentIndex].characters);
await ttsService.speak(quizState.current!.characters);
}
}
@override
void dispose() {
_shakeController.dispose();
super.dispose();
}
void _generateOptions() {
final currentItem = _shuffledDeck[_currentIndex];
if (widget.quizMode == CustomQuizMode.listeningComprehension ||
widget.quizMode == CustomQuizMode.japaneseToEnglish) {
_options = [currentItem.meaning];
}
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;
void _answer(String option) async {
final quizState = _quizState;
final current = quizState.current!;
final isCorrect = quizState.correctAnswers
.map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim());
setState(() {
_selectedOption = answer;
_correctAnswer = correctAnswer;
_showResult = true;
quizState.selectedOption = option;
quizState.showResult = true;
_isAnswering = true;
});
int currentSrsLevel = 0; // Initialize with a default value
if (currentItem.useInterval) {
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);
if (current.useInterval) {
_updateSrsLevel(current, isCorrect);
}
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters)
: currentItem.meaning;
? (widget.useKanji && current.kanji != null
? current.kanji!
: current.characters)
: current.meaning;
final snack = SnackBar(
content: Text(
@@ -266,52 +160,23 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
}
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;
quizState.asked += 1;
if (!quizState.wrongItems.contains(current.characters)) {
quizState.score += 1;
}
if (_playCorrectSound && !_playNarrator) {
await _audioPlayer.play(AssetSource('sfx/correct.wav'));
} else if (_playNarrator) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.englishToJapanese) {
await _speak(currentItem.characters);
await _speak(current.characters);
}
}
await Future.delayed(const Duration(milliseconds: 500));
} 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;
}
quizState.wrongItems.add(current.characters);
_shuffledDeck.add(current);
_shuffledDeck.shuffle();
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
@@ -319,24 +184,132 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
await Future.delayed(const Duration(milliseconds: 900));
}
_nextQuestion();
}
Future<void> _nextQuestion() async {
setState(() {
_currentIndex++;
_answered = false;
_correct = null;
_selectedOption = null;
_correctAnswer = null;
_showResult = false;
if (_currentIndex < _shuffledDeck.length) {
_generateOptions();
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});
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) {
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) {
if (!(_answered && _correct!)) {
_checkAnswer(option);
if (!_isAnswering) {
_answer(option);
}
}
@override
Widget build(BuildContext context) {
if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) {
return Center(child: Text('Review session complete!', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
final quizState = _quizState;
if (quizState.current == null) {
return Center(
child: Text(
'Review session complete!',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
);
}
final currentItem = _shuffledDeck[_currentIndex];
final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning
: (widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters);
final currentItem = quizState.current!;
Widget promptWidget;
String subtitle = '';
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
promptWidget = IconButton(
icon: const Icon(Icons.volume_up, size: 64),
@@ -372,25 +349,60 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
);
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
promptWidget = Text(
question,
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface),
currentItem.meaning,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
);
} else {
final promptText = widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters;
promptWidget = GestureDetector(
onTap: () => _speak(question),
onTap: () => _speak(promptText),
child: Text(
question,
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface),
promptText,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
);
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column(
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),
Expanded(
flex: 3,
@@ -401,7 +413,10 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(characterWidget: promptWidget, subtitle: ''),
child: KanjiCard(
characterWidget: promptWidget,
subtitle: subtitle,
),
),
),
),
@@ -419,11 +434,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
);
},
child: OptionsGrid(
options: _options,
onSelected: _onOptionSelected,
correctAnswers: [],
showResult: false,
isDisabled: false,
options: quizState.options,
onSelected: _isAnswering ? (option) {} : _onOptionSelected,
selectedOption: quizState.selectedOption,
correctAnswers: quizState.correctAnswers,
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) {
final allSrsItems = items.expand((item) => item.srsItems.values).toList();
if (allSrsItems.isNotEmpty && allSrsItems.every((srs) => srs.disabled)) {
final allSrsItems = items
.expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
@@ -144,9 +147,11 @@ class _HomeScreenState extends State<HomeScreen>
if (mode == QuizMode.reading) {
final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi'];
final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi'];
final hasOnyomi = item.onyomi.isNotEmpty &&
final hasOnyomi =
item.onyomi.isNotEmpty &&
(onyomiSrs == null || !onyomiSrs.disabled);
final hasKunyomi = item.kunyomi.isNotEmpty &&
final hasKunyomi =
item.kunyomi.isNotEmpty &&
(kunyomiSrs == null || !kunyomiSrs.disabled);
return hasOnyomi || hasKunyomi;
}
@@ -289,8 +294,9 @@ class _HomeScreenState extends State<HomeScreen>
String readingType = '';
if (mode == QuizMode.reading) {
readingType =
quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi';
readingType = quizState.readingHint.contains("on'yomi")
? 'onyomi'
: 'kunyomi';
}
final srsKey = mode.toString() + readingType;
@@ -341,8 +347,8 @@ class _HomeScreenState extends State<HomeScreen>
final correctDisplay = (mode == QuizMode.kanjiToEnglish)
? _toTitleCase(quizState.correctAnswers.first)
: (mode == QuizMode.reading
? quizState.correctAnswers.join(', ')
: quizState.correctAnswers.first);
? quizState.correctAnswers.join(', ')
: quizState.correctAnswers.first);
final snack = SnackBar(
content: Text(
@@ -449,7 +455,9 @@ class _HomeScreenState extends State<HomeScreen>
child: Text(
_status,
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
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
Theme.of(context).colorScheme.primary,
),
),
],
),

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_kanji_item.dart';
@@ -29,7 +28,9 @@ class CustomDeckRepository {
Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async {
final deck = await getCustomDeck();
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) {
deck[index] = item;
}

View File

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

View File

@@ -52,8 +52,12 @@ class DatabaseHelper {
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) {
await db.execute('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');
await db.execute(
'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 batch = db.batch();
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()];
if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?';
@@ -148,7 +149,8 @@ class DeckRepository {
Future<void> updateSrsItem(SrsItem item) async {
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()];
if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?';

View File

@@ -5,14 +5,26 @@ import 'dart:math';
class DistractorGenerator {
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 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>[];
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();
final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m);
}
@@ -39,8 +51,15 @@ class DistractorGenerator {
return out;
}
List<String> generateKanji(KanjiItem correct, List<KanjiItem> pool, int needed) {
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList();
List<String> generateKanji(
KanjiItem correct,
List<KanjiItem> 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) {
@@ -53,7 +72,11 @@ class DistractorGenerator {
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>[];
for (final k in pool) {
poolReadings.addAll(k.onyomi);
@@ -72,14 +95,26 @@ class DistractorGenerator {
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 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>[];
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();
final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m);
}
@@ -106,8 +141,15 @@ class DistractorGenerator {
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();
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) {
@@ -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);
}
Future<String?> loadApiKey() async {
String? envApiKey;
try {

View File

@@ -38,7 +38,8 @@ extension CustomTheme on ThemeData {
level8: Color(0xFF4FC3F7), // light blue
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(
level1: Color(0xFFB71C1C), // dark red
level2: Color(0xFFD84315), // deep orange
@@ -50,7 +51,8 @@ extension CustomTheme on ThemeData {
level8: Color(0xFF0277BD), // light blue
level9: Color(0xFF283593), // indigo
);
} else { // Light theme
} else {
// Light theme
return const SrsColors(
level1: Colors.red,
level2: Colors.orange,

View File

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

View File

@@ -39,7 +39,11 @@ class OptionsGrid extends StatelessWidget {
Color currentTextColor = fg;
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;
}
}
@@ -51,6 +55,8 @@ class OptionsGrid extends StatelessWidget {
style: ElevatedButton.styleFrom(
backgroundColor: currentButtonColor,
foregroundColor: currentTextColor,
disabledBackgroundColor:
theme.colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -58,7 +64,9 @@ class OptionsGrid extends StatelessWidget {
),
child: Text(
o,
style: theme.textTheme.titleMedium?.copyWith(color: currentTextColor),
style: theme.textTheme.titleMedium?.copyWith(
color: currentTextColor,
),
textAlign: TextAlign.center,
),
),
@@ -66,4 +74,4 @@ class OptionsGrid extends StatelessWidget {
}).toList(),
);
}
}
}