From d5ff5eb12f3aded668e8b49b6f50e7d878e9d7ce Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Sat, 1 Nov 2025 06:38:14 +0100 Subject: [PATCH] some cleanup and some fixes --- lib/main.dart | 10 ++++- lib/src/screens/add_card_screen.dart | 32 +++++++++++++- lib/src/screens/browse_screen.dart | 38 ++++++++--------- lib/src/screens/custom_quiz_screen.dart | 45 ++++++++++++-------- lib/src/screens/home_screen.dart | 4 -- lib/src/screens/vocab_screen.dart | 3 ++ lib/src/services/database_helper.dart | 37 ---------------- lib/src/services/deck_repository.dart | 7 ++-- lib/src/services/tts_service.dart | 56 +++++++++++++++++++++++++ 9 files changed, 148 insertions(+), 84 deletions(-) create mode 100644 lib/src/services/tts_service.dart diff --git a/lib/main.dart b/lib/main.dart index a8252b3..86139e6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,12 +5,12 @@ import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'src/services/deck_repository.dart'; import 'src/screens/start_screen.dart'; +import 'src/services/tts_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); try { await dotenv.load(fileName: ".env"); - // No need to catch because the file is only needed for dev } catch (_) {} runApp( @@ -19,6 +19,14 @@ void main() async { Provider(create: (_) => DeckRepository()), Provider(create: (_) => VocabDeckRepository()), ChangeNotifierProvider(create: (_) => ThemeModel()), + Provider( + create: (_) { + final ttsService = TtsService(); + ttsService.initTts(); + return ttsService; + }, + dispose: (_, ttsService) => ttsService.dispose(), + ), ], child: const WkApp(), ), diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart index ca74757..20f1721 100644 --- a/lib/src/screens/add_card_screen.dart +++ b/lib/src/screens/add_card_screen.dart @@ -18,11 +18,14 @@ class _AddCardScreenState extends State { final _kanaKit = const KanaKit(); final _deckRepository = CustomDeckRepository(); bool _useInterval = false; + late FocusNode _japaneseFocusNode; @override void initState() { super.initState(); _japaneseController.addListener(_convertToKana); + _japaneseFocusNode = FocusNode(); + _japaneseFocusNode.addListener(_onJapaneseFocusChange); } @override @@ -31,13 +34,24 @@ class _AddCardScreenState extends State { _japaneseController.dispose(); _englishController.dispose(); _kanjiController.dispose(); + _japaneseFocusNode.removeListener(_onJapaneseFocusChange); + _japaneseFocusNode.dispose(); super.dispose(); } void _convertToKana() { final text = _japaneseController.text; + final selection = _japaneseController.selection; + final offset = selection.baseOffset; + + if ((offset > 1 && text[offset - 1] == 'n' && text[offset - 2] != 'n') || + (offset == 1 && text[offset - 1] == 'n')) { + return; + } + final converted = _kanaKit.toKana(text); - if (text != converted) { + + if (converted != text) { _japaneseController.value = _japaneseController.value.copyWith( text: converted, selection: TextSelection.fromPosition( @@ -47,6 +61,21 @@ class _AddCardScreenState extends State { } } + void _onJapaneseFocusChange() { + if (!_japaneseFocusNode.hasFocus) { + _forceNConversion(); + } + } + + void _forceNConversion() { + final text = _japaneseController.text; + if (text.isNotEmpty && + text.endsWith('n') && + _kanaKit.toKana(text) != text) { + _japaneseController.text = _kanaKit.toKana(text); + } + } + void _saveCard() { if (_formKey.currentState!.validate()) { final srsData = _useInterval @@ -83,6 +112,7 @@ class _AddCardScreenState extends State { children: [ TextFormField( controller: _japaneseController, + focusNode: _japaneseFocusNode, decoration: const InputDecoration( labelText: 'Japanese (Kana)', hintText: 'Enter Japanese vocabulary or kanji', diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index ea3a3a7..51d7d53 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -200,14 +200,13 @@ class _BrowseScreenState extends State padding: const EdgeInsets.symmetric(vertical: 8.0), color: Theme.of(context).colorScheme.surfaceContainer, height: 60, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(levels.length, (index) { - final level = levels[index]; - final isSelected = index == currentPage; - return Padding( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(levels.length, (index) { + final level = levels[index]; + final isSelected = index == currentPage; + return Expanded( + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: ElevatedButton( onPressed: () { @@ -222,14 +221,14 @@ class _BrowseScreenState extends State ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, foregroundColor: Theme.of(context).colorScheme.onPrimary, - shape: const CircleBorder(), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.all(12), ), child: Text(level.toString()), ), - ); - }), - ), + ), + ); + }), ), ); } @@ -882,11 +881,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { @override void initState() { super.initState(); - _fetchExampleSentences(); + _fetchExampleSentences(context); } - Future _fetchExampleSentences() async { - final theme = Theme.of(context); + Future _fetchExampleSentences(BuildContext context) async { try { final uri = Uri.parse( 'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}', @@ -913,11 +911,11 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { children: [ Text( japaneseWord, - style: TextStyle(color: theme.colorScheme.onSurface), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), Text( englishDefinition, - style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), ), const SizedBox(height: 8), ], @@ -931,7 +929,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { sentences.add( Text( 'No example sentences found.', - style: TextStyle(color: theme.colorScheme.onSurface), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), ); } @@ -946,7 +944,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { _exampleSentences = [ Text( 'Failed to load example sentences.', - style: TextStyle(color: theme.colorScheme.error), + style: TextStyle(color: Theme.of(context).colorScheme.error), ), ]; }); @@ -958,7 +956,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { _exampleSentences = [ Text( 'Error loading example sentences.', - style: TextStyle(color: theme.colorScheme.error), + style: TextStyle(color: Theme.of(context).colorScheme.error), ), ]; }); diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart index 2dcc1ab..44f6d55 100644 --- a/lib/src/screens/custom_quiz_screen.dart +++ b/lib/src/screens/custom_quiz_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; 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'; +import 'package:provider/provider.dart'; +import '../services/tts_service.dart'; enum CustomQuizMode { japaneseToEnglish, @@ -38,7 +39,6 @@ class CustomQuizScreenState extends State List _options = []; bool _answered = false; bool? _correct; - late FlutterTts _flutterTts; late AnimationController _shakeController; late Animation _shakeAnimation; final List _incorrectlyAnsweredItems = []; @@ -47,7 +47,6 @@ class CustomQuizScreenState extends State void initState() { super.initState(); _shuffledDeck = widget.deck.toList()..shuffle(); - _initTts(); if (_shuffledDeck.isNotEmpty) { _generateOptions(); } @@ -61,6 +60,11 @@ class CustomQuizScreenState extends State ); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + @override void didUpdateWidget(CustomQuizScreen oldWidget) { super.didUpdateWidget(oldWidget); @@ -82,21 +86,18 @@ class CustomQuizScreenState extends State } } - void playAudio() { + void playAudio() async { if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) { - _speak(_shuffledDeck[_currentIndex].characters); + final ttsService = Provider.of(context, listen: false); + await ttsService.speak(_shuffledDeck[_currentIndex].characters); } } - void _initTts() async { - _flutterTts = FlutterTts(); - await _flutterTts.setLanguage("ja-JP"); - } + @override void dispose() { - _flutterTts.stop(); _shakeController.dispose(); super.dispose(); } @@ -106,7 +107,8 @@ class CustomQuizScreenState extends State if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) { _options = [currentItem.meaning]; - } else { + } + else { _options = [ widget.useKanji && currentItem.kanji != null ? currentItem.kanji! @@ -232,7 +234,8 @@ class CustomQuizScreenState extends State } if (isCorrect) { - if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { + if (widget.quizMode == CustomQuizMode.japaneseToEnglish || + widget.quizMode == CustomQuizMode.englishToJapanese) { await _speak(currentItem.characters); } await Future.delayed(const Duration(milliseconds: 500)); @@ -244,22 +247,24 @@ class CustomQuizScreenState extends State _nextQuestion(); } - void _nextQuestion() { + Future _nextQuestion() async { setState(() { _currentIndex++; _answered = false; _correct = null; if (_currentIndex < _shuffledDeck.length) { _generateOptions(); - if (widget.quizMode == CustomQuizMode.listeningComprehension) { - _speak(_shuffledDeck[_currentIndex].characters); - } } }); + if (_currentIndex < _shuffledDeck.length && + widget.quizMode == CustomQuizMode.listeningComprehension) { + await _speak(_shuffledDeck[_currentIndex].characters); + } } Future _speak(String text) async { - await _flutterTts.speak(text); + final ttsService = Provider.of(context, listen: false); + await ttsService.speak(text); } void _onOptionSelected(String option) { @@ -287,6 +292,12 @@ class CustomQuizScreenState extends State icon: const Icon(Icons.volume_up, size: 64), onPressed: () => _speak(currentItem.characters), ); + } else if (widget.quizMode == CustomQuizMode.englishToJapanese) { + promptWidget = Text( + question, + style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface), + textAlign: TextAlign.center, + ); } else { promptWidget = GestureDetector( onTap: () => _speak(question), diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 4725dea..ce16ec2 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -265,8 +265,6 @@ class _HomeScreenState extends State ])..shuffle(); break; default: - // Handle other QuizMode cases if necessary, or throw an error - // if these modes are not expected in this context. break; } @@ -435,8 +433,6 @@ class _HomeScreenState extends State subtitle = quizState.readingHint; break; default: - // Handle other QuizMode cases if necessary, or throw an error - // if these modes are not expected in this context. break; } } diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart index 14a1cea..9ed43c1 100644 --- a/lib/src/screens/vocab_screen.dart +++ b/lib/src/screens/vocab_screen.dart @@ -54,6 +54,9 @@ class _VocabScreenState extends State super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { + if (_tabController.index == 2 && !_tabController.indexIsChanging) { + _playCurrentAudio(); + } setState(() {}); }); _loadSettings(); diff --git a/lib/src/services/database_helper.dart b/lib/src/services/database_helper.dart index 88da2f5..1a0939b 100644 --- a/lib/src/services/database_helper.dart +++ b/lib/src/services/database_helper.dart @@ -50,43 +50,6 @@ class DatabaseHelper { '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', ); }, - onUpgrade: (db, oldVersion, newVersion) async { - if (oldVersion < 2) { - await db.execute( - '''CREATE TABLE IF NOT EXISTS ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', - ); - } - if (oldVersion < 4) { - await db.execute( - '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', - ); - } - if (oldVersion < 5) { - await db.execute( - '''CREATE TABLE IF NOT EXISTS ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT)''', - ); - await db.execute( - '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', - ); - } - if (oldVersion < 6) { - try { - await db.execute( - 'ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.pronunciationAudiosColumn} TEXT', - ); - } catch (_) { - // Ignore error, column might already exist - } - } - if (oldVersion < 7) { - try { - await db.execute('ALTER TABLE ${DbConstants.kanjiTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); - await db.execute('ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); - } catch (_) { - // Ignore error, column might already exist - } - } - }, ); } } diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 2a2db35..433dd1a 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -46,9 +46,7 @@ class DeckRepository { _apiKey = envApiKey; return _apiKey; } - } catch (e) { - // dotenv is not initialized - } + } catch (_) {} return null; } @@ -128,7 +126,8 @@ class DeckRepository { DbConstants.srsStageColumn: item.srsStage, DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), }, - where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', + where: + '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType], ); } diff --git a/lib/src/services/tts_service.dart b/lib/src/services/tts_service.dart new file mode 100644 index 0000000..d26c6fc --- /dev/null +++ b/lib/src/services/tts_service.dart @@ -0,0 +1,56 @@ +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class TtsService { + FlutterTts? _flutterTts; + bool _isInitialized = false; + + Future initTts() async { + if (_isInitialized) return; + + _flutterTts = FlutterTts(); + if (_flutterTts != null) { + final isAvailable = await _flutterTts!.isLanguageAvailable("ja-JP"); + if (isAvailable == true) { + await _flutterTts?.setLanguage("ja-JP"); + } else { + debugPrint('Japanese (ja-JP) TTS language not available.'); + } + } + _isInitialized = true; + } + + Future isLanguageAvailable(String language) async { + if (_flutterTts == null) { + await initTts(); + } + return await _flutterTts?.isLanguageAvailable(language) ?? false; + } + + Future speak(String text) async { + const int maxRetries = 3; + for (int i = 0; i < maxRetries; i++) { + try { + if (_flutterTts == null || !_isInitialized) { + await initTts(); + } + await _flutterTts?.speak(text); + return; + } on PlatformException catch (_) { + debugPrint('TTS speak failed, retrying...'); + await _flutterTts?.stop(); + _flutterTts = null; + _isInitialized = false; + await Future.delayed(const Duration(milliseconds: 500)); + } + } + debugPrint('Failed to speak after $maxRetries retries.'); + } + + void dispose() { + _flutterTts?.stop(); + _flutterTts = null; + _isInitialized = false; + } +}