some cleanup and some fixes

This commit is contained in:
Rene Kievits
2025-11-01 06:38:14 +01:00
parent 732408997d
commit d5ff5eb12f
9 changed files with 148 additions and 84 deletions

View File

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

View File

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

View File

@@ -200,14 +200,13 @@ class _BrowseScreenState extends State<BrowseScreen>
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
height: 60, height: 60,
child: SingleChildScrollView( child: Row(
scrollDirection: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: Row( children: List.generate(levels.length, (index) {
mainAxisAlignment: MainAxisAlignment.center, final level = levels[index];
children: List.generate(levels.length, (index) { final isSelected = index == currentPage;
final level = levels[index]; return Expanded(
final isSelected = index == currentPage; child: Padding(
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
@@ -222,14 +221,14 @@ class _BrowseScreenState extends State<BrowseScreen>
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onPrimary, foregroundColor: Theme.of(context).colorScheme.onPrimary,
shape: const CircleBorder(), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
), ),
child: Text(level.toString()), child: Text(level.toString()),
), ),
); ),
}), );
), }),
), ),
); );
} }
@@ -882,11 +881,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchExampleSentences(); _fetchExampleSentences(context);
} }
Future<void> _fetchExampleSentences() async { Future<void> _fetchExampleSentences(BuildContext context) async {
final theme = Theme.of(context);
try { try {
final uri = Uri.parse( final uri = Uri.parse(
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}', 'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}',
@@ -913,11 +911,11 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
children: [ children: [
Text( Text(
japaneseWord, japaneseWord,
style: TextStyle(color: theme.colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
Text( Text(
englishDefinition, englishDefinition,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@@ -931,7 +929,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
sentences.add( sentences.add(
Text( Text(
'No example sentences found.', 'No example sentences found.',
style: TextStyle(color: theme.colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
); );
} }
@@ -946,7 +944,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
_exampleSentences = [ _exampleSentences = [
Text( Text(
'Failed to load example sentences.', 'Failed to load example sentences.',
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
]; ];
}); });
@@ -958,7 +956,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
_exampleSentences = [ _exampleSentences = [
Text( Text(
'Error loading example sentences.', 'Error loading example sentences.',
style: TextStyle(color: theme.colorScheme.error), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
]; ];
}); });

View File

@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:math'; import 'dart:math';
import 'package:flutter_tts/flutter_tts.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
import 'package:provider/provider.dart';
import '../services/tts_service.dart';
enum CustomQuizMode { enum CustomQuizMode {
japaneseToEnglish, japaneseToEnglish,
@@ -38,7 +39,6 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
List<String> _options = []; List<String> _options = [];
bool _answered = false; bool _answered = false;
bool? _correct; bool? _correct;
late FlutterTts _flutterTts;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = []; final List<String> _incorrectlyAnsweredItems = [];
@@ -47,7 +47,6 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void initState() { void initState() {
super.initState(); super.initState();
_shuffledDeck = widget.deck.toList()..shuffle(); _shuffledDeck = widget.deck.toList()..shuffle();
_initTts();
if (_shuffledDeck.isNotEmpty) { if (_shuffledDeck.isNotEmpty) {
_generateOptions(); _generateOptions();
} }
@@ -61,6 +60,11 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
); );
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override @override
void didUpdateWidget(CustomQuizScreen oldWidget) { void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -82,21 +86,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
} }
void playAudio() { void playAudio() async {
if (widget.quizMode == CustomQuizMode.listeningComprehension && if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_currentIndex < _shuffledDeck.length) { _currentIndex < _shuffledDeck.length) {
_speak(_shuffledDeck[_currentIndex].characters); final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(_shuffledDeck[_currentIndex].characters);
} }
} }
void _initTts() async {
_flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP");
}
@override @override
void dispose() { void dispose() {
_flutterTts.stop();
_shakeController.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
@@ -106,7 +107,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
if (widget.quizMode == CustomQuizMode.listeningComprehension || if (widget.quizMode == CustomQuizMode.listeningComprehension ||
widget.quizMode == CustomQuizMode.japaneseToEnglish) { widget.quizMode == CustomQuizMode.japaneseToEnglish) {
_options = [currentItem.meaning]; _options = [currentItem.meaning];
} else { }
else {
_options = [ _options = [
widget.useKanji && currentItem.kanji != null widget.useKanji && currentItem.kanji != null
? currentItem.kanji! ? currentItem.kanji!
@@ -232,7 +234,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
if (isCorrect) { if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.englishToJapanese) {
await _speak(currentItem.characters); await _speak(currentItem.characters);
} }
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
@@ -244,22 +247,24 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
_nextQuestion(); _nextQuestion();
} }
void _nextQuestion() { Future<void> _nextQuestion() async {
setState(() { setState(() {
_currentIndex++; _currentIndex++;
_answered = false; _answered = false;
_correct = null; _correct = null;
if (_currentIndex < _shuffledDeck.length) { if (_currentIndex < _shuffledDeck.length) {
_generateOptions(); _generateOptions();
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
} }
}); });
if (_currentIndex < _shuffledDeck.length &&
widget.quizMode == CustomQuizMode.listeningComprehension) {
await _speak(_shuffledDeck[_currentIndex].characters);
}
} }
Future<void> _speak(String text) async { Future<void> _speak(String text) async {
await _flutterTts.speak(text); final ttsService = Provider.of<TtsService>(context, listen: false);
await ttsService.speak(text);
} }
void _onOptionSelected(String option) { void _onOptionSelected(String option) {
@@ -287,6 +292,12 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
icon: const Icon(Icons.volume_up, size: 64), icon: const Icon(Icons.volume_up, size: 64),
onPressed: () => _speak(currentItem.characters), onPressed: () => _speak(currentItem.characters),
); );
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
promptWidget = Text(
question,
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface),
textAlign: TextAlign.center,
);
} else { } else {
promptWidget = GestureDetector( promptWidget = GestureDetector(
onTap: () => _speak(question), onTap: () => _speak(question),

View File

@@ -265,8 +265,6 @@ class _HomeScreenState extends State<HomeScreen>
])..shuffle(); ])..shuffle();
break; break;
default: default:
// Handle other QuizMode cases if necessary, or throw an error
// if these modes are not expected in this context.
break; break;
} }
@@ -435,8 +433,6 @@ class _HomeScreenState extends State<HomeScreen>
subtitle = quizState.readingHint; subtitle = quizState.readingHint;
break; break;
default: default:
// Handle other QuizMode cases if necessary, or throw an error
// if these modes are not expected in this context.
break; break;
} }
} }

View File

@@ -54,6 +54,9 @@ class _VocabScreenState extends State<VocabScreen>
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.index == 2 && !_tabController.indexIsChanging) {
_playCurrentAudio();
}
setState(() {}); setState(() {});
}); });
_loadSettings(); _loadSettings();

View File

@@ -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}))''', '''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
}
}
},
); );
} }
} }

View File

@@ -46,9 +46,7 @@ class DeckRepository {
_apiKey = envApiKey; _apiKey = envApiKey;
return _apiKey; return _apiKey;
} }
} catch (e) { } catch (_) {}
// dotenv is not initialized
}
return null; return null;
} }
@@ -128,7 +126,8 @@ class DeckRepository {
DbConstants.srsStageColumn: item.srsStage, DbConstants.srsStageColumn: item.srsStage,
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), 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], whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType],
); );
} }

View File

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