Compare commits

..

2 Commits

Author SHA1 Message Date
Rene Kievits
a572a6e6fc added vocabulary audio reading 2025-10-28 06:00:15 +01:00
Rene Kievits
6dabb9c977 added sound 2025-10-28 05:44:14 +01:00
12 changed files with 237 additions and 63 deletions

BIN
assets/sfx/confirm.mp3 Normal file

Binary file not shown.

View File

@@ -96,19 +96,27 @@ class VocabSrsItem {
}) : lastAsked = lastAsked ?? DateTime.now();
}
class PronunciationAudio {
final String url;
final String gender;
PronunciationAudio({required this.url, required this.gender});
}
class VocabularyItem {
final int id;
final String characters;
final List<String> meanings;
final List<String> readings;
final List<PronunciationAudio> pronunciationAudios;
final Map<String, VocabSrsItem> srsItems = {};
VocabularyItem({
required this.id,
required this.characters,
required this.meanings,
required this.readings,
});
VocabularyItem(
{required this.id,
required this.characters,
required this.meanings,
required this.readings,
required this.pronunciationAudios});
factory VocabularyItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
@@ -116,6 +124,7 @@ class VocabularyItem {
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> readings = <String>[];
final List<PronunciationAudio> pronunciationAudios = <PronunciationAudio>[];
if (data['meanings'] != null) {
for (final m in data['meanings'] as List) {
@@ -129,11 +138,26 @@ class VocabularyItem {
}
}
if (data['pronunciation_audios'] != null) {
for (final audio in data['pronunciation_audios'] as List) {
final url = audio['url'] as String?;
final metadata = audio['metadata'] as Map<String, dynamic>?;
final gender = metadata?['gender'] as String?;
if (url != null && gender != null) {
pronunciationAudios.add(PronunciationAudio(
url: url,
gender: gender,
));
}
}
}
return VocabularyItem(
id: id,
characters: characters,
meanings: meanings,
readings: readings,
);
id: id,
characters: characters,
meanings: meanings,
readings: readings,
pronunciationAudios: pronunciationAudios);
}
}

View File

@@ -6,6 +6,8 @@ import '../services/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 _ReadingInfo {
@@ -28,6 +30,7 @@ class _HomeScreenState extends State<HomeScreen> {
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
final _audioPlayer = AudioPlayer();
QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
@@ -109,14 +112,20 @@ class _HomeScreenState extends State<HomeScreen> {
void _nextQuestion() {
_deck.sort((a, b) {
final aSrsItem = a.srsItems[_mode.toString()] ?? SrsItem(kanjiId: a.id, quizMode: _mode);
final bSrsItem = b.srsItems[_mode.toString()] ?? SrsItem(kanjiId: b.id, quizMode: _mode);
final aSrsItem = a.srsItems[_mode.toString()];
final bSrsItem = b.srsItems[_mode.toString()];
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) {
return stageComparison;
final aStage = aSrsItem?.srsStage ?? 0;
final bStage = bSrsItem?.srsStage ?? 0;
if (aStage != bStage) {
return aStage.compareTo(bStage);
}
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
final aLastAsked = aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
final bLastAsked = bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
return aLastAsked.compareTo(bLastAsked);
});
_current = _deck.first;
@@ -128,16 +137,19 @@ class _HomeScreenState extends State<HomeScreen> {
switch (_mode) {
case QuizMode.kanjiToEnglish:
_correctAnswers = [_current!.meanings.first];
_options = [_correctAnswers.first, ..._dg.generateMeanings(_current!, _deck, 3)]
.map(_toTitleCase)
.toList()
_options = [
_correctAnswers.first,
..._dg.generateMeanings(_current!, _deck, 3)
].map(_toTitleCase).toList()
..shuffle();
break;
case QuizMode.englishToKanji:
_correctAnswers = [_current!.characters];
_options = [_correctAnswers.first, ..._dg.generateKanji(_current!, _deck, 3)]
..shuffle();
_options = [
_correctAnswers.first,
..._dg.generateKanji(_current!, _deck, 3)
]..shuffle();
break;
case QuizMode.reading:
@@ -149,10 +161,15 @@ class _HomeScreenState extends State<HomeScreen> {
? _deck.expand((k) => k.onyomi)
: _deck.expand((k) => k.kunyomi);
final distractors =
readingsSource.where((r) => !_correctAnswers.contains(r)).toSet().toList()
final distractors = readingsSource
.where((r) => !_correctAnswers.contains(r))
.toSet()
.toList()
..shuffle();
_options = ([_correctAnswers[_random.nextInt(_correctAnswers.length)], ...distractors.take(3)])
_options = ([
_correctAnswers[_random.nextInt(_correctAnswers.length)],
...distractors.take(3)
])
..shuffle();
break;
}
@@ -176,29 +193,31 @@ class _HomeScreenState extends State<HomeScreen> {
var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null;
srsItem ??= SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType);
final srsItemForUpdate = srsItem ??=
SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType);
setState(() {
_asked += 1;
if (isCorrect) {
_score += 1;
srsItem!.srsStage += 1;
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} else {
srsItem!.srsStage = max(0, srsItem.srsStage - 1);
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
}
srsItem.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItem;
srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate;
});
if (isNew) {
await repo.insertSrsItem(srsItem);
await repo.insertSrsItem(srsItemForUpdate);
} else {
await repo.updateSrsItem(srsItem);
await repo.updateSrsItem(srsItemForUpdate);
}
final correctDisplay = (_mode == QuizMode.kanjiToEnglish)
? _toTitleCase(_correctAnswers.first)
: (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first);
: (_mode == QuizMode.reading
? _correctAnswers.join(', ')
: _correctAnswers.first);
final snack = SnackBar(
content: Text(
@@ -271,7 +290,6 @@ class _HomeScreenState extends State<HomeScreen> {
],
),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 4,
@@ -282,9 +300,7 @@ class _HomeScreenState extends State<HomeScreen> {
_buildChoiceChip('Reading', QuizMode.reading),
],
),
const SizedBox(height: 18),
Expanded(
flex: 3,
child: Center(
@@ -303,9 +319,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
@@ -346,4 +360,4 @@ class _HomeScreenState extends State<HomeScreen> {
backgroundColor: const Color(0xFF1E1E1E),
);
}
}
}

View File

@@ -6,11 +6,13 @@ import '../services/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 VocabScreen extends StatefulWidget {
const VocabScreen({super.key});
@override
State<VocabScreen> createState() => _VocabScreenState();
}
@@ -19,7 +21,7 @@ class _VocabScreenState extends State<VocabScreen> {
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
final _audioPlayer = AudioPlayer();
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
@@ -55,7 +57,8 @@ class _VocabScreenState extends State<VocabScreen> {
}
var items = await repo.loadVocabulary();
if (items.isEmpty) {
if (items.isEmpty ||
items.every((item) => item.pronunciationAudios.isEmpty)) {
setState(() {
_status = 'Fetching deck...';
});
@@ -138,17 +141,18 @@ class _VocabScreenState extends State<VocabScreen> {
final srsKey = _mode.toString();
var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null;
srsItem ??= VocabSrsItem(vocabId: current.id, quizMode: _mode);
var srsItemNullable = current.srsItems[srsKey];
final isNew = srsItemNullable == null;
final srsItem =
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode);
setState(() {
_asked += 1;
if (isCorrect) {
_score += 1;
srsItem!.srsStage += 1;
srsItem.srsStage += 1;
} else {
srsItem!.srsStage = max(0, srsItem.srsStage - 1);
srsItem.srsStage = max(0, srsItem.srsStage - 1);
}
srsItem.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItem;
@@ -179,7 +183,23 @@ class _VocabScreenState extends State<VocabScreen> {
ScaffoldMessenger.of(context).showSnackBar(snack);
}
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
if (isCorrect) {
await _audioPlayer.play(AssetSource('sfx/confirm.mp3'));
final maleAudios =
current.pronunciationAudios.where((a) => a.gender == 'male');
if (maleAudios.isNotEmpty) {
try {
await _audioPlayer.play(UrlSource(maleAudios.first.url));
} catch (e) {
// Ignore player errors
}
}
await Future.delayed(const Duration(milliseconds: 400));
} else {
await Future.delayed(const Duration(milliseconds: 900));
}
_nextQuestion();
}
@override

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
@@ -23,7 +24,7 @@ class DeckRepository {
_db = await openDatabase(
path,
version: 5,
version: 6,
onCreate: (db, version) async {
await db.execute(
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''');
@@ -32,7 +33,7 @@ class DeckRepository {
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, characters TEXT, meanings TEXT, readings TEXT)''');
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, 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))''');
},
@@ -56,6 +57,13 @@ class DeckRepository {
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
}
}
},
);
@@ -257,6 +265,9 @@ class DeckRepository {
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',
{
@@ -264,6 +275,7 @@ class DeckRepository {
'characters': it.characters,
'meanings': it.meanings.join('|'),
'readings': it.readings.join('|'),
'pronunciation_audios': jsonEncode(audios),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
@@ -275,18 +287,36 @@ class DeckRepository {
final db = await _openDb();
final rows = await db.query('vocabulary');
final vocabItems = rows
.map((r) => VocabularyItem(
id: r['id'] as int,
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(),
))
.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?;
final List<PronunciationAudio> audios = [];
if (audiosRaw != null && audiosRaw.isNotEmpty) {
try {
final decoded = jsonDecode(audiosRaw) as List;
for (final audioData in decoded) {
audios.add(PronunciationAudio(
url: audioData['url'] as String,
gender: audioData['gender'] as String,
));
}
} catch (e) {
// Error decoding, so we'll just have no audio for this item
}
}
return VocabularyItem(
id: r['id'] as int,
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) {
@@ -336,4 +366,4 @@ class DeckRepository {
await saveVocabulary(items);
return items;
}
}
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,11 +5,13 @@
import FlutterMacOS
import Foundation
import audioplayers_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@@ -41,6 +41,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
url: "https://pub.dev"
source: hosted
version: "6.5.1"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
url: "https://pub.dev"
source: hosted
version: "6.3.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
boolean_selector:
dependency: transitive
description:
@@ -693,6 +749,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
@@ -821,6 +885,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:

View File

@@ -13,6 +13,7 @@ dependencies:
path: ^1.9.1
provider: ^6.1.5+1
http: ^1.5.0
audioplayers: ^6.0.0
dev_dependencies:
flutter_test:
@@ -28,3 +29,5 @@ flutter_icons:
flutter:
uses-material-design: true
assets:
- assets/sfx/confirm.mp3

View File

@@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_windows/audioplayers_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST