change a bunch of stuff, seperate tracking for progress, updated custom srs layout

This commit is contained in:
Rene Kievits
2025-10-31 07:16:44 +01:00
parent cafec12888
commit d8edfa1686
12 changed files with 1378 additions and 661 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import 'package:provider/provider.dart'; 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';
@@ -14,8 +15,11 @@ void main() async {
} }
runApp( runApp(
Provider<DeckRepository>( MultiProvider(
create: (_) => DeckRepository(), providers: [
Provider<DeckRepository>(create: (_) => DeckRepository()),
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
],
child: const WkApp(), child: const WkApp(),
), ),
); );

View File

@@ -4,28 +4,44 @@ class CustomKanjiItem {
final String meaning; final String meaning;
final String? kanji; final String? kanji;
final bool useInterval; final bool useInterval;
int srsLevel; SrsData srsData;
DateTime? nextReview;
CustomKanjiItem({ CustomKanjiItem({
required this.characters, required this.characters,
required this.meaning, required this.meaning,
this.kanji, this.kanji,
this.useInterval = false, this.useInterval = false,
this.srsLevel = 0, SrsData? srsData,
this.nextReview, }) : srsData = srsData ?? SrsData();
});
factory CustomKanjiItem.fromJson(Map<String, dynamic> json) { factory CustomKanjiItem.fromJson(Map<String, dynamic> json) {
SrsData srsData;
if (json['srsData'] != null) {
srsData = SrsData.fromJson(json['srsData']);
if (json['nextReview'] != null) {
final oldNextReview = DateTime.parse(json['nextReview'] as String);
srsData.japaneseToEnglishNextReview ??= oldNextReview;
srsData.englishToJapaneseNextReview ??= oldNextReview;
srsData.listeningComprehensionNextReview ??= oldNextReview;
}
} else {
DateTime? nextReview = json['nextReview'] != null ? DateTime.parse(json['nextReview'] as String) : null;
srsData = SrsData(
japaneseToEnglish: json['srsLevel'] as int? ?? 0,
japaneseToEnglishNextReview: nextReview,
englishToJapanese: json['srsLevel'] as int? ?? 0,
englishToJapaneseNextReview: nextReview,
listeningComprehension: json['srsLevel'] as int? ?? 0,
listeningComprehensionNextReview: nextReview,
);
}
return CustomKanjiItem( return CustomKanjiItem(
characters: json['characters'] as String, characters: json['characters'] as String,
meaning: json['meaning'] as String, meaning: json['meaning'] as String,
kanji: json['kanji'] as String?, kanji: json['kanji'] as String?,
useInterval: json['useInterval'] as bool? ?? false, useInterval: json['useInterval'] as bool? ?? false,
srsLevel: json['srsLevel'] as int? ?? 0, srsData: srsData,
nextReview: json['nextReview'] != null
? DateTime.parse(json['nextReview'] as String)
: null,
); );
} }
@@ -35,8 +51,47 @@ class CustomKanjiItem {
'meaning': meaning, 'meaning': meaning,
'kanji': kanji, 'kanji': kanji,
'useInterval': useInterval, 'useInterval': useInterval,
'srsLevel': srsLevel, 'srsData': srsData.toJson(),
'nextReview': nextReview?.toIso8601String(), };
}
}
class SrsData {
int japaneseToEnglish;
DateTime? japaneseToEnglishNextReview;
int englishToJapanese;
DateTime? englishToJapaneseNextReview;
int listeningComprehension;
DateTime? listeningComprehensionNextReview;
SrsData({
this.japaneseToEnglish = 0,
this.japaneseToEnglishNextReview,
this.englishToJapanese = 0,
this.englishToJapaneseNextReview,
this.listeningComprehension = 0,
this.listeningComprehensionNextReview,
});
factory SrsData.fromJson(Map<String, dynamic> json) {
return SrsData(
japaneseToEnglish: json['japaneseToEnglish'] as int? ?? 0,
japaneseToEnglishNextReview: json['japaneseToEnglishNextReview'] != null ? DateTime.parse(json['japaneseToEnglishNextReview'] as String) : null,
englishToJapanese: json['englishToJapanese'] as int? ?? 0,
englishToJapaneseNextReview: json['englishToJapaneseNextReview'] != null ? DateTime.parse(json['englishToJapaneseNextReview'] as String) : null,
listeningComprehension: json['listeningComprehension'] as int? ?? 0,
listeningComprehensionNextReview: json['listeningComprehensionNextReview'] != null ? DateTime.parse(json['listeningComprehensionNextReview'] as String) : null,
);
}
Map<String, dynamic> toJson() {
return {
'japaneseToEnglish': japaneseToEnglish,
'japaneseToEnglishNextReview': japaneseToEnglishNextReview?.toIso8601String(),
'englishToJapanese': englishToJapanese,
'englishToJapaneseNextReview': englishToJapaneseNextReview?.toIso8601String(),
'listeningComprehension': listeningComprehension,
'listeningComprehensionNextReview': listeningComprehensionNextReview?.toIso8601String(),
}; };
} }
} }

View File

@@ -50,12 +50,20 @@ class _AddCardScreenState extends State<AddCardScreen> {
void _saveCard() { void _saveCard() {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
final srsData = _useInterval
? SrsData(
japaneseToEnglishNextReview: DateTime.now(),
englishToJapaneseNextReview: DateTime.now(),
listeningComprehensionNextReview: DateTime.now(),
)
: SrsData();
final newItem = CustomKanjiItem( final newItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null, kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null,
useInterval: _useInterval, useInterval: _useInterval,
nextReview: _useInterval ? DateTime.now() : null, srsData: srsData,
); );
_deckRepository.addCard(newItem); _deckRepository.addCard(newItem);
Navigator.of(context).pop(); Navigator.of(context).pop();

View File

@@ -1,11 +1,15 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/custom_deck_repository.dart'; import '../services/custom_deck_repository.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'custom_card_details_screen.dart'; import 'custom_card_details_screen.dart';
import 'add_card_screen.dart';
class BrowseScreen extends StatefulWidget { class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key}); const BrowseScreen({super.key});
@@ -222,8 +226,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
final item = items[index]; final item = items[index];
return GestureDetector( return GestureDetector(
onTap: () => _showReadingsDialog(item), onTap: () => _showReadingsDialog(item),
child: child: _buildSrsItemCard(item),
_buildSrsItemCard(item.characters, item.srsItems.values.toList()),
); );
}, },
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -241,53 +244,87 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
Widget _buildVocabListTile(VocabularyItem item) { Widget _buildVocabListTile(VocabularyItem item) {
final avgSrsStage = item.srsItems.isNotEmpty final requiredModes = <String>[
? item.srsItems.values VocabQuizMode.vocabToEnglish.toString(),
.map((s) => s.srsStage) VocabQuizMode.englishToVocab.toString(),
.reduce((a, b) => a + b) / VocabQuizMode.audioToEnglish.toString(),
item.srsItems.length ];
: 0.0;
return Card( int minSrsStage = 9;
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), for (final mode in requiredModes) {
child: Padding( final srsItem = item.srsItems[mode];
padding: const EdgeInsets.all(12.0), if (srsItem == null) {
child: Row( minSrsStage = 0;
children: [ break;
Expanded( }
child: Text( if (srsItem.srsStage < minSrsStage) {
item.characters, minSrsStage = srsItem.srsStage;
style: const TextStyle(fontSize: 24, color: Colors.white), }
}
return GestureDetector(
onTap: () => _showVocabDetailsDialog(context, item),
child: Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Expanded(
child: Text(
item.characters,
style: const TextStyle(fontSize: 24, color: Colors.white),
),
), ),
), const SizedBox(width: 16),
const SizedBox(width: 16), Expanded(
Expanded( flex: 2,
flex: 2, child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Text(
Text( item.meanings.join(', '),
item.meanings.join(', '), style: const TextStyle(color: Colors.grey),
style: const TextStyle(color: Colors.grey), overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, ),
), const SizedBox(height: 8),
const SizedBox(height: 8), _buildSrsIndicator(minSrsStage),
_buildSrsIndicator(avgSrsStage.round()), ],
], ),
), ),
), ],
], ),
), ),
), ),
); );
} }
Widget _buildSrsItemCard(String characters, List<dynamic> srsItems) { Widget _buildSrsItemCard(KanjiItem item) {
final avgSrsStage = srsItems.isNotEmpty final requiredModes = <String>[
? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) / QuizMode.kanjiToEnglish.toString(),
srsItems.length QuizMode.englishToKanji.toString(),
: 0.0; ];
if (item.onyomi.isNotEmpty) {
requiredModes.add('${QuizMode.reading}onyomi');
}
if (item.kunyomi.isNotEmpty) {
requiredModes.add('${QuizMode.reading}kunyomi');
}
int minSrsStage = 9;
for (final mode in requiredModes) {
final srsItem = item.srsItems[mode];
if (srsItem == null) {
minSrsStage = 0;
break;
}
if (srsItem.srsStage < minSrsStage) {
minSrsStage = srsItem.srsStage;
}
}
return Card( return Card(
color: const Color(0xFF1E1E1E), color: const Color(0xFF1E1E1E),
@@ -297,12 +334,12 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
characters, item.characters,
style: const TextStyle(fontSize: 32, color: Colors.white), style: const TextStyle(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSrsIndicator(avgSrsStage.round()), _buildSrsIndicator(minSrsStage),
], ],
), ),
), ),
@@ -339,6 +376,32 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
void _showReadingsDialog(KanjiItem kanji) { void _showReadingsDialog(KanjiItem kanji) {
final srsScores = <String, int>{
'JP -> EN': 0,
'EN -> JP': 0,
'Reading (onyomi)': 0,
'Reading (kunyomi)': 0,
};
for (final entry in kanji.srsItems.entries) {
final srsItem = entry.value;
switch (srsItem.quizMode) {
case QuizMode.kanjiToEnglish:
srsScores['JP -> EN'] = srsItem.srsStage;
break;
case QuizMode.englishToKanji:
srsScores['EN -> JP'] = srsItem.srsStage;
break;
case QuizMode.reading:
if (srsItem.readingType == 'onyomi') {
srsScores['Reading (onyomi)'] = srsItem.srsStage;
} else if (srsItem.readingType == 'kunyomi') {
srsScores['Reading (kunyomi)'] = srsItem.srsStage;
}
break;
}
}
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
@@ -348,37 +411,51 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
'Details for ${kanji.characters}', 'Details for ${kanji.characters}',
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
content: Column( content: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
'Level: ${kanji.level}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (kanji.meanings.isNotEmpty)
Text( Text(
'Meanings: ${kanji.meanings.join(', ')}', 'Level: ${kanji.level}',
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (kanji.onyomi.isNotEmpty) if (kanji.meanings.isNotEmpty)
Text( Text(
'On\'yomi: ${kanji.onyomi.join(', ')}', 'Meanings: ${kanji.meanings.join(', ')}',
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
if (kanji.kunyomi.isNotEmpty) const SizedBox(height: 16),
Text( if (kanji.onyomi.isNotEmpty)
'Kun\'yomi: ${kanji.kunyomi.join(', ')}', Text(
style: const TextStyle(color: Colors.white), 'On\'yomi: ${kanji.onyomi.join(', ')}',
), style: const TextStyle(color: Colors.white),
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) ),
if (kanji.kunyomi.isNotEmpty)
Text(
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
style: const TextStyle(color: Colors.white),
),
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text(
'No readings available.',
style: TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text( const Text(
'No readings available.', 'SRS Scores:',
style: const TextStyle(color: Colors.white), style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
), ),
], ...srsScores.entries.map((entry) => Text(
' ${entry.key}: ${entry.value}',
style: const TextStyle(color: Colors.white),
)),
],
),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -395,9 +472,11 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Future<void> _loadDecks() async { Future<void> _loadDecks() async {
setState(() => _loading = true); setState(() => _loading = true);
try { try {
final repo = Provider.of<DeckRepository>(context, listen: false); final kanjiRepo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey(); final vocabRepo =
final apiKey = repo.apiKey; Provider.of<VocabDeckRepository>(context, listen: false);
await kanjiRepo.loadApiKey();
final apiKey = kanjiRepo.apiKey;
if (apiKey == null || apiKey.isEmpty) { if (apiKey == null || apiKey.isEmpty) {
setState(() { setState(() {
@@ -407,16 +486,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
return; return;
} }
var kanji = await repo.loadKanji(); var kanji = await kanjiRepo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) { if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...'); setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey); kanji = await kanjiRepo.fetchAndCacheFromWk(apiKey);
} }
var vocab = await repo.loadVocabulary(); var vocab = await vocabRepo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) { if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...'); setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey); vocab = await vocabRepo.fetchAndCacheVocabularyFromWk(apiKey);
} }
_kanjiDeck = kanji; _kanjiDeck = kanji;
@@ -458,7 +537,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), appBar:
_isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
backgroundColor: const Color(0xFF121212), backgroundColor: const Color(0xFF121212),
body: Column( body: Column(
children: [ children: [
@@ -478,7 +558,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
_vocabByLevel, _vocabByLevel,
_vocabSortedLevels, _vocabSortedLevels,
_vocabPageController, _vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())), (items) =>
_buildListView(items.cast<VocabularyItem>())),
), ),
_buildCustomSrsTab(), _buildCustomSrsTab(),
], ],
@@ -487,10 +568,23 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
if (!_isSelectionMode) if (!_isSelectionMode)
SafeArea( SafeArea(
top: false, top: false,
child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(), child: _tabController.index < 2
? _buildLevelSelector()
: const SizedBox.shrink(),
), ),
], ],
), ),
floatingActionButton: _tabController.index == 2
? FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AddCardScreen()),
);
_loadCustomDeck();
},
child: const Icon(Icons.add),
)
: null,
); );
} }
@@ -560,7 +654,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Delete Selected'), title: const Text('Delete Selected'),
content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'), content:
Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
@@ -568,6 +663,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
final navigator = Navigator.of(context);
for (final item in _selectedItems) { for (final item in _selectedItems) {
await _customDeckRepository.deleteCard(item); await _customDeckRepository.deleteCard(item);
} }
@@ -575,8 +671,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
_isSelectionMode = false; _isSelectionMode = false;
_selectedItems.clear(); _selectedItems.clear();
}); });
_loadCustomDeck(); await _loadCustomDeck();
Navigator.of(context).pop(); if (!mounted) return;
navigator.pop();
}, },
child: const Text('Delete'), child: const Text('Delete'),
), ),
@@ -591,7 +688,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
final bool targetState = _selectedItems.any((item) => !item.useInterval); final bool targetState = _selectedItems.any((item) => !item.useInterval);
final selectedCharacters = _selectedItems.map((item) => item.characters).toSet(); final selectedCharacters =
_selectedItems.map((item) => item.characters).toSet();
final List<CustomKanjiItem> updatedItems = []; final List<CustomKanjiItem> updatedItems = [];
for (final item in _selectedItems) { for (final item in _selectedItems) {
@@ -600,8 +698,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
meaning: item.meaning, meaning: item.meaning,
kanji: item.kanji, kanji: item.kanji,
useInterval: targetState, useInterval: targetState,
srsLevel: item.srsLevel, srsData: item.srsData,
nextReview: item.nextReview,
); );
updatedItems.add(updatedItem); updatedItems.add(updatedItem);
} }
@@ -653,14 +750,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
}); });
} else { } else {
Navigator.of(context).push( Navigator.of(context)
MaterialPageRoute( .push(
builder: (_) => CustomCardDetailsScreen( MaterialPageRoute(
item: item, builder: (_) => CustomCardDetailsScreen(
repository: _customDeckRepository, item: item,
), repository: _customDeckRepository,
), ),
).then((_) => _loadCustomDeck()); ),
)
.then((_) => _loadCustomDeck());
} }
}, },
child: Card( child: Card(
@@ -671,7 +770,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
), ),
color: isSelected color: isSelected
? Colors.blue.withOpacity(0.5) ? Colors.blue.withAlpha((255 * 0.5).round())
: const Color(0xFF1E1E1E), : const Color(0xFF1E1E1E),
child: Stack( child: Stack(
children: [ children: [
@@ -683,20 +782,30 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Text( child: Text(
item.kanji ?? item.characters, item.kanji?.isNotEmpty == true
style: const TextStyle(fontSize: 32, color: Colors.white), ? item.kanji!
: item.characters,
style:
const TextStyle(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item.meaning, item.meaning,
style: const TextStyle(color: Colors.grey, fontSize: 16), style:
const TextStyle(color: Colors.grey, fontSize: 16),
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildSrsIndicator(item.srsLevel), Builder(builder: (context) {
final avgSrs = (item.srsData.japaneseToEnglish +
item.srsData.englishToJapanese +
item.srsData.listeningComprehension) /
3;
return _buildSrsIndicator(avgSrs.round());
}),
], ],
), ),
), ),
@@ -719,3 +828,171 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
); );
} }
} }
class _VocabDetailsDialog extends StatefulWidget {
final VocabularyItem vocab;
const _VocabDetailsDialog({required this.vocab});
@override
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
}
class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
List<Widget> _exampleSentences = [const CircularProgressIndicator()];
@override
void initState() {
super.initState();
_fetchExampleSentences();
}
Future<void> _fetchExampleSentences() async {
try {
final uri = Uri.parse(
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}');
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final sentences = <Widget>[];
if (data['data'] != null && (data['data'] as List).isNotEmpty) {
for (final result in data['data']) {
if (result['japanese'] != null &&
(result['japanese'] as List).isNotEmpty &&
result['senses'] != null &&
(result['senses'] as List).isNotEmpty) {
final japaneseWord = result['japanese'][0]['word'] ?? result['japanese'][0]['reading'];
final englishDefinition = result['senses'][0]['english_definitions'].join(', ');
if (japaneseWord != null && englishDefinition != null) {
sentences.add(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(japaneseWord, style: const TextStyle(color: Colors.white)),
Text(englishDefinition, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
],
),
);
}
}
}
}
if (sentences.isEmpty) {
sentences.add(const Text('No example sentences found.', style: TextStyle(color: Colors.white)));
}
if (mounted) {
setState(() {
_exampleSentences = sentences;
});
}
} else {
if (mounted) {
setState(() {
_exampleSentences = [
const Text('Failed to load example sentences.', style: TextStyle(color: Colors.red))
];
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_exampleSentences = [
const Text('Error loading example sentences.', style: TextStyle(color: Colors.red))
];
});
}
}
}
@override
Widget build(BuildContext context) {
final srsScores = <String, int>{
'JP -> EN': 0,
'EN -> JP': 0,
'Audio': 0,
};
for (final entry in widget.vocab.srsItems.entries) {
final srsItem = entry.value;
switch (srsItem.quizMode) {
case VocabQuizMode.vocabToEnglish:
srsScores['JP -> EN'] = srsItem.srsStage;
break;
case VocabQuizMode.englishToVocab:
srsScores['EN -> JP'] = srsItem.srsStage;
break;
case VocabQuizMode.audioToEnglish:
srsScores['Audio'] = srsItem.srsStage;
break;
}
}
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Level: ${widget.vocab.level}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (widget.vocab.meanings.isNotEmpty)
Text(
'Meanings: ${widget.vocab.meanings.join(', ')}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (widget.vocab.readings.isNotEmpty)
Text(
'Readings: ${widget.vocab.readings.join(', ')}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text(
'SRS Scores:',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
...srsScores.entries.map((entry) => Text(
' ${entry.key}: ${entry.value}',
style: const TextStyle(color: Colors.white),
)),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text(
'Example Sentences:',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
..._exampleSentences,
],
),
);
}
}
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: Text(
'Details for ${vocab.characters}',
style: const TextStyle(color: Colors.white),
),
content: _VocabDetailsDialog(vocab: vocab),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close', style: TextStyle(color: Colors.blueAccent)),
),
],
);
},
);
}

View File

@@ -19,7 +19,6 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
late TextEditingController _englishController; late TextEditingController _englishController;
late TextEditingController _kanjiController; late TextEditingController _kanjiController;
late bool _useInterval; late bool _useInterval;
late int _srsLevel;
@override @override
void initState() { void initState() {
@@ -28,7 +27,6 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
_englishController = TextEditingController(text: widget.item.meaning); _englishController = TextEditingController(text: widget.item.meaning);
_kanjiController = TextEditingController(text: widget.item.kanji); _kanjiController = TextEditingController(text: widget.item.kanji);
_useInterval = widget.item.useInterval; _useInterval = widget.item.useInterval;
_srsLevel = widget.item.srsLevel;
} }
@override @override
@@ -43,10 +41,9 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
final updatedItem = CustomKanjiItem( final updatedItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text, kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null,
useInterval: _useInterval, useInterval: _useInterval,
srsLevel: _srsLevel, srsData: widget.item.srsData,
nextReview: widget.item.nextReview,
); );
widget.repository.updateCard(updatedItem); widget.repository.updateCard(updatedItem);
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -113,7 +110,11 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
}); });
}, },
), ),
Text('SRS Level: $_srsLevel'), const SizedBox(height: 20),
const Text('SRS Levels', style: TextStyle(fontWeight: FontWeight.bold)),
Text('Jpn→Eng: ${widget.item.srsData.japaneseToEnglish} (Next review: ${widget.item.srsData.japaneseToEnglishNextReview?.toString() ?? 'N/A'})'),
Text('Eng→Jpn: ${widget.item.srsData.englishToJapanese} (Next review: ${widget.item.srsData.englishToJapaneseNextReview?.toString() ?? 'N/A'})'),
Text('Listening: ${widget.item.srsData.listeningComprehension} (Next review: ${widget.item.srsData.listeningComprehensionNextReview?.toString() ?? 'N/A'})'),
const SizedBox(height: 20), const SizedBox(height: 20),
ElevatedButton( ElevatedButton(
onPressed: _saveChanges, onPressed: _saveChanges,

View File

@@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter_tts/flutter_tts.dart'; 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';
enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension } enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension }
@@ -11,6 +12,7 @@ class CustomQuizScreen extends StatefulWidget {
final CustomQuizMode quizMode; final CustomQuizMode quizMode;
final Function(CustomKanjiItem) onCardReviewed; final Function(CustomKanjiItem) onCardReviewed;
final bool useKanji; final bool useKanji;
final bool isActive;
const CustomQuizScreen({ const CustomQuizScreen({
super.key, super.key,
@@ -18,6 +20,7 @@ class CustomQuizScreen extends StatefulWidget {
required this.quizMode, required this.quizMode,
required this.onCardReviewed, required this.onCardReviewed,
required this.useKanji, required this.useKanji,
required this.isActive,
}); });
@override @override
@@ -34,6 +37,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
late FlutterTts _flutterTts; late FlutterTts _flutterTts;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = [];
@override @override
void initState() { void initState() {
@@ -59,6 +63,17 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
@override @override
void didUpdateWidget(CustomQuizScreen oldWidget) { void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.deck != oldWidget.deck && !widget.isActive) {
setState(() {
_shuffledDeck = widget.deck.toList()..shuffle();
_currentIndex = 0;
_answered = false;
_correct = null;
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
});
}
if (widget.useKanji != oldWidget.useKanji) { if (widget.useKanji != oldWidget.useKanji) {
setState(() { setState(() {
_generateOptions(); _generateOptions();
@@ -67,7 +82,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
void playAudio() { void playAudio() {
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) {
_speak(_shuffledDeck[_currentIndex].characters); _speak(_shuffledDeck[_currentIndex].characters);
} }
} }
@@ -75,9 +90,6 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void _initTts() async { void _initTts() async {
_flutterTts = FlutterTts(); _flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP"); await _flutterTts.setLanguage("ja-JP");
if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
} }
@override @override
@@ -108,7 +120,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
_options.shuffle(); _options.shuffle();
} }
void _checkAnswer(String answer) { void _checkAnswer(String answer) async {
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
@@ -116,42 +128,118 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
final isCorrect = answer == correctAnswer; final isCorrect = answer == correctAnswer;
if (currentItem.useInterval) { if (currentItem.useInterval) {
if (isCorrect) { int currentSrsLevel;
currentItem.srsLevel++; switch (widget.quizMode) {
final interval = pow(2, currentItem.srsLevel).toInt(); case CustomQuizMode.japaneseToEnglish:
currentItem.nextReview = DateTime.now().add(Duration(hours: interval)); currentSrsLevel = currentItem.srsData.japaneseToEnglish;
} else { break;
currentItem.srsLevel = max(0, currentItem.srsLevel - 1); case CustomQuizMode.englishToJapanese:
currentItem.nextReview = DateTime.now().add(const Duration(hours: 1)); 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); widget.onCardReviewed(currentItem);
} }
setState(() { // --- SnackBar Logic (new) ---
_answered = true; final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
_correct = isCorrect; ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
}); : currentItem.meaning;
final snack = SnackBar(
content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color(0xFF222222),
duration: const Duration(milliseconds: 900),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack);
}
// --- End SnackBar Logic ---
if (isCorrect) { if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish || if (widget.quizMode == CustomQuizMode.japaneseToEnglish) {
widget.quizMode == CustomQuizMode.listeningComprehension) { await _speak(currentItem.characters);
_speak(currentItem.characters);
} }
await Future.delayed(const Duration(milliseconds: 500)); // Small delay after correct answer
} else { } else {
_shakeController.forward(from: 0); _shakeController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 900)); // Delay for shake animation
} }
_nextQuestion();
} }
void _nextQuestion() { void _nextQuestion() {
setState(() { setState(() {
_currentIndex = (_currentIndex + 1) % _shuffledDeck.length; _currentIndex++;
_answered = false; _answered = false;
_correct = null; _correct = null;
_generateOptions(); if (_currentIndex < _shuffledDeck.length) {
_generateOptions();
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
}
}); });
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
} }
Future<void> _speak(String text) async { Future<void> _speak(String text) async {
@@ -166,7 +254,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_shuffledDeck.isEmpty) { if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) {
return const Center( return const Center(
child: Text('Review session complete!'), child: Text('Review session complete!'),
); );
@@ -177,54 +265,70 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
? currentItem.meaning ? currentItem.meaning
: (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); : (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
return Center( Widget promptWidget;
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
promptWidget = IconButton(
icon: const Icon(Icons.volume_up, size: 64),
onPressed: () => _speak(currentItem.characters),
);
} else {
promptWidget = GestureDetector(
onTap: () => _speak(question),
child: Text(
question,
style: const TextStyle(fontSize: 48),
textAlign: TextAlign.center,
),
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (widget.quizMode == CustomQuizMode.listeningComprehension) const SizedBox(height: 18),
IconButton( Expanded(
icon: const Icon(Icons.volume_up, size: 64), flex: 3,
onPressed: () => _speak(currentItem.characters), child: Center(
) child: ConstrainedBox(
else constraints: const BoxConstraints(
GestureDetector( minWidth: 0,
onTap: () => _speak(question), maxWidth: 500,
child: Text( minHeight: 150,
question, ),
style: const TextStyle(fontSize: 48), child: KanjiCard(
textAlign: TextAlign.center, characterWidget: promptWidget,
subtitle: '',
),
), ),
), ),
const SizedBox(height: 32),
if (_answered)
Text(
_correct! ? 'Correct!' : 'Incorrect, try again!',
style: TextStyle(
fontSize: 24,
color: _correct! ? Colors.green : Colors.red,
),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value * 10, 0),
child: child,
);
},
child: OptionsGrid(
options: _options,
onSelected: _onOptionSelected,
),
), ),
if (_answered && _correct!) const SizedBox(height: 12),
ElevatedButton( SafeArea(
onPressed: _nextQuestion, top: false,
child: const Text('Next'), child: Column(
children: [
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value * 10, 0),
child: child,
);
},
child: OptionsGrid(
options: _options,
onSelected: _onOptionSelected,
correctAnswers: [],
showResult: false,
isDisabled: false,
),
),
],
), ),
),
], ],
), ),
); );
} }
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
import '../services/custom_deck_repository.dart'; import '../services/custom_deck_repository.dart';
import 'add_card_screen.dart';
import 'custom_quiz_screen.dart'; import 'custom_quiz_screen.dart';
class CustomSrsScreen extends StatefulWidget { class CustomSrsScreen extends StatefulWidget {
@@ -15,7 +14,6 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
late TabController _tabController; late TabController _tabController;
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
List<CustomKanjiItem> _deck = []; List<CustomKanjiItem> _deck = [];
List<CustomKanjiItem> _reviewDeck = [];
bool _useKanji = false; bool _useKanji = false;
final _quizScreenKeys = [ final _quizScreenKeys = [
GlobalKey<CustomQuizScreenState>(), GlobalKey<CustomQuizScreenState>(),
@@ -45,17 +43,8 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
Future<void> _loadDeck() async { Future<void> _loadDeck() async {
final deck = await _deckRepository.getCustomDeck(); final deck = await _deckRepository.getCustomDeck();
final now = DateTime.now();
final reviewDeck = deck.where((item) {
if (!item.useInterval) {
return true;
}
return item.nextReview == null || item.nextReview!.isBefore(now);
}).toList();
setState(() { setState(() {
_deck = deck; _deck = deck;
_reviewDeck = reviewDeck;
}); });
} }
@@ -64,7 +53,6 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
if (index != -1) { if (index != -1) {
setState(() { setState(() {
_deck[index] = item; _deck[index] = item;
_reviewDeck.removeWhere((element) => element.characters == item.characters);
}); });
await _deckRepository.saveDeck(_deck); await _deckRepository.saveDeck(_deck);
} }
@@ -72,6 +60,29 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final now = DateTime.now();
final jpnToEngReviewDeck = _deck.where((item) {
if (!item.useInterval) return true;
return item.srsData.japaneseToEnglishNextReview == null ||
item.srsData.japaneseToEnglishNextReview!.isBefore(now);
}).toList();
final engToJpnReviewDeck = _deck.where((item) {
if (!item.useInterval) return true;
return item.srsData.englishToJapaneseNextReview == null ||
item.srsData.englishToJapaneseNextReview!.isBefore(now);
}).toList();
final listeningReviewDeck = _deck.where((item) {
if (!item.useInterval) return true;
return item.srsData.listeningComprehensionNextReview == null ||
item.srsData.listeningComprehensionNextReview!.isBefore(now);
}).toList();
final allDecksEmpty = jpnToEngReviewDeck.isEmpty &&
engToJpnReviewDeck.isEmpty &&
listeningReviewDeck.isEmpty;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Custom SRS'), title: const Text('Custom SRS'),
@@ -102,43 +113,37 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
), ),
body: _deck.isEmpty body: _deck.isEmpty
? const Center(child: Text('Add cards to start quizzing!')) ? const Center(child: Text('Add cards to start quizzing!'))
: _reviewDeck.isEmpty : allDecksEmpty
? const Center(child: Text('No cards due for review.')) ? const Center(child: Text('No cards due for review.'))
: TabBarView( : TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[0], key: _quizScreenKeys[0],
deck: _reviewDeck, deck: jpnToEngReviewDeck,
quizMode: CustomQuizMode.japaneseToEnglish, quizMode: CustomQuizMode.japaneseToEnglish,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, useKanji: _useKanji,
isActive: _tabController.index == 0,
), ),
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[1], key: _quizScreenKeys[1],
deck: _reviewDeck, deck: engToJpnReviewDeck,
quizMode: CustomQuizMode.englishToJapanese, quizMode: CustomQuizMode.englishToJapanese,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, useKanji: _useKanji,
isActive: _tabController.index == 1,
), ),
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[2], key: _quizScreenKeys[2],
deck: _reviewDeck, deck: listeningReviewDeck,
quizMode: CustomQuizMode.listeningComprehension, quizMode: CustomQuizMode.listeningComprehension,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, useKanji: _useKanji,
isActive: _tabController.index == 2,
), ),
], ],
), ),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AddCardScreen()),
);
_loadDeck();
},
child: const Icon(Icons.add),
),
); );
} }
} }

View File

@@ -18,6 +18,18 @@ class _ReadingInfo {
_ReadingInfo(this.correctReadings, this.hint); _ReadingInfo(this.correctReadings, this.hint);
} }
class _QuizState {
KanjiItem? current;
List<String> options = [];
List<String> correctAnswers = [];
String readingHint = '';
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
}
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, this.distractorGenerator}); const HomeScreen({super.key, this.distractorGenerator});
@@ -31,17 +43,15 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
late TabController _tabController; late TabController _tabController;
List<KanjiItem> _deck = []; List<KanjiItem> _deck = [];
bool _loading = false; bool _loading = false;
bool _isAnswering = false;
String _status = 'Loading deck...'; String _status = 'Loading deck...';
late final DistractorGenerator _dg; late final DistractorGenerator _dg;
final Random _random = Random(); final Random _random = Random();
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
KanjiItem? _current; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
List<String> _options = []; _QuizState get _currentQuizState => _quizStates[_tabController.index];
List<String> _correctAnswers = [];
String _readingHint = '';
int _score = 0;
int _asked = 0;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@@ -50,8 +60,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.indexIsChanging) {
_nextQuestion();
}
setState(() {}); setState(() {});
_nextQuestion();
}); });
_dg = widget.distractorGenerator ?? DistractorGenerator(); _dg = widget.distractorGenerator ?? DistractorGenerator();
_loadSettings(); _loadSettings();
@@ -105,7 +117,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
_nextQuestion(); for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i);
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_status = 'Error: $e'; _status = 'Error: $e';
@@ -135,8 +149,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return _ReadingInfo(readingsList, hint); return _ReadingInfo(readingsList, hint);
} }
QuizMode get _mode { QuizMode _modeForIndex(int index) {
switch (_tabController.index) { switch (index) {
case 0: case 0:
return QuizMode.kanjiToEnglish; return QuizMode.kanjiToEnglish;
case 1: case 1:
@@ -148,127 +162,153 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
} }
} }
void _nextQuestion() { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; if (_deck.isEmpty) return;
final quizState = _quizStates[index ?? _tabController.index];
final mode = _modeForIndex(index ?? _tabController.index);
_deck.sort((a, b) { _deck.sort((a, b) {
String srsKey(KanjiItem item) { int getSrsStage(KanjiItem item) {
var key = _mode.toString(); if (mode == QuizMode.reading) {
if (_mode == QuizMode.reading) { final onyomiStage = item.srsItems['${QuizMode.reading}onyomi']?.srsStage;
if (item.onyomi.isNotEmpty && item.kunyomi.isNotEmpty) { final kunyomiStage = item.srsItems['${QuizMode.reading}kunyomi']?.srsStage;
key += _random.nextBool() ? 'onyomi' : 'kunyomi';
} else if (item.onyomi.isNotEmpty) { if (onyomiStage != null && kunyomiStage != null) {
key += 'onyomi'; return min(onyomiStage, kunyomiStage);
} else {
key += 'kunyomi';
} }
return onyomiStage ?? kunyomiStage ?? 0;
} }
return key; return item.srsItems[mode.toString()]?.srsStage ?? 0;
} }
final aSrsItem = a.srsItems[srsKey(a)]; DateTime getLastAsked(KanjiItem item) {
final bSrsItem = b.srsItems[srsKey(b)]; if (mode == QuizMode.reading) {
final onyomiLastAsked = item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
final kunyomiLastAsked = item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
final aStage = aSrsItem?.srsStage ?? 0; if (onyomiLastAsked != null && kunyomiLastAsked != null) {
final bStage = bSrsItem?.srsStage ?? 0; 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) { if (aStage != bStage) {
return aStage.compareTo(bStage); return aStage.compareTo(bStage);
} }
final aLastAsked = final aLastAsked = getLastAsked(a);
aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0); final bLastAsked = getLastAsked(b);
final bLastAsked =
bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
if (aLastAsked != bLastAsked) { return aLastAsked.compareTo(bLastAsked);
return aLastAsked.compareTo(bLastAsked);
}
return _random.nextDouble().compareTo(_random.nextDouble());
}); });
_current = _deck.first; quizState.current = _deck.first;
quizState.key = UniqueKey();
_correctAnswers = []; quizState.correctAnswers = [];
_options = []; quizState.options = [];
_readingHint = ''; quizState.readingHint = '';
quizState.selectedOption = null;
quizState.showResult = false;
switch (_mode) { switch (mode) {
case QuizMode.kanjiToEnglish: case QuizMode.kanjiToEnglish:
_correctAnswers = [_current!.meanings.first]; quizState.correctAnswers = [quizState.current!.meanings.first];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateMeanings(_current!, _deck, 3) ..._dg.generateMeanings(quizState.current!, _deck, 3)
].map(_toTitleCase).toList() ].map(_toTitleCase).toList()
..shuffle(); ..shuffle();
break; break;
case QuizMode.englishToKanji: case QuizMode.englishToKanji:
_correctAnswers = [_current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateKanji(_current!, _deck, 3) ..._dg.generateKanji(quizState.current!, _deck, 3)
]..shuffle(); ]..shuffle();
break; break;
case QuizMode.reading: case QuizMode.reading:
final info = _pickReading(_current!); final info = _pickReading(quizState.current!);
_correctAnswers = info.correctReadings; quizState.correctAnswers = info.correctReadings;
_readingHint = info.hint; quizState.readingHint = info.hint;
final readingsSource = _readingHint.contains("on'yomi") final readingsSource = quizState.readingHint.contains("on'yomi")
? _deck.expand((k) => k.onyomi) ? _deck.expand((k) => k.onyomi)
: _deck.expand((k) => k.kunyomi); : _deck.expand((k) => k.kunyomi);
final distractors = readingsSource final distractors = readingsSource
.where((r) => !_correctAnswers.contains(r)) .where((r) => !quizState.correctAnswers.contains(r))
.toSet() .toSet()
.toList() .toList()
..shuffle(); ..shuffle();
_options = ([ quizState.options = ([
_correctAnswers[_random.nextInt(_correctAnswers.length)], quizState.correctAnswers[_random.nextInt(quizState.correctAnswers.length)],
...distractors.take(3) ...distractors.take(3)
]) ])
..shuffle(); ..shuffle();
break; break;
} }
setState(() {}); setState(() {
_isAnswering = false;
});
} }
void _answer(String option) async { void _answer(String option) async {
final isCorrect = _correctAnswers final quizState = _currentQuizState;
final mode = _modeForIndex(_tabController.index);
final isCorrect = quizState.correctAnswers
.map((a) => a.toLowerCase().trim()) .map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim()); .contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<DeckRepository>(context, listen: false);
final current = _current!; final current = quizState.current!;
String readingType = ''; String readingType = '';
if (_mode == QuizMode.reading) { if (mode == QuizMode.reading) {
readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; readingType = quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi';
} }
final srsKey = _mode.toString() + readingType; final srsKey = mode.toString() + readingType;
var srsItem = current.srsItems[srsKey]; var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null; final isNew = srsItem == null;
final srsItemForUpdate = srsItem ??= final srsItemForUpdate = srsItem ??=
SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType); SrsItem(kanjiId: current.id, quizMode: mode, readingType: readingType);
setState(() {
_asked += 1; quizState.asked += 1;
if (isCorrect) {
_score += 1; quizState.selectedOption = option;
srsItemForUpdate.srsStage += 1;
if (_playCorrectSound) { quizState.showResult = true;
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} setState(() {}); // Trigger UI rebuild to show selected/correct colors
} else {
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
if (isCorrect) {
quizState.score += 1;
srsItemForUpdate.srsStage += 1;
if (_playCorrectSound) {
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} }
srsItemForUpdate.lastAsked = DateTime.now(); } else {
current.srsItems[srsKey] = srsItemForUpdate; srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
}); }
srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate;
if (isNew) { if (isNew) {
await repo.insertSrsItem(srsItemForUpdate); await repo.insertSrsItem(srsItemForUpdate);
@@ -276,11 +316,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
await repo.updateSrsItem(srsItemForUpdate); await repo.updateSrsItem(srsItemForUpdate);
} }
final correctDisplay = (_mode == QuizMode.kanjiToEnglish) final correctDisplay = (mode == QuizMode.kanjiToEnglish)
? _toTitleCase(_correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: (_mode == QuizMode.reading : (mode == QuizMode.reading
? _correctAnswers.join(', ') ? quizState.correctAnswers.join(', ')
: _correctAnswers.first); : quizState.correctAnswers.first);
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
@@ -297,7 +337,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
ScaffoldMessenger.of(context).showSnackBar(snack); ScaffoldMessenger.of(context).showSnackBar(snack);
} }
Future.delayed(const Duration(milliseconds: 900), _nextQuestion); setState(() {
_isAnswering = true; // Disable input after showing result
});
Future.delayed(const Duration(milliseconds: 900), () => _nextQuestion());
} }
@override @override
@@ -326,22 +370,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
); );
} }
String prompt = '';
String subtitle = '';
switch (_mode) {
case QuizMode.kanjiToEnglish:
prompt = _current?.characters ?? '';
break;
case QuizMode.englishToKanji:
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : '';
break;
case QuizMode.reading:
prompt = _current?.characters ?? '';
subtitle = _readingHint;
break;
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Kanji Quiz'), title: const Text('Kanji Quiz'),
@@ -355,62 +383,97 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
), ),
), ),
backgroundColor: const Color(0xFF121212), backgroundColor: const Color(0xFF121212),
body: Padding( body: TabBarView(
padding: const EdgeInsets.all(16.0), controller: _tabController,
child: Column( children: [
children: [ _buildQuizPage(0),
Row( _buildQuizPage(1),
children: [ _buildQuizPage(2),
Expanded( ],
child: Text( ),
_status, );
style: const TextStyle(color: Colors.white), }
),
Widget _buildQuizPage(int index) {
final quizState = _quizStates[index];
final mode = _modeForIndex(index);
String prompt = '';
String subtitle = '';
if (quizState.current != null) {
switch (mode) {
case QuizMode.kanjiToEnglish:
prompt = quizState.current!.characters;
break;
case QuizMode.englishToKanji:
prompt = _toTitleCase(quizState.current!.meanings.first);
break;
case QuizMode.reading:
prompt = quizState.current!.characters;
subtitle = quizState.readingHint;
break;
}
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
_status,
style: const TextStyle(color: Colors.white),
),
),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
const SizedBox(height: 18),
Expanded(
flex: 3,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 0,
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(
characters: prompt,
subtitle: subtitle,
backgroundColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [
OptionsGrid(
options: quizState.options,
onSelected: _isAnswering ? (option) {} : _answer,
isDisabled: false,
selectedOption: null,
correctAnswers: [],
showResult: false,
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: const TextStyle(color: Colors.white),
), ),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
], ],
), ),
const SizedBox(height: 18), ),
Expanded( ],
flex: 3,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 0,
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(
characters: prompt,
subtitle: subtitle,
backgroundColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [
OptionsGrid(
options: _options,
onSelected: _answer,
buttonColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
const SizedBox(height: 8),
Text(
'Score: $_score / $_asked',
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
), ),
); );
} }

View File

@@ -4,13 +4,26 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../services/deck_repository.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/distractor_generator.dart'; import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
class _QuizState {
VocabularyItem? current;
List<String> options = [];
List<String> correctAnswers = [];
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
List<VocabularyItem> shuffledDeck = [];
int currentIndex = 0;
}
class VocabScreen extends StatefulWidget { class VocabScreen extends StatefulWidget {
const VocabScreen({super.key}); const VocabScreen({super.key});
@@ -22,15 +35,14 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
late TabController _tabController; late TabController _tabController;
List<VocabularyItem> _deck = []; List<VocabularyItem> _deck = [];
bool _loading = false; bool _loading = false;
bool _isAnswering = false;
String _status = 'Loading deck...'; String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator(); final DistractorGenerator _dg = DistractorGenerator();
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
VocabularyItem? _current; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
List<String> _options = []; _QuizState get _currentQuizState => _quizStates[_tabController.index];
List<String> _correctAnswers = [];
int _score = 0;
int _asked = 0;
bool _playAudio = true; bool _playAudio = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@@ -40,8 +52,10 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.indexIsChanging) {
_nextQuestion();
}
setState(() {}); setState(() {});
_nextQuestion();
}); });
_loadSettings(); _loadSettings();
_loadDeck(); _loadDeck();
@@ -68,7 +82,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
}); });
try { try {
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<VocabDeckRepository>(context, listen: false);
await repo.loadApiKey(); await repo.loadApiKey();
final apiKey = repo.apiKey; final apiKey = repo.apiKey;
@@ -96,7 +110,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
_nextQuestion(); for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i);
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_status = 'Error: $e'; _status = 'Error: $e';
@@ -113,8 +129,8 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
.join(' '); .join(' ');
} }
VocabQuizMode get _mode { VocabQuizMode _modeForIndex(int index) {
switch (_tabController.index) { switch (index) {
case 0: case 0:
return VocabQuizMode.vocabToEnglish; return VocabQuizMode.vocabToEnglish;
case 1: case 1:
@@ -126,70 +142,84 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
} }
} }
void _nextQuestion() { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; if (_deck.isEmpty) return;
List<VocabularyItem> deck = _deck; final quizState = _quizStates[index ?? _tabController.index];
if (_mode == VocabQuizMode.audioToEnglish) { final mode = _modeForIndex(index ?? _tabController.index);
deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
if (deck.isEmpty) { List<VocabularyItem> currentDeckForMode = _deck;
if (mode == VocabQuizMode.audioToEnglish) {
currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
if (currentDeckForMode.isEmpty) {
setState(() { setState(() {
_status = 'No vocabulary with audio found.'; _status = 'No vocabulary with audio found.';
_current = null; quizState.current = null;
}); });
return; return;
} }
} }
deck.sort((a, b) { // If it's a new session or we've gone through all shuffled items, re-shuffle
final aSrsItem = a.srsItems[_mode.toString()] ?? if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) {
VocabSrsItem(vocabId: a.id, quizMode: _mode); quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy
final bSrsItem = b.srsItems[_mode.toString()] ?? // Apply sorting based on SRS stages here, but only once per shuffle
VocabSrsItem(vocabId: b.id, quizMode: _mode); quizState.shuffledDeck.sort((a, b) {
final aSrsItem = a.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: a.id, quizMode: mode);
final bSrsItem = b.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: b.id, quizMode: mode);
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) {
return stageComparison;
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
});
quizState.currentIndex = 0;
}
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage); quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck
if (stageComparison != 0) { quizState.currentIndex++; // Advance index
return stageComparison;
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
});
_current = deck.first; quizState.key = UniqueKey();
if (_mode == VocabQuizMode.audioToEnglish) { if (mode == VocabQuizMode.audioToEnglish) {
_playCurrentAudio(); _playCurrentAudio();
} }
_correctAnswers = []; quizState.correctAnswers = [];
_options = []; quizState.options = [];
quizState.selectedOption = null;
quizState.showResult = false;
switch (_mode) { switch (mode) {
case VocabQuizMode.vocabToEnglish: case VocabQuizMode.vocabToEnglish:
case VocabQuizMode.audioToEnglish: case VocabQuizMode.audioToEnglish:
_correctAnswers = [_current!.meanings.first]; quizState.correctAnswers = [quizState.current!.meanings.first];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateVocabMeanings(_current!, _deck, 3) ..._dg.generateVocabMeanings(quizState.current!, _deck, 3)
].map(_toTitleCase).toList() ].map(_toTitleCase).toList()
..shuffle(); ..shuffle();
break; break;
case VocabQuizMode.englishToVocab: case VocabQuizMode.englishToVocab:
_correctAnswers = [_current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateVocab(_current!, _deck, 3) ..._dg.generateVocab(quizState.current!, _deck, 3)
]..shuffle(); ]..shuffle();
break; break;
} }
setState(() {}); setState(() {
_isAnswering = false;
});
} }
Future<void> _playCurrentAudio() async { Future<void> _playCurrentAudio() async {
if (_current == null || _current!.pronunciationAudios.isEmpty) return; final current = _currentQuizState.current;
if (current == null || current.pronunciationAudios.isEmpty) return;
final maleAudios = _current!.pronunciationAudios.where((a) => a.gender == 'male'); final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male');
final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : _current!.pronunciationAudios.first.url); final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url);
try { try {
await _audioPlayer.play(UrlSource(audioUrl)); await _audioPlayer.play(UrlSource(audioUrl));
@@ -199,31 +229,35 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
} }
void _answer(String option) async { void _answer(String option) async {
final isCorrect = _correctAnswers final quizState = _currentQuizState;
final mode = _modeForIndex(_tabController.index);
final isCorrect = quizState.correctAnswers
.map((a) => a.toLowerCase().trim()) .map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim()); .contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<VocabDeckRepository>(context, listen: false);
final current = _current!; final current = quizState.current!;
final srsKey = _mode.toString(); final srsKey = mode.toString();
var srsItemNullable = current.srsItems[srsKey]; var srsItemNullable = current.srsItems[srsKey];
final isNew = srsItemNullable == null; final isNew = srsItemNullable == null;
final srsItem = final srsItem =
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode); srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode);
setState(() { quizState.asked += 1;
_asked += 1; quizState.selectedOption = option;
if (isCorrect) { quizState.showResult = true;
_score += 1; setState(() {}); // Trigger UI rebuild to show selected/correct colors
srsItem.srsStage += 1;
} else { if (isCorrect) {
srsItem.srsStage = max(0, srsItem.srsStage - 1); quizState.score += 1;
} srsItem.srsStage += 1;
srsItem.lastAsked = DateTime.now(); } else {
current.srsItems[srsKey] = srsItem; srsItem.srsStage = max(0, srsItem.srsStage - 1);
}); }
srsItem.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItem;
if (isNew) { if (isNew) {
await repo.insertVocabSrsItem(srsItem); await repo.insertVocabSrsItem(srsItem);
@@ -231,9 +265,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
await repo.updateVocabSrsItem(srsItem); await repo.updateVocabSrsItem(srsItem);
} }
final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish) final correctDisplay = (mode == VocabQuizMode.vocabToEnglish)
? _toTitleCase(_correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: _correctAnswers.first; : quizState.correctAnswers.first;
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
@@ -254,7 +288,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
if (_playCorrectSound) { if (_playCorrectSound) {
await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); await _audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} }
if (_playAudio && _mode != VocabQuizMode.audioToEnglish) { if (_playAudio && mode != VocabQuizMode.audioToEnglish) {
final maleAudios = final maleAudios =
current.pronunciationAudios.where((a) => a.gender == 'male'); current.pronunciationAudios.where((a) => a.gender == 'male');
if (maleAudios.isNotEmpty) { if (maleAudios.isNotEmpty) {
@@ -274,9 +308,13 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
} }
} }
} else { } else {
await Future.delayed(const Duration(milliseconds: 900)); // No fixed delay for incorrect answers
} }
setState(() {
_isAnswering = true; // Disable input after showing result
});
_nextQuestion(); _nextQuestion();
} }
@@ -289,7 +327,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)), const Text('WaniKani API key is not set.'),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
@@ -306,31 +344,6 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
); );
} }
Widget promptWidget;
if (_current == null) {
promptWidget = const SizedBox.shrink();
} else if (_mode == VocabQuizMode.audioToEnglish) {
promptWidget = IconButton(
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64),
onPressed: _playCurrentAudio,
);
} else {
String promptText = '';
switch (_mode) {
case VocabQuizMode.vocabToEnglish:
promptText = _current?.characters ?? '';
break;
case VocabQuizMode.englishToVocab:
promptText = _current != null ? _toTitleCase(_current!.meanings.first) : '';
break;
case VocabQuizMode.audioToEnglish:
// Handled above
break;
}
promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white));
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Vocabulary Quiz'), title: const Text('Vocabulary Quiz'),
@@ -343,63 +356,101 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
], ],
), ),
), ),
backgroundColor: const Color(0xFF121212), body: TabBarView(
body: Padding( controller: _tabController,
padding: const EdgeInsets.all(16.0), children: [
child: Column( _buildQuizPage(0),
children: [ _buildQuizPage(1),
Row( _buildQuizPage(2),
children: [ ],
Expanded( ),
child: Text( );
_status, }
style: const TextStyle(color: Colors.white),
), Widget _buildQuizPage(int index) {
final quizState = _quizStates[index];
final mode = _modeForIndex(index);
Widget promptWidget;
if (quizState.current == null) {
promptWidget = const SizedBox.shrink();
} else if (mode == VocabQuizMode.audioToEnglish) {
promptWidget = IconButton(
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64),
onPressed: _playCurrentAudio,
);
} else {
String promptText = '';
switch (mode) {
case VocabQuizMode.vocabToEnglish:
promptText = quizState.current!.characters;
break;
case VocabQuizMode.englishToVocab:
promptText = _toTitleCase(quizState.current!.meanings.first);
break;
case VocabQuizMode.audioToEnglish:
// Handled above
break;
}
promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white));
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
_status,
),
),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
const SizedBox(height: 18),
Expanded(
flex: 3,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 0,
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(
characterWidget: promptWidget,
subtitle: '',
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [
OptionsGrid(
options: quizState.options,
onSelected: _isAnswering ? (option) {} : _answer,
isDisabled: false,
selectedOption: null,
correctAnswers: [],
showResult: false,
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: const TextStyle(color: Colors.white),
), ),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
], ],
), ),
const SizedBox(height: 18), ),
Expanded( ],
flex: 3,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 0,
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(
characterWidget: promptWidget,
subtitle: '',
backgroundColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [
OptionsGrid(
options: _options,
onSelected: _answer,
buttonColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
const SizedBox(height: 8),
Text(
'Score: $_score / $_asked',
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
), ),
); );
} }

View File

@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@@ -263,154 +262,5 @@ class DeckRepository {
return items; return items;
} }
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async {
final db = await _openDb();
final rows = await db.query(
'srs_vocab_items',
where: 'vocabId = ?',
whereArgs: [vocabId],
);
return rows.map((r) {
return VocabSrsItem(
vocabId: r['vocabId'] as int,
quizMode: VocabQuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String,
),
srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String),
);
}).toList();
}
Future<void> updateVocabSrsItem(VocabSrsItem item) async {
final db = await _openDb();
await db.update(
'srs_vocab_items',
{
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
},
where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.vocabId, item.quizMode.toString()],
);
}
Future<void> insertVocabSrsItem(VocabSrsItem item) async {
final db = await _openDb();
await db.insert('srs_vocab_items', {
'vocabId': item.vocabId,
'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveVocabulary(List<VocabularyItem> items) async {
final db = await _openDb();
final batch = db.batch();
for (final it in items) {
final audios = it.pronunciationAudios
.map((a) => {'url': a.url, 'gender': a.gender})
.toList();
batch.insert('vocabulary', {
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'readings': it.readings.join('|'),
'pronunciation_audios': jsonEncode(audios),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
Future<List<VocabularyItem>> loadVocabulary() async {
final db = await _openDb();
final rows = await db.query('vocabulary');
final vocabItems = rows.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?;
final List<PronunciationAudio> audios = [];
if (audiosRaw != null && audiosRaw.isNotEmpty) {
try {
final decoded = jsonDecode(audiosRaw) as List;
for (final audioData in decoded) {
audios.add(
PronunciationAudio(
url: audioData['url'] as String,
gender: audioData['gender'] as String,
),
);
}
} catch (e) {
// Error decoding, so we'll just have no audio for this item
}
}
return VocabularyItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
readings: (r['readings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
pronunciationAudios: audios,
);
}).toList();
for (final item in vocabItems) {
final srsItems = await getVocabSrsItems(item.id);
for (final srsItem in srsItems) {
final key = srsItem.quizMode.toString();
item.srsItems[key] = srsItem;
}
}
return vocabItems;
}
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([
String? apiKey,
]) async {
final key = apiKey ?? _apiKey;
if (key == null) throw Exception('API key not set');
final client = WkClient(key);
final assignments = await client.fetchAllAssignments(
subjectTypes: ['vocabulary'],
);
final unlocked = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
final sidRaw = data['subject_id'];
if (sidRaw == null) continue;
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
if (sid == null) continue;
final started = data['started_at'];
final srs = data['srs_stage'];
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
if (isUnlocked) unlocked.add(sid);
}
if (unlocked.isEmpty) return [];
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
final items = subjects
.where(
(s) =>
s['object'] == 'vocabulary' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'vocabulary'),
)
.map((s) => VocabularyItem.fromSubject(s))
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
.toList();
await saveVocabulary(items);
return items;
}
} }

View File

@@ -0,0 +1,280 @@
import 'dart:async';
import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart';
import '../api/wk_client.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
class VocabDeckRepository {
Database? _db;
String? _apiKey;
Future<void> setApiKey(String apiKey) async {
_apiKey = apiKey;
await saveApiKey(apiKey);
}
String? get apiKey => _apiKey;
Future<Database> _openDb() async {
if (_db != null) return _db!;
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'wanikani_srs.db');
_db = await openDatabase(
path,
version: 7,
onCreate: (db, version) async {
await db.execute(
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''',
);
await db.execute(
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
);
await db.execute(
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute(
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''',
);
}
if (oldVersion < 3) {
// Migration from version 2 to 3 was flawed, so we just drop the columns if they exist
}
if (oldVersion < 4) {
await db.execute(
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
);
// We are not migrating the old srs data, as it was not mode-specific.
// Old columns will be dropped.
}
if (oldVersion < 5) {
await db.execute(
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
);
}
if (oldVersion < 6) {
try {
await db.execute(
'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT',
);
} catch (_) {
// Ignore error, column might already exist
}
}
if (oldVersion < 7) {
try {
await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER');
await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER');
} catch (_) {
// Ignore error, column might already exist
}
}
},
);
return _db!;
}
Future<void> saveApiKey(String apiKey) async {
final db = await _openDb();
await db.insert('settings', {
'key': 'apiKey',
'value': apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<String?> loadApiKey() async {
String? envApiKey;
try {
envApiKey = dotenv.env['WANIKANI_API_KEY'];
} catch (e) {
// dotenv is not initialized, so we can't get the key.
// This is expected in release builds.
envApiKey = null;
}
if (envApiKey != null && envApiKey.isNotEmpty) {
_apiKey = envApiKey;
return _apiKey;
}
final db = await _openDb();
final rows = await db.query(
'settings',
where: 'key = ?',
whereArgs: ['apiKey'],
);
if (rows.isNotEmpty) {
_apiKey = rows.first['value'] as String;
return _apiKey;
}
return null;
}
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async {
final db = await _openDb();
final rows = await db.query(
'srs_vocab_items',
where: 'vocabId = ?',
whereArgs: [vocabId],
);
return rows.map((r) {
return VocabSrsItem(
vocabId: r['vocabId'] as int,
quizMode: VocabQuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String,
),
srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String),
);
}).toList();
}
Future<void> updateVocabSrsItem(VocabSrsItem item) async {
final db = await _openDb();
await db.update(
'srs_vocab_items',
{
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
},
where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.vocabId, item.quizMode.toString()],
);
}
Future<void> insertVocabSrsItem(VocabSrsItem item) async {
final db = await _openDb();
await db.insert('srs_vocab_items', {
'vocabId': item.vocabId,
'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveVocabulary(List<VocabularyItem> items) async {
final db = await _openDb();
final batch = db.batch();
for (final it in items) {
final audios = it.pronunciationAudios
.map((a) => {'url': a.url, 'gender': a.gender})
.toList();
batch.insert('vocabulary', {
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'readings': it.readings.join('|'),
'pronunciation_audios': jsonEncode(audios),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
Future<List<VocabularyItem>> loadVocabulary() async {
final db = await _openDb();
final rows = await db.query('vocabulary');
final vocabItems = rows.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?;
final List<PronunciationAudio> audios = [];
if (audiosRaw != null && audiosRaw.isNotEmpty) {
try {
final decoded = jsonDecode(audiosRaw) as List;
for (final audioData in decoded) {
audios.add(
PronunciationAudio(
url: audioData['url'] as String,
gender: audioData['gender'] as String,
),
);
}
} catch (e) {
// Error decoding, so we'll just have no audio for this item
}
}
return VocabularyItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
readings: (r['readings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
pronunciationAudios: audios,
);
}).toList();
for (final item in vocabItems) {
final srsItems = await getVocabSrsItems(item.id);
for (final srsItem in srsItems) {
final key = srsItem.quizMode.toString();
item.srsItems[key] = srsItem;
}
}
return vocabItems;
}
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([
String? apiKey,
]) async {
final key = apiKey ?? _apiKey;
if (key == null) throw Exception('API key not set');
final client = WkClient(key);
final assignments = await client.fetchAllAssignments(
subjectTypes: ['vocabulary'],
);
final unlocked = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
final sidRaw = data['subject_id'];
if (sidRaw == null) continue;
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
if (sid == null) continue;
final started = data['started_at'];
final srs = data['srs_stage'];
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
if (isUnlocked) unlocked.add(sid);
}
if (unlocked.isEmpty) return [];
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
final items = subjects
.where(
(s) =>
s['object'] == 'vocabulary' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'vocabulary'),
)
.map((s) => VocabularyItem.fromSubject(s))
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
.toList();
await saveVocabulary(items);
return items;
}
}

View File

@@ -5,6 +5,10 @@ class OptionsGrid extends StatelessWidget {
final void Function(String) onSelected; final void Function(String) onSelected;
final Color? buttonColor; final Color? buttonColor;
final Color? textColor; final Color? textColor;
final bool isDisabled;
final String? selectedOption;
final List<String>? correctAnswers;
final bool showResult;
const OptionsGrid({ const OptionsGrid({
super.key, super.key,
@@ -12,6 +16,10 @@ class OptionsGrid extends StatelessWidget {
required this.onSelected, required this.onSelected,
this.buttonColor, this.buttonColor,
this.textColor, this.textColor,
this.isDisabled = false,
this.selectedOption,
this.correctAnswers,
this.showResult = false,
}); });
@override @override
@@ -27,13 +35,24 @@ class OptionsGrid extends StatelessWidget {
runSpacing: 10, runSpacing: 10,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: options.map((o) { children: options.map((o) {
Color currentButtonColor = bg;
Color currentTextColor = fg;
if (showResult) {
if (correctAnswers != null && correctAnswers!.contains(o)) {
currentButtonColor = Colors.green;
} else if (o == selectedOption) {
currentButtonColor = Colors.red;
}
}
return SizedBox( return SizedBox(
width: 160, width: 160,
child: ElevatedButton( child: ElevatedButton(
onPressed: () => onSelected(o), onPressed: isDisabled ? null : () => onSelected(o),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: bg, backgroundColor: currentButtonColor,
foregroundColor: fg, foregroundColor: currentTextColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -41,7 +60,7 @@ class OptionsGrid extends StatelessWidget {
), ),
child: Text( child: Text(
o, o,
style: TextStyle(fontSize: 20, color: fg), style: TextStyle(fontSize: 20, color: currentTextColor),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -49,4 +68,4 @@ class OptionsGrid extends StatelessWidget {
}).toList(), }).toList(),
); );
} }
} }