From d8edfa168647fc246fd7a60cf4d543a80576f746 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Fri, 31 Oct 2025 07:16:44 +0100 Subject: [PATCH] change a bunch of stuff, seperate tracking for progress, updated custom srs layout --- lib/main.dart | 8 +- lib/src/models/custom_kanji_item.dart | 77 ++- lib/src/screens/add_card_screen.dart | 12 +- lib/src/screens/browse_screen.dart | 473 ++++++++++++++---- .../screens/custom_card_details_screen.dart | 13 +- lib/src/screens/custom_quiz_screen.dart | 236 ++++++--- lib/src/screens/custom_srs_screen.dart | 55 +- lib/src/screens/home_screen.dart | 363 ++++++++------ lib/src/screens/vocab_screen.dart | 343 +++++++------ lib/src/services/deck_repository.dart | 150 ------ lib/src/services/vocab_deck_repository.dart | 280 +++++++++++ lib/src/widgets/options_grid.dart | 29 +- 12 files changed, 1378 insertions(+), 661 deletions(-) create mode 100644 lib/src/services/vocab_deck_repository.dart diff --git a/lib/main.dart b/lib/main.dart index 9fc233d..0fe6d68 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'src/services/deck_repository.dart'; @@ -14,8 +15,11 @@ void main() async { } runApp( - Provider( - create: (_) => DeckRepository(), + MultiProvider( + providers: [ + Provider(create: (_) => DeckRepository()), + Provider(create: (_) => VocabDeckRepository()), + ], child: const WkApp(), ), ); diff --git a/lib/src/models/custom_kanji_item.dart b/lib/src/models/custom_kanji_item.dart index 2d8bd0e..c045761 100644 --- a/lib/src/models/custom_kanji_item.dart +++ b/lib/src/models/custom_kanji_item.dart @@ -4,28 +4,44 @@ class CustomKanjiItem { final String meaning; final String? kanji; final bool useInterval; - int srsLevel; - DateTime? nextReview; + SrsData srsData; CustomKanjiItem({ required this.characters, required this.meaning, this.kanji, this.useInterval = false, - this.srsLevel = 0, - this.nextReview, - }); + SrsData? srsData, + }) : srsData = srsData ?? SrsData(); factory CustomKanjiItem.fromJson(Map 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( characters: json['characters'] as String, meaning: json['meaning'] as String, kanji: json['kanji'] as String?, useInterval: json['useInterval'] as bool? ?? false, - srsLevel: json['srsLevel'] as int? ?? 0, - nextReview: json['nextReview'] != null - ? DateTime.parse(json['nextReview'] as String) - : null, + srsData: srsData, ); } @@ -35,8 +51,47 @@ class CustomKanjiItem { 'meaning': meaning, 'kanji': kanji, 'useInterval': useInterval, - 'srsLevel': srsLevel, - 'nextReview': nextReview?.toIso8601String(), + 'srsData': srsData.toJson(), + }; + } +} + +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 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 toJson() { + return { + 'japaneseToEnglish': japaneseToEnglish, + 'japaneseToEnglishNextReview': japaneseToEnglishNextReview?.toIso8601String(), + 'englishToJapanese': englishToJapanese, + 'englishToJapaneseNextReview': englishToJapaneseNextReview?.toIso8601String(), + 'listeningComprehension': listeningComprehension, + 'listeningComprehensionNextReview': listeningComprehensionNextReview?.toIso8601String(), }; } } diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart index c3cd3de..1969a79 100644 --- a/lib/src/screens/add_card_screen.dart +++ b/lib/src/screens/add_card_screen.dart @@ -50,12 +50,20 @@ class _AddCardScreenState extends State { void _saveCard() { if (_formKey.currentState!.validate()) { + final srsData = _useInterval + ? SrsData( + japaneseToEnglishNextReview: DateTime.now(), + englishToJapaneseNextReview: DateTime.now(), + listeningComprehensionNextReview: DateTime.now(), + ) + : SrsData(); + final newItem = CustomKanjiItem( characters: _japaneseController.text, meaning: _englishController.text, - kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null, + kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, useInterval: _useInterval, - nextReview: _useInterval ? DateTime.now() : null, + srsData: srsData, ); _deckRepository.addCard(newItem); Navigator.of(context).pop(); diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index 909bb2f..823de0f 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -1,11 +1,15 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; import '../models/kanji_item.dart'; import '../services/deck_repository.dart'; +import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/custom_deck_repository.dart'; import '../models/custom_kanji_item.dart'; import 'settings_screen.dart'; import 'custom_card_details_screen.dart'; +import 'add_card_screen.dart'; class BrowseScreen extends StatefulWidget { const BrowseScreen({super.key}); @@ -222,8 +226,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt final item = items[index]; return GestureDetector( onTap: () => _showReadingsDialog(item), - child: - _buildSrsItemCard(item.characters, item.srsItems.values.toList()), + child: _buildSrsItemCard(item), ); }, padding: const EdgeInsets.all(8), @@ -241,53 +244,87 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } Widget _buildVocabListTile(VocabularyItem item) { - final avgSrsStage = item.srsItems.isNotEmpty - ? item.srsItems.values - .map((s) => s.srsStage) - .reduce((a, b) => a + b) / - item.srsItems.length - : 0.0; + final requiredModes = [ + VocabQuizMode.vocabToEnglish.toString(), + VocabQuizMode.englishToVocab.toString(), + VocabQuizMode.audioToEnglish.toString(), + ]; - return 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), + 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 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), - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.meanings.join(', '), - style: const TextStyle(color: Colors.grey), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - _buildSrsIndicator(avgSrsStage.round()), - ], + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.meanings.join(', '), + style: const TextStyle(color: Colors.grey), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + _buildSrsIndicator(minSrsStage), + ], + ), ), - ), - ], + ], + ), ), ), ); } - Widget _buildSrsItemCard(String characters, List srsItems) { - final avgSrsStage = srsItems.isNotEmpty - ? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) / - srsItems.length - : 0.0; + Widget _buildSrsItemCard(KanjiItem item) { + final requiredModes = [ + QuizMode.kanjiToEnglish.toString(), + QuizMode.englishToKanji.toString(), + ]; + 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( color: const Color(0xFF1E1E1E), @@ -297,12 +334,12 @@ class _BrowseScreenState extends State with SingleTickerProviderSt mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - characters, + item.characters, style: const TextStyle(fontSize: 32, color: Colors.white), textAlign: TextAlign.center, ), const SizedBox(height: 8), - _buildSrsIndicator(avgSrsStage.round()), + _buildSrsIndicator(minSrsStage), ], ), ), @@ -339,6 +376,32 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } void _showReadingsDialog(KanjiItem kanji) { + final srsScores = { + '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( context: context, builder: (context) { @@ -348,37 +411,51 @@ class _BrowseScreenState extends State with SingleTickerProviderSt 'Details for ${kanji.characters}', style: const TextStyle(color: Colors.white), ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Level: ${kanji.level}', - style: const TextStyle(color: Colors.white), - ), - const SizedBox(height: 16), - if (kanji.meanings.isNotEmpty) + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'Meanings: ${kanji.meanings.join(', ')}', + 'Level: ${kanji.level}', style: const TextStyle(color: Colors.white), ), - const SizedBox(height: 16), - if (kanji.onyomi.isNotEmpty) - Text( - 'On\'yomi: ${kanji.onyomi.join(', ')}', - style: const TextStyle(color: Colors.white), - ), - if (kanji.kunyomi.isNotEmpty) - Text( - 'Kun\'yomi: ${kanji.kunyomi.join(', ')}', - style: const TextStyle(color: Colors.white), - ), - if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) + const SizedBox(height: 16), + if (kanji.meanings.isNotEmpty) + Text( + 'Meanings: ${kanji.meanings.join(', ')}', + style: const TextStyle(color: Colors.white), + ), + const SizedBox(height: 16), + if (kanji.onyomi.isNotEmpty) + Text( + 'On\'yomi: ${kanji.onyomi.join(', ')}', + style: const TextStyle(color: Colors.white), + ), + 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( - 'No readings available.', - style: const TextStyle(color: Colors.white), + '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), + )), + ], + ), ), actions: [ TextButton( @@ -395,9 +472,11 @@ class _BrowseScreenState extends State with SingleTickerProviderSt Future _loadDecks() async { setState(() => _loading = true); try { - final repo = Provider.of(context, listen: false); - await repo.loadApiKey(); - final apiKey = repo.apiKey; + final kanjiRepo = Provider.of(context, listen: false); + final vocabRepo = + Provider.of(context, listen: false); + await kanjiRepo.loadApiKey(); + final apiKey = kanjiRepo.apiKey; if (apiKey == null || apiKey.isEmpty) { setState(() { @@ -407,16 +486,16 @@ class _BrowseScreenState extends State with SingleTickerProviderSt return; } - var kanji = await repo.loadKanji(); + var kanji = await kanjiRepo.loadKanji(); if (kanji.isEmpty || kanji.every((k) => k.level == 0)) { 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)) { setState(() => _status = 'Fetching vocabulary from WaniKani...'); - vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey); + vocab = await vocabRepo.fetchAndCacheVocabularyFromWk(apiKey); } _kanjiDeck = kanji; @@ -458,7 +537,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { return Scaffold( - appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), + appBar: + _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), backgroundColor: const Color(0xFF121212), body: Column( children: [ @@ -478,7 +558,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt _vocabByLevel, _vocabSortedLevels, _vocabPageController, - (items) => _buildListView(items.cast())), + (items) => + _buildListView(items.cast())), ), _buildCustomSrsTab(), ], @@ -487,10 +568,23 @@ class _BrowseScreenState extends State with SingleTickerProviderSt if (!_isSelectionMode) SafeArea( 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 with SingleTickerProviderSt context: context, builder: (context) => AlertDialog( 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: [ TextButton( onPressed: () => Navigator.of(context).pop(), @@ -568,6 +663,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt ), TextButton( onPressed: () async { + final navigator = Navigator.of(context); for (final item in _selectedItems) { await _customDeckRepository.deleteCard(item); } @@ -575,8 +671,9 @@ class _BrowseScreenState extends State with SingleTickerProviderSt _isSelectionMode = false; _selectedItems.clear(); }); - _loadCustomDeck(); - Navigator.of(context).pop(); + await _loadCustomDeck(); + if (!mounted) return; + navigator.pop(); }, child: const Text('Delete'), ), @@ -591,7 +688,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } 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 updatedItems = []; for (final item in _selectedItems) { @@ -600,8 +698,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt meaning: item.meaning, kanji: item.kanji, useInterval: targetState, - srsLevel: item.srsLevel, - nextReview: item.nextReview, + srsData: item.srsData, ); updatedItems.add(updatedItem); } @@ -653,14 +750,16 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } }); } else { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CustomCardDetailsScreen( - item: item, - repository: _customDeckRepository, - ), - ), - ).then((_) => _loadCustomDeck()); + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => CustomCardDetailsScreen( + item: item, + repository: _customDeckRepository, + ), + ), + ) + .then((_) => _loadCustomDeck()); } }, child: Card( @@ -671,7 +770,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt borderRadius: BorderRadius.circular(12.0), ), color: isSelected - ? Colors.blue.withOpacity(0.5) + ? Colors.blue.withAlpha((255 * 0.5).round()) : const Color(0xFF1E1E1E), child: Stack( children: [ @@ -683,20 +782,30 @@ class _BrowseScreenState extends State with SingleTickerProviderSt FittedBox( fit: BoxFit.scaleDown, child: Text( - item.kanji ?? item.characters, - style: const TextStyle(fontSize: 32, color: Colors.white), + item.kanji?.isNotEmpty == true + ? item.kanji! + : item.characters, + style: + const TextStyle(fontSize: 32, color: Colors.white), textAlign: TextAlign.center, ), ), const SizedBox(height: 8), Text( item.meaning, - style: const TextStyle(color: Colors.grey, fontSize: 16), + style: + const TextStyle(color: Colors.grey, fontSize: 16), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), 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 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 _exampleSentences = [const CircularProgressIndicator()]; + + @override + void initState() { + super.initState(); + _fetchExampleSentences(); + } + + Future _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 = []; + 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 = { + '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)), + ), + ], + ); + }, + ); +} \ No newline at end of file diff --git a/lib/src/screens/custom_card_details_screen.dart b/lib/src/screens/custom_card_details_screen.dart index c303127..6c3f2f3 100644 --- a/lib/src/screens/custom_card_details_screen.dart +++ b/lib/src/screens/custom_card_details_screen.dart @@ -19,7 +19,6 @@ class _CustomCardDetailsScreenState extends State { late TextEditingController _englishController; late TextEditingController _kanjiController; late bool _useInterval; - late int _srsLevel; @override void initState() { @@ -28,7 +27,6 @@ class _CustomCardDetailsScreenState extends State { _englishController = TextEditingController(text: widget.item.meaning); _kanjiController = TextEditingController(text: widget.item.kanji); _useInterval = widget.item.useInterval; - _srsLevel = widget.item.srsLevel; } @override @@ -43,10 +41,9 @@ class _CustomCardDetailsScreenState extends State { final updatedItem = CustomKanjiItem( characters: _japaneseController.text, meaning: _englishController.text, - kanji: _kanjiController.text, + kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, useInterval: _useInterval, - srsLevel: _srsLevel, - nextReview: widget.item.nextReview, + srsData: widget.item.srsData, ); widget.repository.updateCard(updatedItem); Navigator.of(context).pop(true); @@ -113,7 +110,11 @@ class _CustomCardDetailsScreenState extends State { }); }, ), - 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), ElevatedButton( onPressed: _saveChanges, diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart index b304703..aaf8040 100644 --- a/lib/src/screens/custom_quiz_screen.dart +++ b/lib/src/screens/custom_quiz_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter_tts/flutter_tts.dart'; import '../models/custom_kanji_item.dart'; import '../widgets/options_grid.dart'; +import '../widgets/kanji_card.dart'; enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension } @@ -11,6 +12,7 @@ class CustomQuizScreen extends StatefulWidget { final CustomQuizMode quizMode; final Function(CustomKanjiItem) onCardReviewed; final bool useKanji; + final bool isActive; const CustomQuizScreen({ super.key, @@ -18,6 +20,7 @@ class CustomQuizScreen extends StatefulWidget { required this.quizMode, required this.onCardReviewed, required this.useKanji, + required this.isActive, }); @override @@ -34,6 +37,7 @@ class CustomQuizScreenState extends State late FlutterTts _flutterTts; late AnimationController _shakeController; late Animation _shakeAnimation; + final List _incorrectlyAnsweredItems = []; @override void initState() { @@ -59,6 +63,17 @@ class CustomQuizScreenState extends State @override void didUpdateWidget(CustomQuizScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.deck != oldWidget.deck && !widget.isActive) { + setState(() { + _shuffledDeck = widget.deck.toList()..shuffle(); + _currentIndex = 0; + _answered = false; + _correct = null; + if (_shuffledDeck.isNotEmpty) { + _generateOptions(); + } + }); + } if (widget.useKanji != oldWidget.useKanji) { setState(() { _generateOptions(); @@ -67,7 +82,7 @@ class CustomQuizScreenState extends State } void playAudio() { - if (widget.quizMode == CustomQuizMode.listeningComprehension) { + if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) { _speak(_shuffledDeck[_currentIndex].characters); } } @@ -75,9 +90,6 @@ class CustomQuizScreenState extends State void _initTts() async { _flutterTts = FlutterTts(); await _flutterTts.setLanguage("ja-JP"); - if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) { - _speak(_shuffledDeck[_currentIndex].characters); - } } @override @@ -108,7 +120,7 @@ class CustomQuizScreenState extends State _options.shuffle(); } - void _checkAnswer(String answer) { + void _checkAnswer(String answer) async { final currentItem = _shuffledDeck[_currentIndex]; final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) @@ -116,42 +128,118 @@ class CustomQuizScreenState extends State final isCorrect = answer == correctAnswer; if (currentItem.useInterval) { - if (isCorrect) { - currentItem.srsLevel++; - final interval = pow(2, currentItem.srsLevel).toInt(); - currentItem.nextReview = DateTime.now().add(Duration(hours: interval)); - } else { - currentItem.srsLevel = max(0, currentItem.srsLevel - 1); - currentItem.nextReview = DateTime.now().add(const Duration(hours: 1)); + int currentSrsLevel; + 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); } - setState(() { - _answered = true; - _correct = isCorrect; - }); + // --- SnackBar Logic (new) --- + final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese) + ? (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 (widget.quizMode == CustomQuizMode.japaneseToEnglish || - widget.quizMode == CustomQuizMode.listeningComprehension) { - _speak(currentItem.characters); + if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { + await _speak(currentItem.characters); } + await Future.delayed(const Duration(milliseconds: 500)); // Small delay after correct answer } else { _shakeController.forward(from: 0); + await Future.delayed(const Duration(milliseconds: 900)); // Delay for shake animation } + + _nextQuestion(); } void _nextQuestion() { setState(() { - _currentIndex = (_currentIndex + 1) % _shuffledDeck.length; + _currentIndex++; _answered = false; _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 _speak(String text) async { @@ -166,7 +254,7 @@ class CustomQuizScreenState extends State @override Widget build(BuildContext context) { - if (_shuffledDeck.isEmpty) { + if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) { return const Center( child: Text('Review session complete!'), ); @@ -177,54 +265,70 @@ class CustomQuizScreenState extends State ? currentItem.meaning : (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( - mainAxisAlignment: MainAxisAlignment.center, children: [ - if (widget.quizMode == CustomQuizMode.listeningComprehension) - IconButton( - icon: const Icon(Icons.volume_up, size: 64), - onPressed: () => _speak(currentItem.characters), - ) - else - GestureDetector( - onTap: () => _speak(question), - child: Text( - question, - style: const TextStyle(fontSize: 48), - textAlign: TextAlign.center, + 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: 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!) - ElevatedButton( - onPressed: _nextQuestion, - child: const Text('Next'), + const SizedBox(height: 12), + SafeArea( + top: false, + 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, + ), + ), + ], ), + ), ], ), ); } -} +} \ No newline at end of file diff --git a/lib/src/screens/custom_srs_screen.dart b/lib/src/screens/custom_srs_screen.dart index 8727e28..8a4861c 100644 --- a/lib/src/screens/custom_srs_screen.dart +++ b/lib/src/screens/custom_srs_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import '../models/custom_kanji_item.dart'; import '../services/custom_deck_repository.dart'; -import 'add_card_screen.dart'; import 'custom_quiz_screen.dart'; class CustomSrsScreen extends StatefulWidget { @@ -15,7 +14,6 @@ class _CustomSrsScreenState extends State with SingleTickerProv late TabController _tabController; final _deckRepository = CustomDeckRepository(); List _deck = []; - List _reviewDeck = []; bool _useKanji = false; final _quizScreenKeys = [ GlobalKey(), @@ -45,17 +43,8 @@ class _CustomSrsScreenState extends State with SingleTickerProv Future _loadDeck() async { 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(() { _deck = deck; - _reviewDeck = reviewDeck; }); } @@ -64,7 +53,6 @@ class _CustomSrsScreenState extends State with SingleTickerProv if (index != -1) { setState(() { _deck[index] = item; - _reviewDeck.removeWhere((element) => element.characters == item.characters); }); await _deckRepository.saveDeck(_deck); } @@ -72,6 +60,29 @@ class _CustomSrsScreenState extends State with SingleTickerProv @override 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( appBar: AppBar( title: const Text('Custom SRS'), @@ -102,43 +113,37 @@ class _CustomSrsScreenState extends State with SingleTickerProv ), body: _deck.isEmpty ? const Center(child: Text('Add cards to start quizzing!')) - : _reviewDeck.isEmpty + : allDecksEmpty ? const Center(child: Text('No cards due for review.')) : TabBarView( controller: _tabController, children: [ CustomQuizScreen( key: _quizScreenKeys[0], - deck: _reviewDeck, + deck: jpnToEngReviewDeck, quizMode: CustomQuizMode.japaneseToEnglish, onCardReviewed: _updateCard, useKanji: _useKanji, + isActive: _tabController.index == 0, ), CustomQuizScreen( key: _quizScreenKeys[1], - deck: _reviewDeck, + deck: engToJpnReviewDeck, quizMode: CustomQuizMode.englishToJapanese, onCardReviewed: _updateCard, useKanji: _useKanji, + isActive: _tabController.index == 1, ), CustomQuizScreen( key: _quizScreenKeys[2], - deck: _reviewDeck, + deck: listeningReviewDeck, quizMode: CustomQuizMode.listeningComprehension, onCardReviewed: _updateCard, 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), - ), ); } } diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 3c3d77e..7f292ad 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -18,6 +18,18 @@ class _ReadingInfo { _ReadingInfo(this.correctReadings, this.hint); } +class _QuizState { + KanjiItem? current; + List options = []; + List correctAnswers = []; + String readingHint = ''; + int score = 0; + int asked = 0; + Key key = UniqueKey(); + String? selectedOption; + bool showResult = false; +} + class HomeScreen extends StatefulWidget { const HomeScreen({super.key, this.distractorGenerator}); @@ -31,17 +43,15 @@ class _HomeScreenState extends State with SingleTickerProviderStateM late TabController _tabController; List _deck = []; bool _loading = false; + bool _isAnswering = false; String _status = 'Loading deck...'; late final DistractorGenerator _dg; final Random _random = Random(); final _audioPlayer = AudioPlayer(); - KanjiItem? _current; - List _options = []; - List _correctAnswers = []; - String _readingHint = ''; - int _score = 0; - int _asked = 0; + final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; + _QuizState get _currentQuizState => _quizStates[_tabController.index]; + bool _playCorrectSound = true; bool _apiKeyMissing = false; @@ -50,8 +60,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { + if (_tabController.indexIsChanging) { + _nextQuestion(); + } setState(() {}); - _nextQuestion(); }); _dg = widget.distractorGenerator ?? DistractorGenerator(); _loadSettings(); @@ -105,7 +117,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM _apiKeyMissing = false; }); - _nextQuestion(); + for (var i = 0; i < _tabController.length; i++) { + _nextQuestion(i); + } } catch (e) { setState(() { _status = 'Error: $e'; @@ -135,8 +149,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM return _ReadingInfo(readingsList, hint); } - QuizMode get _mode { - switch (_tabController.index) { + QuizMode _modeForIndex(int index) { + switch (index) { case 0: return QuizMode.kanjiToEnglish; case 1: @@ -148,127 +162,153 @@ class _HomeScreenState extends State with SingleTickerProviderStateM } } - void _nextQuestion() { + void _nextQuestion([int? index]) { if (_deck.isEmpty) return; + final quizState = _quizStates[index ?? _tabController.index]; + final mode = _modeForIndex(index ?? _tabController.index); + _deck.sort((a, b) { - String srsKey(KanjiItem item) { - var key = _mode.toString(); - if (_mode == QuizMode.reading) { - if (item.onyomi.isNotEmpty && item.kunyomi.isNotEmpty) { - key += _random.nextBool() ? 'onyomi' : 'kunyomi'; - } else if (item.onyomi.isNotEmpty) { - key += 'onyomi'; - } else { - key += 'kunyomi'; + 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 key; + return item.srsItems[mode.toString()]?.srsStage ?? 0; } - final aSrsItem = a.srsItems[srsKey(a)]; - final bSrsItem = b.srsItems[srsKey(b)]; + DateTime getLastAsked(KanjiItem item) { + 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; - final bStage = bSrsItem?.srsStage ?? 0; + 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 = - aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0); - final bLastAsked = - bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0); + final aLastAsked = getLastAsked(a); + final bLastAsked = getLastAsked(b); - if (aLastAsked != bLastAsked) { - return aLastAsked.compareTo(bLastAsked); - } - - return _random.nextDouble().compareTo(_random.nextDouble()); + return aLastAsked.compareTo(bLastAsked); }); - _current = _deck.first; + quizState.current = _deck.first; + quizState.key = UniqueKey(); - _correctAnswers = []; - _options = []; - _readingHint = ''; + quizState.correctAnswers = []; + quizState.options = []; + quizState.readingHint = ''; + quizState.selectedOption = null; + quizState.showResult = false; - switch (_mode) { + switch (mode) { case QuizMode.kanjiToEnglish: - _correctAnswers = [_current!.meanings.first]; - _options = [ - _correctAnswers.first, - ..._dg.generateMeanings(_current!, _deck, 3) + quizState.correctAnswers = [quizState.current!.meanings.first]; + quizState.options = [ + quizState.correctAnswers.first, + ..._dg.generateMeanings(quizState.current!, _deck, 3) ].map(_toTitleCase).toList() ..shuffle(); break; case QuizMode.englishToKanji: - _correctAnswers = [_current!.characters]; - _options = [ - _correctAnswers.first, - ..._dg.generateKanji(_current!, _deck, 3) + quizState.correctAnswers = [quizState.current!.characters]; + quizState.options = [ + quizState.correctAnswers.first, + ..._dg.generateKanji(quizState.current!, _deck, 3) ]..shuffle(); break; case QuizMode.reading: - final info = _pickReading(_current!); - _correctAnswers = info.correctReadings; - _readingHint = info.hint; + final info = _pickReading(quizState.current!); + quizState.correctAnswers = info.correctReadings; + 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.kunyomi); final distractors = readingsSource - .where((r) => !_correctAnswers.contains(r)) + .where((r) => !quizState.correctAnswers.contains(r)) .toSet() .toList() - ..shuffle(); - _options = ([ - _correctAnswers[_random.nextInt(_correctAnswers.length)], + ..shuffle(); + quizState.options = ([ + quizState.correctAnswers[_random.nextInt(quizState.correctAnswers.length)], ...distractors.take(3) ]) ..shuffle(); break; } - setState(() {}); + setState(() { + _isAnswering = false; + }); } 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()) .contains(option.toLowerCase().trim()); final repo = Provider.of(context, listen: false); - final current = _current!; + final current = quizState.current!; String readingType = ''; - if (_mode == QuizMode.reading) { - readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; + if (mode == QuizMode.reading) { + readingType = quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; } - final srsKey = _mode.toString() + readingType; + final srsKey = mode.toString() + readingType; var srsItem = current.srsItems[srsKey]; final isNew = srsItem == null; final srsItemForUpdate = srsItem ??= - SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType); - setState(() { - _asked += 1; - if (isCorrect) { - _score += 1; - srsItemForUpdate.srsStage += 1; - if (_playCorrectSound) { - _audioPlayer.play(AssetSource('sfx/confirm.mp3')); - } - } else { - srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1); + SrsItem(kanjiId: current.id, quizMode: mode, readingType: readingType); + + quizState.asked += 1; + + quizState.selectedOption = option; + + quizState.showResult = true; + + setState(() {}); // Trigger UI rebuild to show selected/correct colors + + + + if (isCorrect) { + quizState.score += 1; + srsItemForUpdate.srsStage += 1; + if (_playCorrectSound) { + _audioPlayer.play(AssetSource('sfx/confirm.mp3')); } - srsItemForUpdate.lastAsked = DateTime.now(); - current.srsItems[srsKey] = srsItemForUpdate; - }); + } else { + srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1); + } + srsItemForUpdate.lastAsked = DateTime.now(); + current.srsItems[srsKey] = srsItemForUpdate; if (isNew) { await repo.insertSrsItem(srsItemForUpdate); @@ -276,11 +316,11 @@ class _HomeScreenState extends State with SingleTickerProviderStateM await repo.updateSrsItem(srsItemForUpdate); } - final correctDisplay = (_mode == QuizMode.kanjiToEnglish) - ? _toTitleCase(_correctAnswers.first) - : (_mode == QuizMode.reading - ? _correctAnswers.join(', ') - : _correctAnswers.first); + final correctDisplay = (mode == QuizMode.kanjiToEnglish) + ? _toTitleCase(quizState.correctAnswers.first) + : (mode == QuizMode.reading + ? quizState.correctAnswers.join(', ') + : quizState.correctAnswers.first); final snack = SnackBar( content: Text( @@ -297,7 +337,11 @@ class _HomeScreenState extends State with SingleTickerProviderStateM 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 @@ -326,22 +370,6 @@ class _HomeScreenState extends State 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( appBar: AppBar( title: const Text('Kanji Quiz'), @@ -355,62 +383,97 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ), backgroundColor: const Color(0xFF121212), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Text( - _status, - style: const TextStyle(color: Colors.white), - ), + body: TabBarView( + controller: _tabController, + children: [ + _buildQuizPage(0), + _buildQuizPage(1), + _buildQuizPage(2), + ], + ), + ); + } + + 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), - ), - ], - ), - ), - ], - ), + ), + ], ), ); } diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart index b47facb..cc7773e 100644 --- a/lib/src/screens/vocab_screen.dart +++ b/lib/src/screens/vocab_screen.dart @@ -4,13 +4,26 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.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 '../widgets/kanji_card.dart'; import '../widgets/options_grid.dart'; import 'package:audioplayers/audioplayers.dart'; import 'settings_screen.dart'; +class _QuizState { + VocabularyItem? current; + List options = []; + List correctAnswers = []; + int score = 0; + int asked = 0; + Key key = UniqueKey(); + String? selectedOption; + bool showResult = false; + List shuffledDeck = []; + int currentIndex = 0; +} + class VocabScreen extends StatefulWidget { const VocabScreen({super.key}); @@ -22,15 +35,14 @@ class _VocabScreenState extends State with SingleTickerProviderStat late TabController _tabController; List _deck = []; bool _loading = false; + bool _isAnswering = false; String _status = 'Loading deck...'; final DistractorGenerator _dg = DistractorGenerator(); final _audioPlayer = AudioPlayer(); - VocabularyItem? _current; - List _options = []; - List _correctAnswers = []; - int _score = 0; - int _asked = 0; + final _quizStates = [_QuizState(), _QuizState(), _QuizState()]; + _QuizState get _currentQuizState => _quizStates[_tabController.index]; + bool _playAudio = true; bool _playCorrectSound = true; bool _apiKeyMissing = false; @@ -40,8 +52,10 @@ class _VocabScreenState extends State with SingleTickerProviderStat super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { + if (_tabController.indexIsChanging) { + _nextQuestion(); + } setState(() {}); - _nextQuestion(); }); _loadSettings(); _loadDeck(); @@ -68,7 +82,7 @@ class _VocabScreenState extends State with SingleTickerProviderStat }); try { - final repo = Provider.of(context, listen: false); + final repo = Provider.of(context, listen: false); await repo.loadApiKey(); final apiKey = repo.apiKey; @@ -96,7 +110,9 @@ class _VocabScreenState extends State with SingleTickerProviderStat _apiKeyMissing = false; }); - _nextQuestion(); + for (var i = 0; i < _tabController.length; i++) { + _nextQuestion(i); + } } catch (e) { setState(() { _status = 'Error: $e'; @@ -113,8 +129,8 @@ class _VocabScreenState extends State with SingleTickerProviderStat .join(' '); } - VocabQuizMode get _mode { - switch (_tabController.index) { + VocabQuizMode _modeForIndex(int index) { + switch (index) { case 0: return VocabQuizMode.vocabToEnglish; case 1: @@ -126,70 +142,84 @@ class _VocabScreenState extends State with SingleTickerProviderStat } } - void _nextQuestion() { + void _nextQuestion([int? index]) { if (_deck.isEmpty) return; - List deck = _deck; - if (_mode == VocabQuizMode.audioToEnglish) { - deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList(); - if (deck.isEmpty) { + final quizState = _quizStates[index ?? _tabController.index]; + final mode = _modeForIndex(index ?? _tabController.index); + + List currentDeckForMode = _deck; + if (mode == VocabQuizMode.audioToEnglish) { + currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList(); + if (currentDeckForMode.isEmpty) { setState(() { _status = 'No vocabulary with audio found.'; - _current = null; + quizState.current = null; }); return; } } - deck.sort((a, b) { - final aSrsItem = a.srsItems[_mode.toString()] ?? - VocabSrsItem(vocabId: a.id, quizMode: _mode); - final bSrsItem = b.srsItems[_mode.toString()] ?? - VocabSrsItem(vocabId: b.id, quizMode: _mode); + // If it's a new session or we've gone through all shuffled items, re-shuffle + if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) { + quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy + // Apply sorting based on SRS stages here, but only once per shuffle + 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); - if (stageComparison != 0) { - return stageComparison; - } - return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked); - }); + quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck + quizState.currentIndex++; // Advance index - _current = deck.first; - if (_mode == VocabQuizMode.audioToEnglish) { + quizState.key = UniqueKey(); + if (mode == VocabQuizMode.audioToEnglish) { _playCurrentAudio(); } - _correctAnswers = []; - _options = []; + quizState.correctAnswers = []; + quizState.options = []; + quizState.selectedOption = null; + quizState.showResult = false; - switch (_mode) { + switch (mode) { case VocabQuizMode.vocabToEnglish: case VocabQuizMode.audioToEnglish: - _correctAnswers = [_current!.meanings.first]; - _options = [ - _correctAnswers.first, - ..._dg.generateVocabMeanings(_current!, _deck, 3) + quizState.correctAnswers = [quizState.current!.meanings.first]; + quizState.options = [ + quizState.correctAnswers.first, + ..._dg.generateVocabMeanings(quizState.current!, _deck, 3) ].map(_toTitleCase).toList() ..shuffle(); break; case VocabQuizMode.englishToVocab: - _correctAnswers = [_current!.characters]; - _options = [ - _correctAnswers.first, - ..._dg.generateVocab(_current!, _deck, 3) + quizState.correctAnswers = [quizState.current!.characters]; + quizState.options = [ + quizState.correctAnswers.first, + ..._dg.generateVocab(quizState.current!, _deck, 3) ]..shuffle(); break; } - setState(() {}); + setState(() { + _isAnswering = false; + }); } Future _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 audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : _current!.pronunciationAudios.first.url); + final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male'); + final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url); try { await _audioPlayer.play(UrlSource(audioUrl)); @@ -199,31 +229,35 @@ class _VocabScreenState extends State with SingleTickerProviderStat } 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()) .contains(option.toLowerCase().trim()); - final repo = Provider.of(context, listen: false); - final current = _current!; + final repo = Provider.of(context, listen: false); + final current = quizState.current!; - final srsKey = _mode.toString(); + final srsKey = mode.toString(); var srsItemNullable = current.srsItems[srsKey]; final isNew = srsItemNullable == null; final srsItem = - srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode); + srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode); - setState(() { - _asked += 1; - if (isCorrect) { - _score += 1; - srsItem.srsStage += 1; - } else { - srsItem.srsStage = max(0, srsItem.srsStage - 1); - } - srsItem.lastAsked = DateTime.now(); - current.srsItems[srsKey] = srsItem; - }); + quizState.asked += 1; + quizState.selectedOption = option; + quizState.showResult = true; + setState(() {}); // Trigger UI rebuild to show selected/correct colors + + if (isCorrect) { + quizState.score += 1; + srsItem.srsStage += 1; + } else { + srsItem.srsStage = max(0, srsItem.srsStage - 1); + } + srsItem.lastAsked = DateTime.now(); + current.srsItems[srsKey] = srsItem; if (isNew) { await repo.insertVocabSrsItem(srsItem); @@ -231,9 +265,9 @@ class _VocabScreenState extends State with SingleTickerProviderStat await repo.updateVocabSrsItem(srsItem); } - final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish) - ? _toTitleCase(_correctAnswers.first) - : _correctAnswers.first; + final correctDisplay = (mode == VocabQuizMode.vocabToEnglish) + ? _toTitleCase(quizState.correctAnswers.first) + : quizState.correctAnswers.first; final snack = SnackBar( content: Text( @@ -254,7 +288,7 @@ class _VocabScreenState extends State with SingleTickerProviderStat if (_playCorrectSound) { await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); } - if (_playAudio && _mode != VocabQuizMode.audioToEnglish) { + if (_playAudio && mode != VocabQuizMode.audioToEnglish) { final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male'); if (maleAudios.isNotEmpty) { @@ -274,9 +308,13 @@ class _VocabScreenState extends State with SingleTickerProviderStat } } } else { - await Future.delayed(const Duration(milliseconds: 900)); + // No fixed delay for incorrect answers } + setState(() { + _isAnswering = true; // Disable input after showing result + }); + _nextQuestion(); } @@ -289,7 +327,7 @@ class _VocabScreenState extends State with SingleTickerProviderStat child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), ElevatedButton( onPressed: () async { @@ -306,31 +344,6 @@ class _VocabScreenState extends State 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( appBar: AppBar( title: const Text('Vocabulary Quiz'), @@ -343,63 +356,101 @@ class _VocabScreenState extends State with SingleTickerProviderStat ], ), ), - backgroundColor: const Color(0xFF121212), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Text( - _status, - style: const TextStyle(color: Colors.white), - ), + body: TabBarView( + controller: _tabController, + children: [ + _buildQuizPage(0), + _buildQuizPage(1), + _buildQuizPage(2), + ], + ), + ); + } + + 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), - ), - ], - ), - ), - ], - ), + ), + ], ), ); } diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index cfc22a9..aeb6b99 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; @@ -263,154 +262,5 @@ class DeckRepository { return items; } - Future> 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 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 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 saveVocabulary(List 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> 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 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> 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 = {}; - for (final a in assignments) { - final data = a['data'] as Map; - 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; - } } \ No newline at end of file diff --git a/lib/src/services/vocab_deck_repository.dart b/lib/src/services/vocab_deck_repository.dart new file mode 100644 index 0000000..cc25efa --- /dev/null +++ b/lib/src/services/vocab_deck_repository.dart @@ -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 setApiKey(String apiKey) async { + _apiKey = apiKey; + await saveApiKey(apiKey); + } + + String? get apiKey => _apiKey; + + Future _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 saveApiKey(String apiKey) async { + final db = await _openDb(); + await db.insert('settings', { + 'key': 'apiKey', + 'value': apiKey, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future 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> 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 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 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 saveVocabulary(List 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> 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 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> 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 = {}; + for (final a in assignments) { + final data = a['data'] as Map; + 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; + } +} diff --git a/lib/src/widgets/options_grid.dart b/lib/src/widgets/options_grid.dart index 3ec8fed..d1043cd 100644 --- a/lib/src/widgets/options_grid.dart +++ b/lib/src/widgets/options_grid.dart @@ -5,6 +5,10 @@ class OptionsGrid extends StatelessWidget { final void Function(String) onSelected; final Color? buttonColor; final Color? textColor; + final bool isDisabled; + final String? selectedOption; + final List? correctAnswers; + final bool showResult; const OptionsGrid({ super.key, @@ -12,6 +16,10 @@ class OptionsGrid extends StatelessWidget { required this.onSelected, this.buttonColor, this.textColor, + this.isDisabled = false, + this.selectedOption, + this.correctAnswers, + this.showResult = false, }); @override @@ -27,13 +35,24 @@ class OptionsGrid extends StatelessWidget { runSpacing: 10, alignment: WrapAlignment.center, 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( width: 160, child: ElevatedButton( - onPressed: () => onSelected(o), + onPressed: isDisabled ? null : () => onSelected(o), style: ElevatedButton.styleFrom( - backgroundColor: bg, - foregroundColor: fg, + backgroundColor: currentButtonColor, + foregroundColor: currentTextColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -41,7 +60,7 @@ class OptionsGrid extends StatelessWidget { ), child: Text( o, - style: TextStyle(fontSize: 20, color: fg), + style: TextStyle(fontSize: 20, color: currentTextColor), textAlign: TextAlign.center, ), ), @@ -49,4 +68,4 @@ class OptionsGrid extends StatelessWidget { }).toList(), ); } -} +} \ No newline at end of file