5 Commits

Author SHA1 Message Date
Rene Kievits
14ad880f79 fix error with browser view and levels 2025-11-02 19:53:10 +01:00
Rene Kievits
5f1b9ba12e finish v3 2025-11-02 19:00:17 +01:00
Rene Kievits
16da0f04ac possible to exclude levels and change how questions are served 2025-11-02 17:07:23 +01:00
Rene Kievits
e9f115a32a more cleanup and small fixes, new sound effects 2025-11-01 07:50:21 +01:00
Rene Kievits
d5ff5eb12f some cleanup and some fixes 2025-11-01 06:38:14 +01:00
25 changed files with 1126 additions and 468 deletions

Binary file not shown.

BIN
assets/sfx/correct.wav Normal file

Binary file not shown.

BIN
assets/sfx/incorrect.wav Normal file

Binary file not shown.

View File

@@ -5,12 +5,12 @@ import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'src/services/deck_repository.dart'; import 'src/services/deck_repository.dart';
import 'src/screens/start_screen.dart'; import 'src/screens/start_screen.dart';
import 'src/services/tts_service.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
try { try {
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");
// No need to catch because the file is only needed for dev
} catch (_) {} } catch (_) {}
runApp( runApp(
@@ -19,6 +19,14 @@ void main() async {
Provider<DeckRepository>(create: (_) => DeckRepository()), Provider<DeckRepository>(create: (_) => DeckRepository()),
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()), Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()), ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()),
Provider<TtsService>(
create: (_) {
final ttsService = TtsService();
ttsService.initTts();
return ttsService;
},
dispose: (_, ttsService) => ttsService.dispose(),
),
], ],
child: const WkApp(), child: const WkApp(),
), ),

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

@@ -6,6 +6,7 @@ class SrsItem {
final String? readingType; final String? readingType;
int srsStage; int srsStage;
DateTime lastAsked; DateTime lastAsked;
bool disabled;
SrsItem({ SrsItem({
required this.subjectId, required this.subjectId,
@@ -13,5 +14,6 @@ class SrsItem {
this.readingType, this.readingType,
this.srsStage = 0, this.srsStage = 0,
DateTime? lastAsked, DateTime? lastAsked,
this.disabled = false,
}) : lastAsked = lastAsked ?? DateTime.now(); }) : lastAsked = lastAsked ?? DateTime.now();
} }

View File

@@ -18,11 +18,14 @@ class _AddCardScreenState extends State<AddCardScreen> {
final _kanaKit = const KanaKit(); final _kanaKit = const KanaKit();
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
bool _useInterval = false; bool _useInterval = false;
late FocusNode _japaneseFocusNode;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_japaneseController.addListener(_convertToKana); _japaneseController.addListener(_convertToKana);
_japaneseFocusNode = FocusNode();
_japaneseFocusNode.addListener(_onJapaneseFocusChange);
} }
@override @override
@@ -31,13 +34,24 @@ class _AddCardScreenState extends State<AddCardScreen> {
_japaneseController.dispose(); _japaneseController.dispose();
_englishController.dispose(); _englishController.dispose();
_kanjiController.dispose(); _kanjiController.dispose();
_japaneseFocusNode.removeListener(_onJapaneseFocusChange);
_japaneseFocusNode.dispose();
super.dispose(); super.dispose();
} }
void _convertToKana() { void _convertToKana() {
final text = _japaneseController.text; final text = _japaneseController.text;
final selection = _japaneseController.selection;
final offset = selection.baseOffset;
if ((offset > 1 && text[offset - 1] == 'n' && text[offset - 2] != 'n') ||
(offset == 1 && text[offset - 1] == 'n')) {
return;
}
final converted = _kanaKit.toKana(text); final converted = _kanaKit.toKana(text);
if (text != converted) {
if (converted != text) {
_japaneseController.value = _japaneseController.value.copyWith( _japaneseController.value = _japaneseController.value.copyWith(
text: converted, text: converted,
selection: TextSelection.fromPosition( selection: TextSelection.fromPosition(
@@ -47,6 +61,21 @@ class _AddCardScreenState extends State<AddCardScreen> {
} }
} }
void _onJapaneseFocusChange() {
if (!_japaneseFocusNode.hasFocus) {
_forceNConversion();
}
}
void _forceNConversion() {
final text = _japaneseController.text;
if (text.isNotEmpty &&
text.endsWith('n') &&
_kanaKit.toKana(text) != text) {
_japaneseController.text = _kanaKit.toKana(text);
}
}
void _saveCard() { void _saveCard() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
final srsData = _useInterval final srsData = _useInterval
@@ -83,6 +112,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _japaneseController, controller: _japaneseController,
focusNode: _japaneseFocusNode,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Japanese (Kana)', labelText: 'Japanese (Kana)',
hintText: 'Enter Japanese vocabulary or kanji', hintText: 'Enter Japanese vocabulary or kanji',

View File

@@ -123,9 +123,14 @@ class _BrowseScreenState extends State<BrowseScreen>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircularProgressIndicator(color: Theme.of(context).colorScheme.primary), CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), Text(
_status,
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
], ],
), ),
); );
@@ -151,6 +156,7 @@ class _BrowseScreenState extends State<BrowseScreen>
List<int> sortedLevels, List<int> sortedLevels,
PageController pageController, PageController pageController,
Widget Function(List<dynamic>) buildPageContent, Widget Function(List<dynamic>) buildPageContent,
dynamic repository,
) { ) {
if (sortedLevels.isEmpty) { if (sortedLevels.isEmpty) {
return Center( return Center(
@@ -167,18 +173,45 @@ class _BrowseScreenState extends State<BrowseScreen>
itemBuilder: (context, index) { itemBuilder: (context, index) {
final level = sortedLevels[index]; final level = sortedLevels[index];
final levelItems = groupedItems[level]!; final levelItems = groupedItems[level]!;
final bool isDisabled;
if (repository is DeckRepository) {
isDisabled = levelItems.every(
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every(
(srs) => srs.disabled,
),
);
} else if (repository is VocabDeckRepository) {
isDisabled = levelItems.every(
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every(
(srs) => srs.disabled,
),
);
} else {
isDisabled = false; // Default to false if repository type is unknown
}
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Row(
'Level $level', mainAxisAlignment: MainAxisAlignment.center,
style: TextStyle( children: [
fontSize: 24, Text(
color: Theme.of(context).colorScheme.onSurface, 'Level $level',
fontWeight: FontWeight.bold, style: TextStyle(
), fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
Checkbox(
value: !isDisabled,
onChanged: (value) {
_toggleLevelExclusion(level, repository, index, pageController);
},
),
],
), ),
), ),
Expanded(child: buildPageContent(levelItems)), Expanded(child: buildPageContent(levelItems)),
@@ -199,37 +232,67 @@ class _BrowseScreenState extends State<BrowseScreen>
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
height: 60, child: Row(
child: SingleChildScrollView( mainAxisAlignment: MainAxisAlignment.spaceEvenly,
scrollDirection: Axis.horizontal, children: List.generate(levels.length, (index) {
child: Row( final level = levels[index];
mainAxisAlignment: MainAxisAlignment.center, final isSelected = index == currentPage;
children: List.generate(levels.length, (index) { final items = isKanji ? _kanjiByLevel[level] : _vocabByLevel[level];
final level = levels[index]; final bool isDisabled;
final isSelected = index == currentPage; if (isKanji) {
return Padding( isDisabled = items?.every(
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every(
(srs) => srs.disabled,
), ) ??
false;
} else {
isDisabled = items?.every(
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every(
(srs) => srs.disabled,
), ) ??
false;
}
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
controller.animateToPage( controller.animateToPage(
index, index,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isSelected backgroundColor: isSelected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: isDisabled
? Theme.of(context).colorScheme.surfaceContainerHighest
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
shape: const CircleBorder(), foregroundColor: isSelected
? Theme.of(context).colorScheme.onPrimary
: isDisabled
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
), ),
child: Text(level.toString()), child: Text(level.toString()),
), ),
); ),
}), );
), }),
), ),
); );
} }
@@ -296,7 +359,10 @@ class _BrowseScreenState extends State<BrowseScreen>
Expanded( Expanded(
child: Text( child: Text(
item.characters, item.characters,
style: TextStyle(fontSize: 24, color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -307,7 +373,9 @@ class _BrowseScreenState extends State<BrowseScreen>
children: [ children: [
Text( Text(
item.meanings.join(', '), item.meanings.join(', '),
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -356,7 +424,10 @@ class _BrowseScreenState extends State<BrowseScreen>
children: [ children: [
Text( Text(
item.characters, item.characters,
style: TextStyle(fontSize: 32, color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
fontSize: 32,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -376,7 +447,9 @@ class _BrowseScreenState extends State<BrowseScreen>
height: 10, height: 10,
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: level / 9.0, value: level / 9.0,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
_getColorForSrsLevel(level), _getColorForSrsLevel(level),
), ),
@@ -445,29 +518,39 @@ class _BrowseScreenState extends State<BrowseScreen>
children: [ children: [
Text( Text(
'Level: ${kanji.level}', 'Level: ${kanji.level}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (kanji.meanings.isNotEmpty) if (kanji.meanings.isNotEmpty)
Text( Text(
'Meanings: ${kanji.meanings.join(', ')}', 'Meanings: ${kanji.meanings.join(', ')}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (kanji.onyomi.isNotEmpty) if (kanji.onyomi.isNotEmpty)
Text( Text(
'On\'yomi: ${kanji.onyomi.join(', ')}', 'On\'yomi: ${kanji.onyomi.join(', ')}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
if (kanji.kunyomi.isNotEmpty) if (kanji.kunyomi.isNotEmpty)
Text( Text(
'Kun\'yomi: ${kanji.kunyomi.join(', ')}', 'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
Text( Text(
'No readings available.', 'No readings available.',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
@@ -482,7 +565,9 @@ class _BrowseScreenState extends State<BrowseScreen>
...srsScores.entries.map( ...srsScores.entries.map(
(entry) => Text( (entry) => Text(
' ${entry.key}: ${entry.value}', ' ${entry.key}: ${entry.value}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
), ),
], ],
@@ -569,6 +654,106 @@ class _BrowseScreenState extends State<BrowseScreen>
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort(); _vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
} }
Future<void> _toggleLevelExclusion(
int level,
dynamic repository,
int currentPageIndex,
PageController pageController,
) async {
final List<SrsItem> itemsToUpdate = [];
List<dynamic> items = [];
bool currentlyDisabled = false;
if (repository is DeckRepository) {
items = _kanjiByLevel[level] ?? [];
currentlyDisabled = items.every(
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every((srs) => srs.disabled),
);
for (final item in items) {
for (final srsItem in item.srsItems.values) {
itemsToUpdate.add(srsItem);
}
}
} else if (repository is VocabDeckRepository) {
items = _vocabByLevel[level] ?? [];
currentlyDisabled = items.every(
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every((srs) => srs.disabled),
);
for (final item in items) {
for (final srsItem in item.srsItems.values) {
itemsToUpdate.add(srsItem);
}
}
} else {
return;
}
if (itemsToUpdate.isEmpty) {
// No SrsItems exist for this level, so create them and set their disabled status
for (final item in items) { // 'items' contains KanjiItem or VocabularyItem
// Determine quiz modes based on repository type
List<QuizMode> quizModes = [];
if (repository is DeckRepository) {
quizModes = [QuizMode.kanjiToEnglish, QuizMode.englishToKanji, QuizMode.reading];
} else if (repository is VocabDeckRepository) {
quizModes = [QuizMode.vocabToEnglish, QuizMode.englishToVocab, QuizMode.audioToEnglish];
}
for (final mode in quizModes) {
String? readingType;
if (mode == QuizMode.reading && repository is DeckRepository) {
// For reading mode, create SrsItems for both onyomi and kunyomi if they exist
if ((item as KanjiItem).onyomi.isNotEmpty) {
readingType = 'onyomi';
itemsToUpdate.add(SrsItem(
subjectId: item.id,
quizMode: mode,
readingType: readingType,
disabled: !currentlyDisabled,
));
}
if ((item as KanjiItem).kunyomi.isNotEmpty) {
readingType = 'kunyomi';
itemsToUpdate.add(SrsItem(
subjectId: item.id,
quizMode: mode,
readingType: readingType,
disabled: !currentlyDisabled,
));
}
} else {
itemsToUpdate.add(SrsItem(
subjectId: item.id,
quizMode: mode,
disabled: !currentlyDisabled,
));
}
}
}
// Now insert these newly created SrsItems
if (repository is DeckRepository) {
for (final srsItem in itemsToUpdate) {
await (repository as DeckRepository).insertSrsItem(srsItem);
}
} else if (repository is VocabDeckRepository) {
for (final srsItem in itemsToUpdate) {
await (repository as VocabDeckRepository).insertVocabSrsItem(srsItem);
}
}
} else {
// Existing SrsItems, so update them
for (final item in itemsToUpdate) {
item.disabled = !currentlyDisabled;
}
await repository.updateSrsItems(itemsToUpdate);
}
setState(() {});
if (pageController.hasClients) {
pageController.jumpToPage(currentPageIndex);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -587,6 +772,7 @@ class _BrowseScreenState extends State<BrowseScreen>
_kanjiSortedLevels, _kanjiSortedLevels,
_kanjiPageController, _kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>()), (items) => _buildGridView(items.cast<KanjiItem>()),
Provider.of<DeckRepository>(context, listen: false),
), ),
), ),
_buildWaniKaniTab( _buildWaniKaniTab(
@@ -595,6 +781,7 @@ class _BrowseScreenState extends State<BrowseScreen>
_vocabSortedLevels, _vocabSortedLevels,
_vocabPageController, _vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>()), (items) => _buildListView(items.cast<VocabularyItem>()),
Provider.of<VocabDeckRepository>(context, listen: false),
), ),
), ),
_buildCustomSrsTab(), _buildCustomSrsTab(),
@@ -674,8 +861,7 @@ class _BrowseScreenState extends State<BrowseScreen>
setState(() { setState(() {
if (_selectedItems.length == _customDeck.length) { if (_selectedItems.length == _customDeck.length) {
_selectedItems.clear(); _selectedItems.clear();
} } else {
else {
_selectedItems = List.from(_customDeck); _selectedItems = List.from(_customDeck);
} }
}); });
@@ -800,12 +986,17 @@ class _BrowseScreenState extends State<BrowseScreen>
child: Card( child: Card(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
side: isSelected side: isSelected
? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0) ? BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2.0,
)
: BorderSide.none, : BorderSide.none,
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
), ),
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary.withAlpha((255 * 0.5).round()) ? Theme.of(
context,
).colorScheme.primary.withAlpha((255 * 0.5).round())
: Theme.of(context).colorScheme.surfaceContainer, : Theme.of(context).colorScheme.surfaceContainer,
child: Stack( child: Stack(
children: [ children: [
@@ -855,7 +1046,11 @@ class _BrowseScreenState extends State<BrowseScreen>
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
child: Icon(Icons.timer, color: Theme.of(context).colorScheme.tertiary, size: 16), child: Icon(
Icons.timer,
color: Theme.of(context).colorScheme.tertiary,
size: 16,
),
), ),
], ],
), ),
@@ -869,8 +1064,9 @@ class _BrowseScreenState extends State<BrowseScreen>
class _VocabDetailsDialog extends StatefulWidget { class _VocabDetailsDialog extends StatefulWidget {
final VocabularyItem vocab; final VocabularyItem vocab;
final ThemeData theme;
const _VocabDetailsDialog({required this.vocab}); const _VocabDetailsDialog({required this.vocab, required this.theme});
@override @override
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState(); State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
@@ -886,7 +1082,6 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
} }
Future<void> _fetchExampleSentences() async { Future<void> _fetchExampleSentences() async {
final theme = Theme.of(context);
try { try {
final uri = Uri.parse( final uri = Uri.parse(
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}', 'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}',
@@ -913,11 +1108,15 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
children: [ children: [
Text( Text(
japaneseWord, japaneseWord,
style: TextStyle(color: theme.colorScheme.onSurface), style: TextStyle(
color: widget.theme.colorScheme.onSurface,
),
), ),
Text( Text(
englishDefinition, englishDefinition,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant), style: TextStyle(
color: widget.theme.colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@@ -931,7 +1130,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
sentences.add( sentences.add(
Text( Text(
'No example sentences found.', 'No example sentences found.',
style: TextStyle(color: theme.colorScheme.onSurface), style: TextStyle(color: widget.theme.colorScheme.onSurface),
), ),
); );
} }
@@ -946,7 +1145,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
_exampleSentences = [ _exampleSentences = [
Text( Text(
'Failed to load example sentences.', 'Failed to load example sentences.',
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: widget.theme.colorScheme.error),
), ),
]; ];
}); });
@@ -958,7 +1157,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
_exampleSentences = [ _exampleSentences = [
Text( Text(
'Error loading example sentences.', 'Error loading example sentences.',
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: widget.theme.colorScheme.error),
), ),
]; ];
}); });
@@ -994,39 +1193,45 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
children: [ children: [
Text( Text(
'Level: ${widget.vocab.level}', 'Level: ${widget.vocab.level}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: widget.theme.colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (widget.vocab.meanings.isNotEmpty) if (widget.vocab.meanings.isNotEmpty)
Text( Text(
'Meanings: ${widget.vocab.meanings.join(', ')}', 'Meanings: ${widget.vocab.meanings.join(', ')}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: widget.theme.colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (widget.vocab.readings.isNotEmpty) if (widget.vocab.readings.isNotEmpty)
Text( Text(
'Readings: ${widget.vocab.readings.join(', ')}', 'Readings: ${widget.vocab.readings.join(', ')}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: widget.theme.colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), Divider(color: widget.theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'SRS Scores:', 'SRS Scores:',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), style: TextStyle(
color: widget.theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
), ),
...srsScores.entries.map( ...srsScores.entries.map(
(entry) => Text( (entry) => Text(
' ${entry.key}: ${entry.value}', ' ${entry.key}: ${entry.value}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: widget.theme.colorScheme.onSurface),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), Divider(color: widget.theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Example Sentences:', 'Example Sentences:',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), style: TextStyle(
color: widget.theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
), ),
..._exampleSentences, ..._exampleSentences,
], ],
@@ -1036,22 +1241,23 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
} }
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) { void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
final currentTheme = Theme.of(context);
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: currentTheme.colorScheme.surfaceContainer,
title: Text( title: Text(
'Details for ${vocab.characters}', 'Details for ${vocab.characters}',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: currentTheme.colorScheme.onSurface),
), ),
content: _VocabDetailsDialog(vocab: vocab), content: _VocabDetailsDialog(vocab: vocab, theme: currentTheme),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: Text( child: Text(
'Close', 'Close',
style: TextStyle(color: Theme.of(context).colorScheme.primary), style: TextStyle(color: currentTheme.colorScheme.primary),
), ),
), ),
], ],
@@ -1059,4 +1265,3 @@ void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
}, },
); );
} }

View File

@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math'; import 'dart:math';
import 'package:flutter_tts/flutter_tts.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
import 'package:provider/provider.dart';
import '../services/tts_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
enum CustomQuizMode { enum CustomQuizMode {
japaneseToEnglish, japaneseToEnglish,
@@ -31,27 +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 FlutterTts _flutterTts;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = []; final _audioPlayer = AudioPlayer();
bool _playIncorrectSound = true;
bool _playCorrectSound = true;
bool _playNarrator = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_shuffledDeck = widget.deck.toList()..shuffle(); _shuffledDeck = widget.deck.toList()..shuffle();
_initTts(); _sessionDeckSize = _shuffledDeck.length;
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
_shakeController = AnimationController( _shakeController = AnimationController(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
vsync: this, vsync: this,
@@ -59,168 +72,83 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate( _shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn), CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
); );
_loadSettings();
_nextQuestion();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
} }
@override @override
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() { void playAudio() async {
final quizState = _quizState;
if (widget.quizMode == CustomQuizMode.listeningComprehension && if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_currentIndex < _shuffledDeck.length) { quizState.current != null &&
_speak(_shuffledDeck[_currentIndex].characters); _playNarrator) {
final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(quizState.current!.characters);
} }
} }
void _initTts() async {
_flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP");
}
@override @override
void dispose() { void dispose() {
_flutterTts.stop();
_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())
} else { .contains(option.toLowerCase().trim());
_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,
);
}
}
_options.shuffle();
}
void _checkAnswer(String answer) async { setState(() {
final currentItem = _shuffledDeck[_currentIndex]; quizState.selectedOption = option;
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) quizState.showResult = true;
? (widget.useKanji && currentItem.kanji != null _isAnswering = true;
? currentItem.kanji! });
: currentItem.characters)
: currentItem.meaning;
final isCorrect = answer == correctAnswer;
if (currentItem.useInterval) { if (current.useInterval) {
int currentSrsLevel; _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(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error, color: isCorrect
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -232,76 +160,249 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
if (isCorrect) { if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { quizState.asked += 1;
await _speak(currentItem.characters); 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(current.characters);
}
} }
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} else { } else {
quizState.wrongItems.add(current.characters);
_shuffledDeck.add(current);
_shuffledDeck.shuffle();
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
_shakeController.forward(from: 0); _shakeController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 900)); await Future.delayed(const Duration(milliseconds: 900));
} }
_nextQuestion(); Future.delayed(const Duration(milliseconds: 900), () {
} if (mounted) {
_nextQuestion();
void _nextQuestion() {
setState(() {
_currentIndex++;
_answered = false;
_correct = null;
if (_currentIndex < _shuffledDeck.length) {
_generateOptions();
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
} }
}); });
} }
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) {
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);
}
}
Future<void> _speak(String text) async { Future<void> _speak(String text) async {
await _flutterTts.speak(text); final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(text);
} }
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),
onPressed: () => _speak(currentItem.characters), onPressed: () => _speak(currentItem.characters),
); );
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
promptWidget = Text(
currentItem.meaning,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
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,
@@ -312,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,
),
), ),
), ),
), ),
@@ -330,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

@@ -29,6 +29,7 @@ class _QuizState {
Key key = UniqueKey(); Key key = UniqueKey();
String? selectedOption; String? selectedOption;
bool showResult = false; bool showResult = false;
Set<int> wrongItems = {};
} }
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@@ -52,8 +53,11 @@ class _HomeScreenState extends State<HomeScreen>
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
final _sessionDecks = <int, List<KanjiItem>>{};
final _sessionDeckSizes = <int, int>{};
_QuizState get _currentQuizState => _quizStates[_tabController.index]; _QuizState get _currentQuizState => _quizStates[_tabController.index];
bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@@ -78,6 +82,7 @@ class _HomeScreenState extends State<HomeScreen>
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
}); });
} }
@@ -116,6 +121,49 @@ class _HomeScreenState extends State<HomeScreen>
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
final disabledLevels = <int>{};
final itemsByLevel = <int, List<KanjiItem>>{};
for (final item in _deck) {
(itemsByLevel[item.level] ??= []).add(item);
}
itemsByLevel.forEach((level, items) {
final allSrsItems = items
.expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
for (var i = 0; i < _tabController.length; i++) {
final mode = _modeForIndex(i);
final filteredDeck = _deck.where((item) {
if (disabledLevels.contains(item.level)) {
return false;
}
if (mode == QuizMode.reading) {
final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi'];
final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi'];
final hasOnyomi =
item.onyomi.isNotEmpty &&
(onyomiSrs == null || !onyomiSrs.disabled);
final hasKunyomi =
item.kunyomi.isNotEmpty &&
(kunyomiSrs == null || !kunyomiSrs.disabled);
return hasOnyomi || hasKunyomi;
}
final srsItem = item.srsItems[mode.toString()];
return srsItem == null || !srsItem.disabled;
}).toList();
filteredDeck.shuffle(_random);
_sessionDecks[i] = filteredDeck;
_sessionDeckSizes[i] = filteredDeck.length;
}
for (var i = 0; i < _tabController.length; i++) { for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i); _nextQuestion(i);
} }
@@ -162,61 +210,20 @@ class _HomeScreenState extends State<HomeScreen>
} }
void _nextQuestion([int? index]) { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; final tabIndex = index ?? _tabController.index;
final quizState = _quizStates[tabIndex];
final sessionDeck = _sessionDecks[tabIndex];
final mode = _modeForIndex(tabIndex);
final quizState = _quizStates[index ?? _tabController.index]; if (sessionDeck == null || sessionDeck.isEmpty) {
final mode = _modeForIndex(index ?? _tabController.index); setState(() {
quizState.current = null;
_status = 'Quiz complete!';
});
return;
}
_deck.sort((a, b) { quizState.current = sessionDeck.removeAt(0);
int getSrsStage(KanjiItem item) {
if (mode == QuizMode.reading) {
final onyomiStage =
item.srsItems['${QuizMode.reading}onyomi']?.srsStage;
final kunyomiStage =
item.srsItems['${QuizMode.reading}kunyomi']?.srsStage;
if (onyomiStage != null && kunyomiStage != null) {
return min(onyomiStage, kunyomiStage);
}
return onyomiStage ?? kunyomiStage ?? 0;
}
return item.srsItems[mode.toString()]?.srsStage ?? 0;
}
DateTime getLastAsked(KanjiItem item) {
if (mode == QuizMode.reading) {
final onyomiLastAsked =
item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
final kunyomiLastAsked =
item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
if (onyomiLastAsked != null && kunyomiLastAsked != null) {
return onyomiLastAsked.isBefore(kunyomiLastAsked)
? onyomiLastAsked
: kunyomiLastAsked;
}
return onyomiLastAsked ??
kunyomiLastAsked ??
DateTime.fromMillisecondsSinceEpoch(0);
}
return item.srsItems[mode.toString()]?.lastAsked ??
DateTime.fromMillisecondsSinceEpoch(0);
}
final aStage = getSrsStage(a);
final bStage = getSrsStage(b);
if (aStage != bStage) {
return aStage.compareTo(bStage);
}
final aLastAsked = getLastAsked(a);
final bLastAsked = getLastAsked(b);
return aLastAsked.compareTo(bLastAsked);
});
quizState.current = _deck.first;
quizState.key = UniqueKey(); quizState.key = UniqueKey();
quizState.correctAnswers = []; quizState.correctAnswers = [];
@@ -265,8 +272,6 @@ class _HomeScreenState extends State<HomeScreen>
])..shuffle(); ])..shuffle();
break; break;
default: default:
// Handle other QuizMode cases if necessary, or throw an error
// if these modes are not expected in this context.
break; break;
} }
@@ -284,6 +289,8 @@ class _HomeScreenState extends State<HomeScreen>
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<DeckRepository>(context, listen: false);
final current = quizState.current!; final current = quizState.current!;
final tabIndex = _tabController.index;
final sessionDeck = _sessionDecks[tabIndex]!;
String readingType = ''; String readingType = '';
if (mode == QuizMode.reading) { if (mode == QuizMode.reading) {
@@ -301,8 +308,6 @@ class _HomeScreenState extends State<HomeScreen>
readingType: readingType, readingType: readingType,
); );
quizState.asked += 1;
quizState.selectedOption = option; quizState.selectedOption = option;
quizState.showResult = true; quizState.showResult = true;
@@ -310,13 +315,22 @@ class _HomeScreenState extends State<HomeScreen>
setState(() {}); setState(() {});
if (isCorrect) { if (isCorrect) {
quizState.score += 1; quizState.asked += 1;
if (!quizState.wrongItems.contains(current.id)) {
quizState.score += 1;
}
srsItemForUpdate.srsStage += 1; srsItemForUpdate.srsStage += 1;
if (_playCorrectSound) { if (_playCorrectSound) {
_audioPlayer.play(AssetSource('sfx/confirm.mp3')); _audioPlayer.play(AssetSource('sfx/correct.wav'));
} }
} else { } else {
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1); srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
sessionDeck.add(current);
sessionDeck.shuffle(_random);
quizState.wrongItems.add(current.id);
if (_playIncorrectSound) {
_audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
} }
srsItemForUpdate.lastAsked = DateTime.now(); srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate; current.srsItems[srsKey] = srsItemForUpdate;
@@ -395,6 +409,23 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
if (_loading) {
return Scaffold(
appBar: AppBar(
title: const Text('Kanji Quiz'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Kanji→English'),
Tab(text: 'English→Kanji'),
Tab(text: 'Reading'),
],
),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Kanji Quiz'), title: const Text('Kanji Quiz'),
@@ -419,6 +450,18 @@ class _HomeScreenState extends State<HomeScreen>
final quizState = _quizStates[index]; final quizState = _quizStates[index];
final mode = _modeForIndex(index); final mode = _modeForIndex(index);
if (quizState.current == null) {
return Center(
child: Text(
_status,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
String prompt = ''; String prompt = '';
String subtitle = ''; String subtitle = '';
@@ -435,8 +478,6 @@ class _HomeScreenState extends State<HomeScreen>
subtitle = quizState.readingHint; subtitle = quizState.readingHint;
break; break;
default: default:
// Handle other QuizMode cases if necessary, or throw an error
// if these modes are not expected in this context.
break; break;
} }
} }
@@ -446,20 +487,29 @@ class _HomeScreenState extends State<HomeScreen>
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
Row( Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Text(
child: Text( '${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
_status, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface, fontSize: 18,
), fontWeight: FontWeight.bold,
), ),
), ),
if (_loading) const SizedBox(height: 4),
CircularProgressIndicator( LinearProgressIndicator(
color: Theme.of(context).colorScheme.primary, value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
), ),
),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -489,10 +539,9 @@ class _HomeScreenState extends State<HomeScreen>
OptionsGrid( OptionsGrid(
options: quizState.options, options: quizState.options,
onSelected: _isAnswering ? (option) {} : _answer, onSelected: _isAnswering ? (option) {} : _answer,
isDisabled: false, showResult: quizState.showResult,
selectedOption: null, selectedOption: quizState.selectedOption,
correctAnswers: [], correctAnswers: quizState.correctAnswers,
showResult: false,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(

View File

@@ -15,8 +15,9 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _apiKeyController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController();
bool _playAudio = true; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _playNarrator = true;
@override @override
void dispose() { void dispose() {
@@ -39,8 +40,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playAudio = prefs.getBool('playAudio') ?? true; _playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
}); });
} }
@@ -108,17 +110,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
SwitchListTile( SwitchListTile(
title: Text( title: Text(
'Play audio for vocabulary', 'Play incorrect sound',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
value: _playAudio, value: _playIncorrectSound,
onChanged: (value) async { onChanged: (value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
prefs.setBool('playAudio', value); prefs.setBool('playIncorrectSound', value);
setState(() { setState(() {
_playAudio = value; _playIncorrectSound = value;
}); });
}, },
activeThumbColor: Theme.of(context).colorScheme.primary, activeThumbColor: Theme.of(context).colorScheme.primary,
@@ -133,7 +135,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
title: Text( title: Text(
'Play sound on correct answer', 'Play correct sound',
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
@@ -156,6 +158,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile(
title: Text(
'Play narrator (TTS)',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
value: _playNarrator,
onChanged: (value) async {
final prefs = await SharedPreferences.getInstance();
prefs.setBool('playNarrator', value);
setState(() {
_playNarrator = value;
});
},
activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Theme.of(
context,
).colorScheme.onSurfaceVariant,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 12),
ListTile( ListTile(
title: Text( title: Text(
'Theme', 'Theme',

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

@@ -21,8 +21,7 @@ class _QuizState {
Key key = UniqueKey(); Key key = UniqueKey();
String? selectedOption; String? selectedOption;
bool showResult = false; bool showResult = false;
List<VocabularyItem> shuffledDeck = []; Set<int> wrongItems = {};
int currentIndex = 0;
} }
class VocabScreen extends StatefulWidget { class VocabScreen extends StatefulWidget {
@@ -40,13 +39,17 @@ class _VocabScreenState extends State<VocabScreen>
bool _isAnswering = false; bool _isAnswering = false;
String _status = 'Loading deck...'; String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator(); final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
_QuizState get _currentQuizState => _quizStates[_tabController.index]; _QuizState get _currentQuizState => _quizStates[_tabController.index];
final _sessionDecks = <int, List<VocabularyItem>>{};
final _sessionDeckSizes = <int, int>{};
bool _playAudio = true; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _playNarrator = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@override @override
@@ -54,6 +57,9 @@ class _VocabScreenState extends State<VocabScreen>
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.index == 2 && !_tabController.indexIsChanging) {
_playCurrentAudio();
}
setState(() {}); setState(() {});
}); });
_loadSettings(); _loadSettings();
@@ -69,8 +75,9 @@ class _VocabScreenState extends State<VocabScreen>
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playAudio = prefs.getBool('playAudio') ?? true; _playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
}); });
} }
@@ -109,6 +116,43 @@ class _VocabScreenState extends State<VocabScreen>
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
final disabledLevels = <int>{};
final itemsByLevel = <int, List<VocabularyItem>>{};
for (final item in _deck) {
(itemsByLevel[item.level] ??= []).add(item);
}
itemsByLevel.forEach((level, items) {
final allSrsItems = items
.expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
for (var i = 0; i < _tabController.length; i++) {
final mode = _modeForIndex(i);
var filteredDeck = _deck.where((item) {
if (disabledLevels.contains(item.level)) {
return false;
}
final srsItem = item.srsItems[mode.toString()];
return srsItem == null || !srsItem.disabled;
}).toList();
if (mode == QuizMode.audioToEnglish) {
filteredDeck = filteredDeck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
}
filteredDeck.shuffle(_random);
_sessionDecks[i] = filteredDeck;
_sessionDeckSizes[i] = filteredDeck.length;
}
for (var i = 0; i < _tabController.length; i++) { for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i); _nextQuestion(i);
} }
@@ -142,47 +186,20 @@ class _VocabScreenState extends State<VocabScreen>
} }
void _nextQuestion([int? index]) { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; final tabIndex = index ?? _tabController.index;
final quizState = _quizStates[tabIndex];
final sessionDeck = _sessionDecks[tabIndex];
final mode = _modeForIndex(tabIndex);
final quizState = _quizStates[index ?? _tabController.index]; if (sessionDeck == null || sessionDeck.isEmpty) {
final mode = _modeForIndex(index ?? _tabController.index); setState(() {
quizState.current = null;
List<VocabularyItem> currentDeckForMode = _deck; _status = 'Quiz complete!';
if (mode == QuizMode.audioToEnglish) {
currentDeckForMode = _deck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
if (currentDeckForMode.isEmpty) {
setState(() {
_status = 'No vocabulary with audio found.';
quizState.current = null;
});
return;
}
}
if (quizState.shuffledDeck.isEmpty ||
quizState.currentIndex >= quizState.shuffledDeck.length) {
quizState.shuffledDeck = currentDeckForMode.toList();
quizState.shuffledDeck.sort((a, b) {
final aSrsItem =
a.srsItems[mode.toString()] ??
SrsItem(subjectId: a.id, quizMode: mode);
final bSrsItem =
b.srsItems[mode.toString()] ??
SrsItem(subjectId: b.id, quizMode: mode);
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) {
return stageComparison;
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
}); });
quizState.currentIndex = 0; return;
} }
quizState.current = quizState.shuffledDeck[quizState.currentIndex]; quizState.current = sessionDeck.removeAt(0);
quizState.currentIndex++;
quizState.key = UniqueKey(); quizState.key = UniqueKey();
quizState.correctAnswers = []; quizState.correctAnswers = [];
quizState.options = []; quizState.options = [];
@@ -213,13 +230,17 @@ class _VocabScreenState extends State<VocabScreen>
setState(() { setState(() {
_isAnswering = false; _isAnswering = false;
}); });
if (mode == QuizMode.audioToEnglish) {
_playCurrentAudio(playOnLoad: true);
}
} }
Future<void> _playCurrentAudio({bool playOnLoad = false}) async { Future<void> _playCurrentAudio({bool playOnLoad = false}) async {
final current = _currentQuizState.current; final current = _currentQuizState.current;
if (current == null || current.pronunciationAudios.isEmpty) return; if (current == null || current.pronunciationAudios.isEmpty) return;
if (playOnLoad && !_playAudio) return; if (playOnLoad && !_playNarrator) return;
final maleAudios = current.pronunciationAudios.where( final maleAudios = current.pronunciationAudios.where(
(a) => a.gender == 'male', (a) => a.gender == 'male',
@@ -242,6 +263,8 @@ class _VocabScreenState extends State<VocabScreen>
final repo = Provider.of<VocabDeckRepository>(context, listen: false); final repo = Provider.of<VocabDeckRepository>(context, listen: false);
final current = quizState.current!; final current = quizState.current!;
final tabIndex = _tabController.index;
final sessionDeck = _sessionDecks[tabIndex]!;
final srsKey = mode.toString(); final srsKey = mode.toString();
@@ -250,16 +273,24 @@ class _VocabScreenState extends State<VocabScreen>
final srsItem = final srsItem =
srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode); srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
quizState.asked += 1;
quizState.selectedOption = option; quizState.selectedOption = option;
quizState.showResult = true; quizState.showResult = true;
setState(() {}); setState(() {});
if (isCorrect) { if (isCorrect) {
quizState.score += 1; quizState.asked += 1;
if (!quizState.wrongItems.contains(current.id)) {
quizState.score += 1;
}
srsItem.srsStage += 1; srsItem.srsStage += 1;
} else { } else {
srsItem.srsStage = max(0, srsItem.srsStage - 1); srsItem.srsStage = max(0, srsItem.srsStage - 1);
sessionDeck.add(current);
sessionDeck.shuffle(_random);
quizState.wrongItems.add(current.id);
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
} }
srsItem.lastAsked = DateTime.now(); srsItem.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItem; current.srsItems[srsKey] = srsItem;
@@ -291,10 +322,9 @@ class _VocabScreenState extends State<VocabScreen>
ScaffoldMessenger.of(context).showSnackBar(snack); ScaffoldMessenger.of(context).showSnackBar(snack);
if (isCorrect) { if (isCorrect) {
if (_playCorrectSound) { if (_playCorrectSound && !_playNarrator) {
await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); await _audioPlayer.play(AssetSource('sfx/correct.wav'));
} } else if (_playNarrator) {
if (_playAudio) {
final maleAudios = current.pronunciationAudios.where( final maleAudios = current.pronunciationAudios.where(
(a) => a.gender == 'male', (a) => a.gender == 'male',
); );
@@ -318,7 +348,11 @@ class _VocabScreenState extends State<VocabScreen>
_isAnswering = true; _isAnswering = true;
}); });
_nextQuestion(); Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});
} }
@override @override
@@ -353,6 +387,23 @@ class _VocabScreenState extends State<VocabScreen>
); );
} }
if (_loading) {
return Scaffold(
appBar: AppBar(
title: const Text('Vocabulary Quiz'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Vocab→English'),
Tab(text: 'English→Vocab'),
Tab(text: 'Listening'),
],
),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Vocabulary Quiz'), title: const Text('Vocabulary Quiz'),
@@ -376,6 +427,18 @@ class _VocabScreenState extends State<VocabScreen>
final quizState = _quizStates[index]; final quizState = _quizStates[index];
final mode = _modeForIndex(index); final mode = _modeForIndex(index);
if (quizState.current == null) {
return Center(
child: Text(
_status,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
Widget promptWidget; Widget promptWidget;
if (quizState.current == null) { if (quizState.current == null) {
@@ -417,20 +480,29 @@ class _VocabScreenState extends State<VocabScreen>
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
Row( Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Text(
child: Text( '${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
_status, style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface, fontSize: 18,
), fontWeight: FontWeight.bold,
), ),
), ),
if (_loading) const SizedBox(height: 4),
CircularProgressIndicator( LinearProgressIndicator(
color: Theme.of(context).colorScheme.primary, value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
), ),
),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -455,10 +527,9 @@ class _VocabScreenState extends State<VocabScreen>
OptionsGrid( OptionsGrid(
options: quizState.options, options: quizState.options,
onSelected: _isAnswering ? (option) {} : _answer, onSelected: _isAnswering ? (option) {} : _answer,
isDisabled: false, showResult: quizState.showResult,
selectedOption: null, selectedOption: quizState.selectedOption,
correctAnswers: [], correctAnswers: quizState.correctAnswers,
showResult: false,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(

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';
@@ -24,4 +23,5 @@ class DbConstants {
static const String readingTypeColumn = 'readingType'; static const String readingTypeColumn = 'readingType';
static const String srsStageColumn = 'srsStage'; static const String srsStageColumn = 'srsStage';
static const String lastAskedColumn = 'lastAsked'; static const String lastAskedColumn = 'lastAsked';
static const String disabledColumn = 'disabled';
} }

View File

@@ -32,7 +32,7 @@ class DatabaseHelper {
return openDatabase( return openDatabase(
path, path,
version: 7, version: 8,
onCreate: (db, version) async { onCreate: (db, version) async {
await db.execute( await db.execute(
'''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''', '''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''',
@@ -41,51 +41,24 @@ class DatabaseHelper {
'''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', '''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''',
); );
await db.execute( await db.execute(
'''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', '''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''',
); );
await db.execute( await db.execute(
'''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''', '''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''',
); );
await db.execute( await db.execute(
'''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''',
); );
}, },
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) { if (oldVersion < 8) {
await db.execute( await db.execute(
'''CREATE TABLE IF NOT EXISTS ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', 'ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
);
}
if (oldVersion < 4) {
await db.execute(
'''CREATE TABLE IF NOT EXISTS ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''',
);
}
if (oldVersion < 5) {
await db.execute(
'''CREATE TABLE IF NOT EXISTS ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT)''',
); );
await db.execute( await db.execute(
'''CREATE TABLE IF NOT EXISTS ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', 'ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
); );
} }
if (oldVersion < 6) {
try {
await db.execute(
'ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.pronunciationAudiosColumn} TEXT',
);
} catch (_) {
// Ignore error, column might already exist
}
}
if (oldVersion < 7) {
try {
await db.execute('ALTER TABLE ${DbConstants.kanjiTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER');
await db.execute('ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER');
} catch (_) {
// Ignore error, column might already exist
}
}
}, },
); );
} }

View File

@@ -46,9 +46,7 @@ class DeckRepository {
_apiKey = envApiKey; _apiKey = envApiKey;
return _apiKey; return _apiKey;
} }
} catch (e) { } catch (_) {}
// dotenv is not initialized
}
return null; return null;
} }
@@ -105,6 +103,7 @@ class DeckRepository {
readingType: r[DbConstants.readingTypeColumn] as String?, readingType: r[DbConstants.readingTypeColumn] as String?,
srsStage: r[DbConstants.srsStageColumn] as int, srsStage: r[DbConstants.srsStageColumn] as int,
lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String), lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String),
disabled: (r[DbConstants.disabledColumn] as int? ?? 0) == 1,
); );
srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem); srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem);
} }
@@ -120,16 +119,55 @@ class DeckRepository {
return kanjiItems; return kanjiItems;
} }
Future<void> updateSrsItems(List<SrsItem> items) async {
final db = await DatabaseHelper().db;
final batch = db.batch();
for (final item in items) {
var where =
'${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?';
final whereArgs = [item.subjectId, item.quizMode.toString()];
if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?';
whereArgs.add(item.readingType!);
} else {
where += ' AND ${DbConstants.readingTypeColumn} IS NULL';
}
batch.update(
DbConstants.srsItemsTable,
{
DbConstants.srsStageColumn: item.srsStage,
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
},
where: where,
whereArgs: whereArgs,
);
}
await batch.commit(noResult: true);
}
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} = ?';
final whereArgs = [item.subjectId, item.quizMode.toString()];
if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?';
whereArgs.add(item.readingType!);
} else {
where += ' AND ${DbConstants.readingTypeColumn} IS NULL';
}
await db.update( await db.update(
DbConstants.srsItemsTable, DbConstants.srsItemsTable,
{ {
DbConstants.srsStageColumn: item.srsStage, DbConstants.srsStageColumn: item.srsStage,
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
}, },
where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', where: where,
whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType], whereArgs: whereArgs,
); );
} }
@@ -141,6 +179,7 @@ class DeckRepository {
DbConstants.readingTypeColumn: item.readingType, DbConstants.readingTypeColumn: item.readingType,
DbConstants.srsStageColumn: item.srsStage, DbConstants.srsStageColumn: item.srsStage,
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }

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

@@ -0,0 +1,56 @@
import 'package:flutter_tts/flutter_tts.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class TtsService {
FlutterTts? _flutterTts;
bool _isInitialized = false;
Future<void> initTts() async {
if (_isInitialized) return;
_flutterTts = FlutterTts();
if (_flutterTts != null) {
final isAvailable = await _flutterTts!.isLanguageAvailable("ja-JP");
if (isAvailable == true) {
await _flutterTts?.setLanguage("ja-JP");
} else {
debugPrint('Japanese (ja-JP) TTS language not available.');
}
}
_isInitialized = true;
}
Future<bool> isLanguageAvailable(String language) async {
if (_flutterTts == null) {
await initTts();
}
return await _flutterTts?.isLanguageAvailable(language) ?? false;
}
Future<void> speak(String text) async {
const int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
if (_flutterTts == null || !_isInitialized) {
await initTts();
}
await _flutterTts?.speak(text);
return;
} on PlatformException catch (_) {
debugPrint('TTS speak failed, retrying...');
await _flutterTts?.stop();
_flutterTts = null;
_isInitialized = false;
await Future.delayed(const Duration(milliseconds: 500));
}
}
debugPrint('Failed to speak after $maxRetries retries.');
}
void dispose() {
_flutterTts?.stop();
_flutterTts = null;
_isInitialized = false;
}
}

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 {
@@ -68,10 +67,29 @@ class VocabDeckRepository {
), ),
srsStage: r['srsStage'] as int, srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String), lastAsked: DateTime.parse(r['lastAsked'] as String),
disabled: (r['disabled'] as int? ?? 0) == 1,
); );
}).toList(); }).toList();
} }
Future<void> updateSrsItems(List<SrsItem> items) async {
final db = await DatabaseHelper().db;
final batch = db.batch();
for (final item in items) {
batch.update(
'srs_vocab_items',
{
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
},
where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.subjectId, item.quizMode.toString()],
);
}
await batch.commit(noResult: true);
}
Future<void> updateVocabSrsItem(SrsItem item) async { Future<void> updateVocabSrsItem(SrsItem item) async {
final db = await DatabaseHelper().db; final db = await DatabaseHelper().db;
await db.update( await db.update(
@@ -79,6 +97,7 @@ class VocabDeckRepository {
{ {
'srsStage': item.srsStage, 'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), 'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
}, },
where: 'vocabId = ? AND quizMode = ?', where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.subjectId, item.quizMode.toString()], whereArgs: [item.subjectId, item.quizMode.toString()],
@@ -92,6 +111,7 @@ class VocabDeckRepository {
'quizMode': item.quizMode.toString(), 'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage, 'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), 'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }

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,20 +39,24 @@ 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;
} else if (o == selectedOption) {
currentButtonColor = theme.colorScheme.error;
} }
} }
return SizedBox( return SizedBox(
width: 160, width: 160,
child: ElevatedButton( child: ElevatedButton(
onPressed: isDisabled ? null : () => onSelected(o), onPressed: isDisabled || o == '---' ? null : () => onSelected(o),
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),
), ),
@@ -60,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,
), ),
), ),

View File

@@ -34,4 +34,5 @@ flutter_icons:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/sfx/confirm.mp3 - assets/sfx/correct.wav
- assets/sfx/incorrect.wav