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 '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<DeckRepository>(create: (_) => DeckRepository()),
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()),
Provider<TtsService>(
create: (_) {
final ttsService = TtsService();
ttsService.initTts();
return ttsService;
},
dispose: (_, ttsService) => ttsService.dispose(),
),
],
child: const WkApp(),
),

View File

@@ -18,11 +18,14 @@ class _AddCardScreenState extends State<AddCardScreen> {
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<AddCardScreen> {
_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<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() {
if (_formKey.currentState!.validate()) {
final srsData = _useInterval
@@ -83,6 +112,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
children: [
TextFormField(
controller: _japaneseController,
focusNode: _japaneseFocusNode,
decoration: const InputDecoration(
labelText: 'Japanese (Kana)',
hintText: 'Enter Japanese vocabulary or kanji',

View File

@@ -200,14 +200,13 @@ class _BrowseScreenState extends State<BrowseScreen>
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<BrowseScreen>
? 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<void> _fetchExampleSentences() async {
final theme = Theme.of(context);
Future<void> _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),
),
];
});

View File

@@ -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<CustomQuizScreen>
List<String> _options = [];
bool _answered = false;
bool? _correct;
late FlutterTts _flutterTts;
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
final List<String> _incorrectlyAnsweredItems = [];
@@ -47,7 +47,6 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void initState() {
super.initState();
_shuffledDeck = widget.deck.toList()..shuffle();
_initTts();
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
@@ -61,6 +60,11 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -82,21 +86,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
}
}
void playAudio() {
void playAudio() async {
if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_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
void dispose() {
_flutterTts.stop();
_shakeController.dispose();
super.dispose();
}
@@ -106,7 +107,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
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<CustomQuizScreen>
}
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<CustomQuizScreen>
_nextQuestion();
}
void _nextQuestion() {
Future<void> _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<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) {
@@ -287,6 +292,12 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
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),

View File

@@ -265,8 +265,6 @@ class _HomeScreenState extends State<HomeScreen>
])..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<HomeScreen>
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;
}
}

View File

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

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;
}
}