11 Commits

Author SHA1 Message Date
Rene Kievits
5f1b9ba12e finish v3 2025-11-02 19:00:17 +01:00
Rene Kievits
16da0f04ac possible to exclude levels and change how questions are served 2025-11-02 17:07:23 +01:00
Rene Kievits
e9f115a32a more cleanup and small fixes, new sound effects 2025-11-01 07:50:21 +01:00
Rene Kievits
d5ff5eb12f some cleanup and some fixes 2025-11-01 06:38:14 +01:00
732408997d Merge pull request 'themeing' (#7) from themeing into master
Reviewed-on: #7
2025-10-31 17:19:39 +01:00
Rene Kievits
de3501c3e4 themes and some refractoring 2025-10-31 17:18:33 +01:00
Rene Kievits
4eb488e28c themes 2025-10-31 13:40:14 +01:00
Rene Kievits
ad61292263 working on themes 2025-10-31 07:39:32 +01:00
4a6fce37b2 Merge pull request 'change a bunch of stuff, seperate tracking for progress, updated custom srs layout' (#6) from seperate_leassons_custom into master
Reviewed-on: #6
2025-10-31 07:17:46 +01:00
Rene Kievits
d8edfa1686 change a bunch of stuff, seperate tracking for progress, updated custom srs layout 2025-10-31 07:16:44 +01:00
Rene Kievits
cafec12888 fix .env loading 2025-10-30 18:02:06 +01:00
32 changed files with 2907 additions and 1256 deletions

Binary file not shown.

BIN
assets/sfx/correct.wav Normal file

Binary file not shown.

BIN
assets/sfx/incorrect.wav Normal file

Binary file not shown.

View File

@@ -1,21 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hirameki_srs/src/models/theme_model.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import 'package:provider/provider.dart'; 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");
} catch (e) { } catch (_) {}
// It's okay if the .env file is not found.
// This is expected in release builds.
}
runApp( runApp(
Provider<DeckRepository>( MultiProvider(
create: (_) => DeckRepository(), providers: [
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(), child: const WkApp(),
), ),
); );
@@ -26,11 +38,15 @@ class WkApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return Consumer<ThemeModel>(
title: 'Hirameki SRS', builder: (context, themeModel, child) {
debugShowCheckedModeBanner: false, return MaterialApp(
theme: ThemeData.dark(useMaterial3: true), title: 'Hirameki SRS',
home: const StartScreen(), debugShowCheckedModeBanner: false,
theme: themeModel.currentTheme,
home: const StartScreen(),
);
},
); );
} }
} }

View File

@@ -1,14 +1,24 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/subject.dart';
import '../models/kanji_item.dart';
import '../models/vocabulary_item.dart';
class WkClient { class WkClient {
final String apiKey; final String apiKey;
final Map<String, String> headers; final Map<String, String> headers;
final String base = 'https://api.wanikani.com/v2'; final String base = 'https://api.wanikani.com/v2';
WkClient(this.apiKey) : headers = {'Authorization': 'Bearer $apiKey', 'Wanikani-Revision': '20170710', 'Accept': 'application/json'}; WkClient(this.apiKey)
: headers = {
'Authorization': 'Bearer $apiKey',
'Wanikani-Revision': '20170710',
'Accept': 'application/json',
};
Future<List<Map<String, dynamic>>> fetchAllAssignments({List<String>? subjectTypes}) async { Future<List<Map<String, dynamic>>> fetchAllAssignments({
List<String>? subjectTypes,
}) async {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
String url = '$base/assignments?page=1'; String url = '$base/assignments?page=1';
if (subjectTypes != null && subjectTypes.isNotEmpty) { if (subjectTypes != null && subjectTypes.isNotEmpty) {
@@ -30,7 +40,9 @@ class WkClient {
return out; return out;
} }
Future<List<Map<String, dynamic>>> fetchAllSubjects({List<String>? types}) async { Future<List<Map<String, dynamic>>> fetchAllSubjects({
List<String>? types,
}) async {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
String url = '$base/subjects'; String url = '$base/subjects';
if (types != null && types.isNotEmpty) { if (types != null && types.isNotEmpty) {
@@ -56,7 +68,10 @@ class WkClient {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
const batch = 100; const batch = 100;
for (var i = 0; i < ids.length; i += batch) { for (var i = 0; i < ids.length; i += batch) {
final chunk = ids.sublist(i, i + batch > ids.length ? ids.length : i + batch); final chunk = ids.sublist(
i,
i + batch > ids.length ? ids.length : i + batch,
);
String url = '$base/subjects?ids=${chunk.join(',')}&page=1'; String url = '$base/subjects?ids=${chunk.join(',')}&page=1';
while (true) { while (true) {
final resp = await http.get(Uri.parse(url), headers: headers); final resp = await http.get(Uri.parse(url), headers: headers);
@@ -73,4 +88,14 @@ class WkClient {
} }
return out; return out;
} }
static Subject createSubjectFromMap(Map<String, dynamic> map) {
final String object = map['object'];
if (object == 'kanji') {
return KanjiItem.fromSubject(map);
} else if (object == 'vocabulary') {
return VocabularyItem.fromSubject(map);
}
throw Exception('Unknown subject type: $object');
}
} }

View File

@@ -1,31 +1,48 @@
class CustomKanjiItem { class CustomKanjiItem {
final String characters; final String characters;
final String meaning; final String meaning;
final String? kanji; final String? kanji;
final bool useInterval; final bool useInterval;
int srsLevel; SrsData srsData;
DateTime? nextReview;
CustomKanjiItem({ CustomKanjiItem({
required this.characters, required this.characters,
required this.meaning, required this.meaning,
this.kanji, this.kanji,
this.useInterval = false, this.useInterval = false,
this.srsLevel = 0, SrsData? srsData,
this.nextReview, }) : srsData = srsData ?? SrsData();
});
factory CustomKanjiItem.fromJson(Map<String, dynamic> json) { factory CustomKanjiItem.fromJson(Map<String, dynamic> 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( return CustomKanjiItem(
characters: json['characters'] as String, characters: json['characters'] as String,
meaning: json['meaning'] as String, meaning: json['meaning'] as String,
kanji: json['kanji'] as String?, kanji: json['kanji'] as String?,
useInterval: json['useInterval'] as bool? ?? false, useInterval: json['useInterval'] as bool? ?? false,
srsLevel: json['srsLevel'] as int? ?? 0, srsData: srsData,
nextReview: json['nextReview'] != null
? DateTime.parse(json['nextReview'] as String)
: null,
); );
} }
@@ -35,8 +52,57 @@ class CustomKanjiItem {
'meaning': meaning, 'meaning': meaning,
'kanji': kanji, 'kanji': kanji,
'useInterval': useInterval, 'useInterval': useInterval,
'srsLevel': srsLevel, 'srsData': srsData.toJson(),
'nextReview': nextReview?.toIso8601String(), };
}
}
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<String, dynamic> 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<String, dynamic> toJson() {
return {
'japaneseToEnglish': japaneseToEnglish,
'japaneseToEnglishNextReview': japaneseToEnglishNextReview
?.toIso8601String(),
'englishToJapanese': englishToJapanese,
'englishToJapaneseNextReview': englishToJapaneseNextReview
?.toIso8601String(),
'listeningComprehension': listeningComprehension,
'listeningComprehensionNextReview': listeningComprehensionNextReview
?.toIso8601String(),
}; };
} }
} }

View File

@@ -1,54 +1,24 @@
enum QuizMode { kanjiToEnglish, englishToKanji, reading } import 'subject.dart';
class SrsItem { class KanjiItem extends Subject {
final int kanjiId;
final QuizMode quizMode;
final String? readingType; // 'onyomi' or 'kunyomi'
int srsStage;
DateTime lastAsked;
SrsItem({
required this.kanjiId,
required this.quizMode,
this.readingType,
this.srsStage = 0,
DateTime? lastAsked,
}) : lastAsked = lastAsked ?? DateTime.now();
}
class KanjiItem {
final int id;
final int level;
final String characters;
final List<String> meanings;
final List<String> onyomi; final List<String> onyomi;
final List<String> kunyomi; final List<String> kunyomi;
final Map<String, SrsItem> srsItems = {};
KanjiItem({ KanjiItem({
required this.id, required super.id,
required this.level, required super.level,
required this.characters, required super.characters,
required this.meanings, required super.meanings,
required this.onyomi, required this.onyomi,
required this.kunyomi, required this.kunyomi,
}); });
factory KanjiItem.fromSubject(Map<String, dynamic> subj) { factory KanjiItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int; final commonFields = Subject.parseCommonFields(subj);
final data = subj['data'] as Map<String, dynamic>; final data = commonFields['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> onyomi = <String>[]; final List<String> onyomi = <String>[];
final List<String> kunyomi = <String>[]; final List<String> kunyomi = <String>[];
if (data['meanings'] != null) {
for (final m in data['meanings'] as List) {
meanings.add((m['meaning'] as String).toLowerCase());
}
}
if (data['readings'] != null) { if (data['readings'] != null) {
for (final r in data['readings'] as List) { for (final r in data['readings'] as List) {
final typ = r['type'] as String? ?? ''; final typ = r['type'] as String? ?? '';
@@ -62,10 +32,10 @@ class KanjiItem {
} }
return KanjiItem( return KanjiItem(
id: id, id: commonFields['id'] as int,
level: level, level: commonFields['level'] as int,
characters: characters, characters: commonFields['characters'] as String,
meanings: meanings, meanings: commonFields['meanings'] as List<String>,
onyomi: onyomi, onyomi: onyomi,
kunyomi: kunyomi, kunyomi: kunyomi,
); );
@@ -83,89 +53,3 @@ String _katakanaToHiragana(String input) {
} }
return buf.toString(); return buf.toString();
} }
enum VocabQuizMode { vocabToEnglish, englishToVocab, audioToEnglish }
class VocabSrsItem {
final int vocabId;
final VocabQuizMode quizMode;
int srsStage;
DateTime lastAsked;
VocabSrsItem({
required this.vocabId,
required this.quizMode,
this.srsStage = 0,
DateTime? lastAsked,
}) : 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 int level;
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.level,
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;
final data = subj['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
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) {
meanings.add((m['meaning'] as String).toLowerCase());
}
}
if (data['readings'] != null) {
for (final r in data['readings'] as List) {
readings.add(r['reading'] as String);
}
}
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,
level: level,
characters: characters,
meanings: meanings,
readings: readings,
pronunciationAudios: pronunciationAudios);
}
}

View File

@@ -0,0 +1,19 @@
enum QuizMode { kanjiToEnglish, englishToKanji, reading, vocabToEnglish, englishToVocab, audioToEnglish }
class SrsItem {
final int subjectId;
final QuizMode quizMode;
final String? readingType;
int srsStage;
DateTime lastAsked;
bool disabled;
SrsItem({
required this.subjectId,
required this.quizMode,
this.readingType,
this.srsStage = 0,
DateTime? lastAsked,
this.disabled = false,
}) : lastAsked = lastAsked ?? DateTime.now();
}

View File

@@ -0,0 +1,38 @@
import 'srs_item.dart';
abstract class Subject {
final int id;
final int level;
final String characters;
final List<String> meanings;
final Map<String, SrsItem> srsItems = {};
Subject({
required this.id,
required this.level,
required this.characters,
required this.meanings,
});
static Map<String, dynamic> parseCommonFields(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
final data = subj['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
if (data['meanings'] != null) {
for (final m in data['meanings'] as List) {
meanings.add((m['meaning'] as String).toLowerCase());
}
}
return {
'id': id,
'level': level,
'characters': characters,
'meanings': meanings,
'data': data,
};
}
}

View File

@@ -0,0 +1,15 @@
import 'kanji_item.dart';
import 'vocabulary_item.dart';
import 'subject.dart';
class SubjectFactory {
static Subject fromMap(Map<String, dynamic> map) {
final String object = map['object'];
if (object == 'kanji') {
return KanjiItem.fromSubject(map);
} else if (object == 'vocabulary') {
return VocabularyItem.fromSubject(map);
}
throw Exception('Unknown subject type: $object');
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:hirameki_srs/src/themes.dart';
class ThemeModel extends ChangeNotifier {
ThemeData _currentTheme = Themes.dark;
ThemeData get currentTheme => _currentTheme;
void setTheme(ThemeData theme) {
_currentTheme = theme;
notifyListeners();
}
}

View File

@@ -0,0 +1,56 @@
import 'subject.dart';
class PronunciationAudio {
final String url;
final String gender;
PronunciationAudio({required this.url, required this.gender});
}
class VocabularyItem extends Subject {
final List<String> readings;
final List<PronunciationAudio> pronunciationAudios;
VocabularyItem({
required super.id,
required super.level,
required super.characters,
required super.meanings,
required this.readings,
required this.pronunciationAudios,
});
factory VocabularyItem.fromSubject(Map<String, dynamic> subj) {
final commonFields = Subject.parseCommonFields(subj);
final data = commonFields['data'] as Map<String, dynamic>;
final List<String> readings = <String>[];
final List<PronunciationAudio> pronunciationAudios = <PronunciationAudio>[];
if (data['readings'] != null) {
for (final r in data['readings'] as List) {
readings.add(r['reading'] as String);
}
}
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: commonFields['id'] as int,
level: commonFields['level'] as int,
characters: commonFields['characters'] as String,
meanings: commonFields['meanings'] as List<String>,
readings: readings,
pronunciationAudios: pronunciationAudios,
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:kana_kit/kana_kit.dart'; import 'package:kana_kit/kana_kit.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
@@ -19,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
@@ -32,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(
@@ -48,14 +61,39 @@ 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
? SrsData(
japaneseToEnglishNextReview: DateTime.now(),
englishToJapaneseNextReview: DateTime.now(),
listeningComprehensionNextReview: DateTime.now(),
)
: SrsData();
final newItem = CustomKanjiItem( final newItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null, kanji: _kanjiController.text.trim().isNotEmpty
? _kanjiController.text.trim()
: null,
useInterval: _useInterval, useInterval: _useInterval,
nextReview: _useInterval ? DateTime.now() : null, srsData: srsData,
); );
_deckRepository.addCard(newItem); _deckRepository.addCard(newItem);
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -65,9 +103,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Add New Card')),
title: const Text('Add New Card'),
),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Form( child: Form(
@@ -76,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',

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,11 @@ class CustomCardDetailsScreen extends StatefulWidget {
final CustomKanjiItem item; final CustomKanjiItem item;
final CustomDeckRepository repository; final CustomDeckRepository repository;
const CustomCardDetailsScreen( const CustomCardDetailsScreen({
{super.key, required this.item, required this.repository}); super.key,
required this.item,
required this.repository,
});
@override @override
State<CustomCardDetailsScreen> createState() => State<CustomCardDetailsScreen> createState() =>
@@ -19,7 +22,6 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
late TextEditingController _englishController; late TextEditingController _englishController;
late TextEditingController _kanjiController; late TextEditingController _kanjiController;
late bool _useInterval; late bool _useInterval;
late int _srsLevel;
@override @override
void initState() { void initState() {
@@ -28,7 +30,6 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
_englishController = TextEditingController(text: widget.item.meaning); _englishController = TextEditingController(text: widget.item.meaning);
_kanjiController = TextEditingController(text: widget.item.kanji); _kanjiController = TextEditingController(text: widget.item.kanji);
_useInterval = widget.item.useInterval; _useInterval = widget.item.useInterval;
_srsLevel = widget.item.srsLevel;
} }
@override @override
@@ -43,10 +44,11 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
final updatedItem = CustomKanjiItem( final updatedItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text, kanji: _kanjiController.text.trim().isNotEmpty
? _kanjiController.text.trim()
: null,
useInterval: _useInterval, useInterval: _useInterval,
srsLevel: _srsLevel, srsData: widget.item.srsData,
nextReview: widget.item.nextReview,
); );
widget.repository.updateCard(updatedItem); widget.repository.updateCard(updatedItem);
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -82,10 +84,7 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Edit Card'), title: const Text('Edit Card'),
actions: [ actions: [
IconButton( IconButton(icon: const Icon(Icons.delete), onPressed: _deleteCard),
icon: const Icon(Icons.delete),
onPressed: _deleteCard,
),
], ],
), ),
body: Padding( body: Padding(
@@ -113,7 +112,20 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
}); });
}, },
), ),
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), const SizedBox(height: 20),
ElevatedButton( ElevatedButton(
onPressed: _saveChanges, onPressed: _saveChanges,

View File

@@ -1,16 +1,25 @@
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 'package:provider/provider.dart';
import '../services/tts_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:audioplayers/audioplayers.dart';
enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension } enum CustomQuizMode {
japaneseToEnglish,
englishToJapanese,
listeningComprehension,
}
class CustomQuizScreen extends StatefulWidget { class CustomQuizScreen extends StatefulWidget {
final List<CustomKanjiItem> deck; final List<CustomKanjiItem> deck;
final CustomQuizMode quizMode; final CustomQuizMode quizMode;
final Function(CustomKanjiItem) onCardReviewed; final Function(CustomKanjiItem) onCardReviewed;
final bool useKanji; final bool useKanji;
final bool isActive;
const CustomQuizScreen({ const CustomQuizScreen({
super.key, super.key,
@@ -18,211 +27,430 @@ class CustomQuizScreen extends StatefulWidget {
required this.quizMode, required this.quizMode,
required this.onCardReviewed, required this.onCardReviewed,
required this.useKanji, required this.useKanji,
required this.isActive,
}); });
@override @override
State<CustomQuizScreen> createState() => CustomQuizScreenState(); State<CustomQuizScreen> createState() => CustomQuizScreenState();
} }
class _CustomQuizState {
CustomKanjiItem? current;
List<String> options = [];
List<String> correctAnswers = [];
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
Set<String> wrongItems = {};
}
class CustomQuizScreenState extends State<CustomQuizScreen> class CustomQuizScreenState extends State<CustomQuizScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
int _currentIndex = 0; final _quizState = _CustomQuizState();
List<CustomKanjiItem> _shuffledDeck = []; List<CustomKanjiItem> _shuffledDeck = [];
List<String> _options = []; int _sessionDeckSize = 0;
bool _answered = false; bool _isAnswering = false;
bool? _correct;
late FlutterTts _flutterTts;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
final _audioPlayer = AudioPlayer();
bool _playIncorrectSound = true;
bool _playCorrectSound = true;
bool _playNarrator = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_shuffledDeck = widget.deck.toList()..shuffle(); _shuffledDeck = widget.deck.toList()..shuffle();
_initTts(); _sessionDeckSize = _shuffledDeck.length;
if (_shuffledDeck.isNotEmpty) {
_generateOptions();
}
_shakeController = AnimationController( _shakeController = AnimationController(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
vsync: this, vsync: this,
); );
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate( _shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation( CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
parent: _shakeController,
curve: Curves.elasticIn,
),
); );
_loadSettings();
_nextQuestion();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
} }
@override @override
void didUpdateWidget(CustomQuizScreen oldWidget) { void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.deck != oldWidget.deck && !widget.isActive) {
_shuffledDeck = widget.deck.toList()..shuffle();
_sessionDeckSize = _shuffledDeck.length;
_nextQuestion();
}
if (widget.useKanji != oldWidget.useKanji) { if (widget.useKanji != oldWidget.useKanji) {
setState(() { _nextQuestion();
_generateOptions();
});
} }
} }
void playAudio() { void playAudio() async {
if (widget.quizMode == CustomQuizMode.listeningComprehension) { final quizState = _quizState;
_speak(_shuffledDeck[_currentIndex].characters); if (widget.quizMode == CustomQuizMode.listeningComprehension &&
} quizState.current != null &&
} _playNarrator) {
final ttsService = Provider.of<TtsService>(context, listen: false);
void _initTts() async { await ttsService.speak(quizState.current!.characters);
_flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP");
if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
} }
} }
@override @override
void dispose() { void dispose() {
_flutterTts.stop();
_shakeController.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
void _generateOptions() { void _answer(String option) async {
final currentItem = _shuffledDeck[_currentIndex]; final quizState = _quizState;
if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) { final current = quizState.current!;
_options = [currentItem.meaning]; final isCorrect = quizState.correctAnswers
} else { .map((a) => a.toLowerCase().trim())
_options = [widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters]; .contains(option.toLowerCase().trim());
}
final otherItems = widget.deck
.where((item) => item.characters != currentItem.characters)
.toList();
otherItems.shuffle();
for (var i = 0; i < min(3, otherItems.length); i++) {
if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) {
_options.add(otherItems[i].meaning);
} else {
_options.add(widget.useKanji && otherItems[i].kanji != null ? otherItems[i].kanji! : otherItems[i].characters);
}
}
_options.shuffle();
}
void _checkAnswer(String answer) {
final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
: currentItem.meaning;
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));
}
widget.onCardReviewed(currentItem);
}
setState(() { setState(() {
_answered = true; quizState.selectedOption = option;
_correct = isCorrect; quizState.showResult = true;
_isAnswering = true;
}); });
if (current.useInterval) {
_updateSrsLevel(current, isCorrect);
}
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (widget.useKanji && current.kanji != null
? current.kanji!
: current.characters)
: current.meaning;
final snack = SnackBar(
content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle(
color: isCorrect
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold,
),
),
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack);
}
if (isCorrect) { if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish || quizState.asked += 1;
widget.quizMode == CustomQuizMode.listeningComprehension) { if (!quizState.wrongItems.contains(current.characters)) {
_speak(currentItem.characters); quizState.score += 1;
}
if (_playCorrectSound && !_playNarrator) {
await _audioPlayer.play(AssetSource('sfx/correct.wav'));
} else if (_playNarrator) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.englishToJapanese) {
await _speak(current.characters);
}
}
await Future.delayed(const Duration(milliseconds: 500));
} else {
quizState.wrongItems.add(current.characters);
_shuffledDeck.add(current);
_shuffledDeck.shuffle();
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
_shakeController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 900));
}
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});
}
void _updateSrsLevel(CustomKanjiItem item, bool isCorrect) {
int currentSrsLevel = 0;
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
currentSrsLevel = item.srsData.japaneseToEnglish;
break;
case CustomQuizMode.englishToJapanese:
currentSrsLevel = item.srsData.englishToJapanese;
break;
case CustomQuizMode.listeningComprehension:
currentSrsLevel = item.srsData.listeningComprehension;
break;
}
if (isCorrect) {
currentSrsLevel++;
final interval = pow(2, currentSrsLevel).toInt();
final newNextReview = DateTime.now().add(Duration(hours: interval));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehensionNextReview = newNextReview;
break;
} }
} else { } else {
_shakeController.forward(from: 0); currentSrsLevel = max(0, currentSrsLevel - 1);
final newNextReview = DateTime.now().add(const Duration(hours: 1));
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglishNextReview = newNextReview;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapaneseNextReview = newNextReview;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehensionNextReview = newNextReview;
break;
}
} }
switch (widget.quizMode) {
case CustomQuizMode.japaneseToEnglish:
item.srsData.japaneseToEnglish = currentSrsLevel;
break;
case CustomQuizMode.englishToJapanese:
item.srsData.englishToJapanese = currentSrsLevel;
break;
case CustomQuizMode.listeningComprehension:
item.srsData.listeningComprehension = currentSrsLevel;
break;
}
widget.onCardReviewed(item);
} }
void _nextQuestion() { void _nextQuestion() {
final quizState = _quizState;
if (_shuffledDeck.isEmpty) {
setState(() {
quizState.current = null;
});
return;
}
quizState.current = _shuffledDeck.removeAt(0);
quizState.key = UniqueKey();
quizState.correctAnswers = [];
quizState.options = [];
quizState.selectedOption = null;
quizState.showResult = false;
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.listeningComprehension) {
quizState.correctAnswers = [quizState.current!.meaning];
quizState.options = [quizState.correctAnswers.first];
} else {
quizState.correctAnswers = [
widget.useKanji && quizState.current!.kanji != null
? quizState.current!.kanji!
: quizState.current!.characters,
];
quizState.options = [quizState.correctAnswers.first];
}
final otherItems = widget.deck
.where((item) => item.characters != quizState.current!.characters)
.toList();
otherItems.shuffle();
for (var i = 0; i < min(3, otherItems.length); i++) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.listeningComprehension) {
quizState.options.add(otherItems[i].meaning);
} else {
quizState.options.add(
widget.useKanji && otherItems[i].kanji != null
? otherItems[i].kanji!
: otherItems[i].characters,
);
}
}
while (quizState.options.length < 4) {
quizState.options.add('---');
}
quizState.options.shuffle();
setState(() { setState(() {
_currentIndex = (_currentIndex + 1) % _shuffledDeck.length; _isAnswering = false;
_answered = false;
_correct = null;
_generateOptions();
}); });
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters); _speak(quizState.current!.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) {
if (!(_answered && _correct!)) { if (!_isAnswering) {
_checkAnswer(option); _answer(option);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_shuffledDeck.isEmpty) { final quizState = _quizState;
return const Center(
child: Text('Review session complete!'), if (quizState.current == null) {
return Center(
child: Text(
'Review session complete!',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
); );
} }
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = quizState.current!;
final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning
: (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
return Center( Widget promptWidget;
String subtitle = '';
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
promptWidget = IconButton(
icon: const Icon(Icons.volume_up, size: 64),
onPressed: () => _speak(currentItem.characters),
);
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
promptWidget = Text(
currentItem.meaning,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
);
} else {
final promptText = widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters;
promptWidget = GestureDetector(
onTap: () => _speak(promptText),
child: Text(
promptText,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
);
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (widget.quizMode == CustomQuizMode.listeningComprehension) Column(
IconButton( crossAxisAlignment: CrossAxisAlignment.start,
icon: const Icon(Icons.volume_up, size: 64), children: [
onPressed: () => _speak(currentItem.characters), Text(
) '${quizState.asked} / $_sessionDeckSize',
else style: TextStyle(
GestureDetector( color: Theme.of(context).colorScheme.onSurface,
onTap: () => _speak(question), fontSize: 18,
child: Text( fontWeight: FontWeight.bold,
question, ),
style: const TextStyle(fontSize: 48),
textAlign: TextAlign.center,
), ),
), const SizedBox(height: 4),
const SizedBox(height: 32), LinearProgressIndicator(
if (_answered) value: _sessionDeckSize > 0
Text( ? quizState.asked / _sessionDeckSize
_correct! ? 'Correct!' : 'Incorrect, try again!', : 0,
style: TextStyle( backgroundColor: Theme.of(
fontSize: 24, context,
color: _correct! ? Colors.green : Colors.red, ).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
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: subtitle,
),
), ),
),
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!) const SizedBox(height: 12),
ElevatedButton( SafeArea(
onPressed: _nextQuestion, top: false,
child: const Text('Next'), child: Column(
children: [
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value * 10, 0),
child: child,
);
},
child: OptionsGrid(
options: quizState.options,
onSelected: _isAnswering ? (option) {} : _onOptionSelected,
selectedOption: quizState.selectedOption,
correctAnswers: quizState.correctAnswers,
showResult: quizState.showResult,
),
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
), ),
),
], ],
), ),
); );

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
import '../services/custom_deck_repository.dart'; import '../services/custom_deck_repository.dart';
import 'add_card_screen.dart';
import 'custom_quiz_screen.dart'; import 'custom_quiz_screen.dart';
class CustomSrsScreen extends StatefulWidget { class CustomSrsScreen extends StatefulWidget {
@@ -11,11 +10,11 @@ class CustomSrsScreen extends StatefulWidget {
State<CustomSrsScreen> createState() => _CustomSrsScreenState(); State<CustomSrsScreen> createState() => _CustomSrsScreenState();
} }
class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProviderStateMixin { class _CustomSrsScreenState extends State<CustomSrsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
List<CustomKanjiItem> _deck = []; List<CustomKanjiItem> _deck = [];
List<CustomKanjiItem> _reviewDeck = [];
bool _useKanji = false; bool _useKanji = false;
final _quizScreenKeys = [ final _quizScreenKeys = [
GlobalKey<CustomQuizScreenState>(), GlobalKey<CustomQuizScreenState>(),
@@ -45,26 +44,18 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
Future<void> _loadDeck() async { Future<void> _loadDeck() async {
final deck = await _deckRepository.getCustomDeck(); 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(() { setState(() {
_deck = deck; _deck = deck;
_reviewDeck = reviewDeck;
}); });
} }
Future<void> _updateCard(CustomKanjiItem item) async { Future<void> _updateCard(CustomKanjiItem item) async {
final index = _deck.indexWhere((element) => element.characters == item.characters); final index = _deck.indexWhere(
(element) => element.characters == item.characters,
);
if (index != -1) { if (index != -1) {
setState(() { setState(() {
_deck[index] = item; _deck[index] = item;
_reviewDeck.removeWhere((element) => element.characters == item.characters);
}); });
await _deckRepository.saveDeck(_deck); await _deckRepository.saveDeck(_deck);
} }
@@ -72,6 +63,30 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Custom SRS'), title: const Text('Custom SRS'),
@@ -102,43 +117,37 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
), ),
body: _deck.isEmpty body: _deck.isEmpty
? const Center(child: Text('Add cards to start quizzing!')) ? const Center(child: Text('Add cards to start quizzing!'))
: _reviewDeck.isEmpty : allDecksEmpty
? const Center(child: Text('No cards due for review.')) ? const Center(child: Text('No cards due for review.'))
: TabBarView( : TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[0], key: _quizScreenKeys[0],
deck: _reviewDeck, deck: jpnToEngReviewDeck,
quizMode: CustomQuizMode.japaneseToEnglish, quizMode: CustomQuizMode.japaneseToEnglish,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, useKanji: _useKanji,
isActive: _tabController.index == 0,
), ),
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[1], key: _quizScreenKeys[1],
deck: _reviewDeck, deck: engToJpnReviewDeck,
quizMode: CustomQuizMode.englishToJapanese, quizMode: CustomQuizMode.englishToJapanese,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, useKanji: _useKanji,
isActive: _tabController.index == 1,
), ),
CustomQuizScreen( CustomQuizScreen(
key: _quizScreenKeys[2], key: _quizScreenKeys[2],
deck: _reviewDeck, deck: listeningReviewDeck,
quizMode: CustomQuizMode.listeningComprehension, quizMode: CustomQuizMode.listeningComprehension,
onCardReviewed: _updateCard, onCardReviewed: _updateCard,
useKanji: _useKanji, 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),
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/srs_item.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import '../services/distractor_generator.dart'; import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
@@ -18,6 +19,19 @@ class _ReadingInfo {
_ReadingInfo(this.correctReadings, this.hint); _ReadingInfo(this.correctReadings, this.hint);
} }
class _QuizState {
KanjiItem? current;
List<String> options = [];
List<String> correctAnswers = [];
String readingHint = '';
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
Set<int> wrongItems = {};
}
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, this.distractorGenerator}); const HomeScreen({super.key, this.distractorGenerator});
@@ -27,21 +41,23 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin { class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<KanjiItem> _deck = []; List<KanjiItem> _deck = [];
bool _loading = false; bool _loading = false;
bool _isAnswering = false;
String _status = 'Loading deck...'; String _status = 'Loading deck...';
late final DistractorGenerator _dg; late final DistractorGenerator _dg;
final Random _random = Random(); final Random _random = Random();
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
KanjiItem? _current; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
List<String> _options = []; final _sessionDecks = <int, List<KanjiItem>>{};
List<String> _correctAnswers = []; final _sessionDeckSizes = <int, int>{};
String _readingHint = ''; _QuizState get _currentQuizState => _quizStates[_tabController.index];
int _score = 0;
int _asked = 0; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@@ -51,7 +67,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
setState(() {}); setState(() {});
_nextQuestion();
}); });
_dg = widget.distractorGenerator ?? DistractorGenerator(); _dg = widget.distractorGenerator ?? DistractorGenerator();
_loadSettings(); _loadSettings();
@@ -67,6 +82,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
}); });
} }
@@ -105,7 +121,52 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
_nextQuestion(); final disabledLevels = <int>{};
final itemsByLevel = <int, List<KanjiItem>>{};
for (final item in _deck) {
(itemsByLevel[item.level] ??= []).add(item);
}
itemsByLevel.forEach((level, items) {
final allSrsItems = items
.expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
for (var i = 0; i < _tabController.length; i++) {
final mode = _modeForIndex(i);
final filteredDeck = _deck.where((item) {
if (disabledLevels.contains(item.level)) {
return false;
}
if (mode == QuizMode.reading) {
final onyomiSrs = item.srsItems['${QuizMode.reading}onyomi'];
final kunyomiSrs = item.srsItems['${QuizMode.reading}kunyomi'];
final hasOnyomi =
item.onyomi.isNotEmpty &&
(onyomiSrs == null || !onyomiSrs.disabled);
final hasKunyomi =
item.kunyomi.isNotEmpty &&
(kunyomiSrs == null || !kunyomiSrs.disabled);
return hasOnyomi || hasKunyomi;
}
final srsItem = item.srsItems[mode.toString()];
return srsItem == null || !srsItem.disabled;
}).toList();
filteredDeck.shuffle(_random);
_sessionDecks[i] = filteredDeck;
_sessionDeckSizes[i] = filteredDeck.length;
}
for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i);
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_status = 'Error: $e'; _status = 'Error: $e';
@@ -135,8 +196,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return _ReadingInfo(readingsList, hint); return _ReadingInfo(readingsList, hint);
} }
QuizMode get _mode { QuizMode _modeForIndex(int index) {
switch (_tabController.index) { switch (index) {
case 0: case 0:
return QuizMode.kanjiToEnglish; return QuizMode.kanjiToEnglish;
case 1: case 1:
@@ -148,127 +209,134 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
} }
} }
void _nextQuestion() { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; final tabIndex = index ?? _tabController.index;
final quizState = _quizStates[tabIndex];
final sessionDeck = _sessionDecks[tabIndex];
final mode = _modeForIndex(tabIndex);
_deck.sort((a, b) { if (sessionDeck == null || sessionDeck.isEmpty) {
String srsKey(KanjiItem item) { setState(() {
var key = _mode.toString(); quizState.current = null;
if (_mode == QuizMode.reading) { _status = 'Quiz complete!';
if (item.onyomi.isNotEmpty && item.kunyomi.isNotEmpty) { });
key += _random.nextBool() ? 'onyomi' : 'kunyomi'; return;
} else if (item.onyomi.isNotEmpty) { }
key += 'onyomi';
} else {
key += 'kunyomi';
}
}
return key;
}
final aSrsItem = a.srsItems[srsKey(a)]; quizState.current = sessionDeck.removeAt(0);
final bSrsItem = b.srsItems[srsKey(b)]; quizState.key = UniqueKey();
final aStage = aSrsItem?.srsStage ?? 0; quizState.correctAnswers = [];
final bStage = bSrsItem?.srsStage ?? 0; quizState.options = [];
quizState.readingHint = '';
quizState.selectedOption = null;
quizState.showResult = false;
if (aStage != bStage) { switch (mode) {
return aStage.compareTo(bStage);
}
final aLastAsked =
aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
final bLastAsked =
bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
if (aLastAsked != bLastAsked) {
return aLastAsked.compareTo(bLastAsked);
}
return _random.nextDouble().compareTo(_random.nextDouble());
});
_current = _deck.first;
_correctAnswers = [];
_options = [];
_readingHint = '';
switch (_mode) {
case QuizMode.kanjiToEnglish: case QuizMode.kanjiToEnglish:
_correctAnswers = [_current!.meanings.first]; quizState.correctAnswers = [quizState.current!.meanings.first];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateMeanings(_current!, _deck, 3) ..._dg.generateMeanings(quizState.current!, _deck, 3),
].map(_toTitleCase).toList() ].map(_toTitleCase).toList()..shuffle();
..shuffle();
break; break;
case QuizMode.englishToKanji: case QuizMode.englishToKanji:
_correctAnswers = [_current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateKanji(_current!, _deck, 3) ..._dg.generateKanji(quizState.current!, _deck, 3),
]..shuffle(); ]..shuffle();
break; break;
case QuizMode.reading: case QuizMode.reading:
final info = _pickReading(_current!); final info = _pickReading(quizState.current!);
_correctAnswers = info.correctReadings; quizState.correctAnswers = info.correctReadings;
_readingHint = info.hint; 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.onyomi)
: _deck.expand((k) => k.kunyomi); : _deck.expand((k) => k.kunyomi);
final distractors = readingsSource final distractors =
.where((r) => !_correctAnswers.contains(r)) readingsSource
.toSet() .where((r) => !quizState.correctAnswers.contains(r))
.toList() .toSet()
..shuffle(); .toList()
_options = ([ ..shuffle();
_correctAnswers[_random.nextInt(_correctAnswers.length)], quizState.options = ([
...distractors.take(3) quizState.correctAnswers[_random.nextInt(
]) quizState.correctAnswers.length,
..shuffle(); )],
...distractors.take(3),
])..shuffle();
break;
default:
break; break;
} }
setState(() {}); setState(() {
_isAnswering = false;
});
} }
void _answer(String option) async { 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()) .map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim()); .contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<DeckRepository>(context, listen: false);
final current = _current!; final current = quizState.current!;
final tabIndex = _tabController.index;
final sessionDeck = _sessionDecks[tabIndex]!;
String readingType = ''; String readingType = '';
if (_mode == QuizMode.reading) { if (mode == QuizMode.reading) {
readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; readingType = quizState.readingHint.contains("on'yomi")
? 'onyomi'
: 'kunyomi';
} }
final srsKey = _mode.toString() + readingType; final srsKey = mode.toString() + readingType;
var srsItem = current.srsItems[srsKey]; var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null; final isNew = srsItem == null;
final srsItemForUpdate = srsItem ??= final srsItemForUpdate = srsItem ??= SrsItem(
SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType); subjectId: current.id,
setState(() { quizMode: mode,
_asked += 1; readingType: readingType,
if (isCorrect) { );
_score += 1;
srsItemForUpdate.srsStage += 1; quizState.selectedOption = option;
if (_playCorrectSound) {
_audioPlayer.play(AssetSource('sfx/confirm.mp3')); quizState.showResult = true;
}
} else { setState(() {});
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
if (isCorrect) {
quizState.asked += 1;
if (!quizState.wrongItems.contains(current.id)) {
quizState.score += 1;
} }
srsItemForUpdate.lastAsked = DateTime.now(); srsItemForUpdate.srsStage += 1;
current.srsItems[srsKey] = srsItemForUpdate; if (_playCorrectSound) {
}); _audioPlayer.play(AssetSource('sfx/correct.wav'));
}
} else {
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
sessionDeck.add(current);
sessionDeck.shuffle(_random);
quizState.wrongItems.add(current.id);
if (_playIncorrectSound) {
_audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
}
srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final theme = Theme.of(context);
if (isNew) { if (isNew) {
await repo.insertSrsItem(srsItemForUpdate); await repo.insertSrsItem(srsItemForUpdate);
@@ -276,28 +344,38 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
await repo.updateSrsItem(srsItemForUpdate); await repo.updateSrsItem(srsItemForUpdate);
} }
final correctDisplay = (_mode == QuizMode.kanjiToEnglish) final correctDisplay = (mode == QuizMode.kanjiToEnglish)
? _toTitleCase(_correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: (_mode == QuizMode.reading : (mode == QuizMode.reading
? _correctAnswers.join(', ') ? quizState.correctAnswers.join(', ')
: _correctAnswers.first); : quizState.correctAnswers.first);
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent, color: isCorrect
? theme.colorScheme.primary
: theme.colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
backgroundColor: const Color(0xFF222222), backgroundColor: theme.colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack); scaffoldMessenger.showSnackBar(snack);
} }
Future.delayed(const Duration(milliseconds: 900), _nextQuestion); setState(() {
_isAnswering = true;
});
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});
} }
@override @override
@@ -309,7 +387,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)), Text(
'WaniKani API key is not set.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
@@ -326,20 +409,21 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
); );
} }
String prompt = ''; if (_loading) {
String subtitle = ''; return Scaffold(
appBar: AppBar(
switch (_mode) { title: const Text('Kanji Quiz'),
case QuizMode.kanjiToEnglish: bottom: TabBar(
prompt = _current?.characters ?? ''; controller: _tabController,
break; tabs: const [
case QuizMode.englishToKanji: Tab(text: 'Kanji→English'),
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : ''; Tab(text: 'English→Kanji'),
break; Tab(text: 'Reading'),
case QuizMode.reading: ],
prompt = _current?.characters ?? ''; ),
subtitle = _readingHint; ),
break; body: const Center(child: CircularProgressIndicator()),
);
} }
return Scaffold( return Scaffold(
@@ -354,63 +438,122 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
], ],
), ),
), ),
backgroundColor: const Color(0xFF121212), backgroundColor: Theme.of(context).colorScheme.surface,
body: Padding( body: TabBarView(
padding: const EdgeInsets.all(16.0), controller: _tabController,
child: Column( children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
children: [ ),
Row( );
}
Widget _buildQuizPage(int index) {
final quizState = _quizStates[index];
final mode = _modeForIndex(index);
if (quizState.current == null) {
return Center(
child: Text(
_status,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
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;
default:
break;
}
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
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: Theme.of(context).colorScheme.surface,
textColor: Theme.of(context).colorScheme.onSurface,
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [ children: [
Expanded( OptionsGrid(
child: Text( options: quizState.options,
_status, onSelected: _isAnswering ? (option) {} : _answer,
style: const TextStyle(color: Colors.white), showResult: quizState.showResult,
selectedOption: quizState.selectedOption,
correctAnswers: quizState.correctAnswers,
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
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),
),
],
),
),
],
),
), ),
); );
} }

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hirameki_srs/src/models/theme_model.dart';
import 'package:hirameki_srs/src/themes.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
@@ -13,8 +15,9 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _apiKeyController = TextEditingController(); final TextEditingController _apiKeyController = TextEditingController();
bool _playAudio = true; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _playNarrator = true;
@override @override
void dispose() { void dispose() {
@@ -37,8 +40,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playAudio = prefs.getBool('playAudio') ?? true; _playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
}); });
} }
@@ -50,24 +54,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
await repo.setApiKey(apiKey); await repo.setApiKey(apiKey);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('API key saved!')), context,
); ).showSnackBar(const SnackBar(content: Text('API key saved!')));
Navigator.of(context).pushReplacement( Navigator.of(
MaterialPageRoute(builder: (_) => const HomeScreen()), context,
); ).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeModel = Provider.of<ThemeModel>(context);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF121212), backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar( appBar: AppBar(
title: const Text('Settings'), title: const Text('Settings'),
backgroundColor: const Color(0xFF1F1F1F), backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
foregroundColor: Colors.white, foregroundColor: Theme.of(context).colorScheme.onSurface,
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -76,15 +82,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
TextField( TextField(
controller: _apiKeyController, controller: _apiKeyController,
obscureText: true, obscureText: true,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'WaniKani API Key', labelText: 'WaniKani API Key',
labelStyle: const TextStyle(color: Colors.grey), labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
filled: true, filled: true,
fillColor: const Color(0xFF1E1E1E), fillColor: Theme.of(context).colorScheme.surfaceContainer,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Colors.grey), borderSide: BorderSide(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -92,37 +102,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
ElevatedButton( ElevatedButton(
onPressed: _saveApiKey, onPressed: _saveApiKey,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent, backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white, foregroundColor: Theme.of(context).colorScheme.onPrimary,
), ),
child: const Text('Save & Start Quiz'), child: const Text('Save & Start Quiz'),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
SwitchListTile( SwitchListTile(
title: const Text( title: Text(
'Play audio for vocabulary', 'Play incorrect sound',
style: TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
value: _playAudio, value: _playIncorrectSound,
onChanged: (value) async { onChanged: (value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
prefs.setBool('playAudio', value); prefs.setBool('playIncorrectSound', value);
setState(() { setState(() {
_playAudio = value; _playIncorrectSound = value;
}); });
}, },
activeThumbColor: Colors.blueAccent, activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Colors.grey, inactiveThumbColor: Theme.of(
tileColor: const Color(0xFF1E1E1E), context,
).colorScheme.onSurfaceVariant,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
title: const Text( title: Text(
'Play sound on correct answer', 'Play correct sound',
style: TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
value: _playCorrectSound, value: _playCorrectSound,
onChanged: (value) async { onChanged: (value) async {
@@ -132,9 +148,75 @@ class _SettingsScreenState extends State<SettingsScreen> {
_playCorrectSound = value; _playCorrectSound = value;
}); });
}, },
activeThumbColor: Colors.blueAccent, activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Colors.grey, inactiveThumbColor: Theme.of(
tileColor: const Color(0xFF1E1E1E), context,
).colorScheme.onSurfaceVariant,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 12),
SwitchListTile(
title: Text(
'Play narrator (TTS)',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
value: _playNarrator,
onChanged: (value) async {
final prefs = await SharedPreferences.getInstance();
prefs.setBool('playNarrator', value);
setState(() {
_playNarrator = value;
});
},
activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Theme.of(
context,
).colorScheme.onSurfaceVariant,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(height: 12),
ListTile(
title: Text(
'Theme',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
trailing: DropdownButton<ThemeData>(
value: themeModel.currentTheme,
dropdownColor: Theme.of(context).colorScheme.surfaceContainer,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
items: [
DropdownMenuItem(
value: Themes.dark,
child: const Text('Dark'),
),
DropdownMenuItem(
value: Themes.light,
child: const Text('Light'),
),
DropdownMenuItem(
value: Themes.nier,
child: const Text('Nier'),
),
],
onChanged: (theme) {
if (theme != null) {
themeModel.setTheme(theme);
}
},
),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),

View File

@@ -17,9 +17,9 @@ class StartScreen extends StatelessWidget {
IconButton( IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.settings),
onPressed: () { onPressed: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const SettingsScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
}, },
), ),
], ],
@@ -28,8 +28,8 @@ class StartScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
const Color(0xFF121212), Theme.of(context).colorScheme.surface,
Colors.grey[900]!, Theme.of(context).colorScheme.surface,
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
@@ -48,9 +48,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.extension, icon: Icons.extension,
description: 'Test your knowledge of kanji characters.', description: 'Test your knowledge of kanji characters.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const HomeScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const HomeScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -59,9 +59,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.school, icon: Icons.school,
description: 'Practice vocabulary from your WaniKani deck.', description: 'Practice vocabulary from your WaniKani deck.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const VocabScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const VocabScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -70,9 +70,9 @@ class StartScreen extends StatelessWidget {
icon: Icons.grid_view, icon: Icons.grid_view,
description: 'Look through your kanji and vocabulary decks.', description: 'Look through your kanji and vocabulary decks.',
onTap: () { onTap: () {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const BrowseScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => const BrowseScreen()));
}, },
), ),
_buildModeCard( _buildModeCard(
@@ -92,7 +92,8 @@ class StartScreen extends StatelessWidget {
); );
} }
Widget _buildModeCard(BuildContext context, { Widget _buildModeCard(
BuildContext context, {
required String title, required String title,
required IconData icon, required IconData icon,
required String description, required String description,
@@ -109,18 +110,26 @@ class StartScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary), Icon(
icon,
size: 48,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: Text( child: Text(
description, description,
style: const TextStyle(fontSize: 12, color: Colors.grey), style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
softWrap: true, softWrap: true,
), ),

View File

@@ -3,14 +3,27 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/kanji_item.dart'; import '../models/vocabulary_item.dart';
import '../services/deck_repository.dart'; import '../models/srs_item.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/distractor_generator.dart'; import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
class _QuizState {
VocabularyItem? current;
List<String> options = [];
List<String> correctAnswers = [];
int score = 0;
int asked = 0;
Key key = UniqueKey();
String? selectedOption;
bool showResult = false;
Set<int> wrongItems = {};
}
class VocabScreen extends StatefulWidget { class VocabScreen extends StatefulWidget {
const VocabScreen({super.key}); const VocabScreen({super.key});
@@ -18,21 +31,25 @@ class VocabScreen extends StatefulWidget {
State<VocabScreen> createState() => _VocabScreenState(); State<VocabScreen> createState() => _VocabScreenState();
} }
class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStateMixin { class _VocabScreenState extends State<VocabScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<VocabularyItem> _deck = []; List<VocabularyItem> _deck = [];
bool _loading = false; bool _loading = false;
bool _isAnswering = false;
String _status = 'Loading deck...'; String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator(); final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
final _audioPlayer = AudioPlayer(); final _audioPlayer = AudioPlayer();
VocabularyItem? _current; final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
List<String> _options = []; _QuizState get _currentQuizState => _quizStates[_tabController.index];
List<String> _correctAnswers = []; final _sessionDecks = <int, List<VocabularyItem>>{};
int _score = 0; final _sessionDeckSizes = <int, int>{};
int _asked = 0;
bool _playAudio = true; bool _playIncorrectSound = true;
bool _playCorrectSound = true; bool _playCorrectSound = true;
bool _playNarrator = true;
bool _apiKeyMissing = false; bool _apiKeyMissing = false;
@override @override
@@ -40,8 +57,10 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
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(() {});
_nextQuestion();
}); });
_loadSettings(); _loadSettings();
_loadDeck(); _loadDeck();
@@ -56,8 +75,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_playAudio = prefs.getBool('playAudio') ?? true; _playIncorrectSound = prefs.getBool('playIncorrectSound') ?? true;
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true; _playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
_playNarrator = prefs.getBool('playNarrator') ?? true;
}); });
} }
@@ -68,7 +88,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
}); });
try { try {
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<VocabDeckRepository>(context, listen: false);
await repo.loadApiKey(); await repo.loadApiKey();
final apiKey = repo.apiKey; final apiKey = repo.apiKey;
@@ -96,7 +116,46 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
_apiKeyMissing = false; _apiKeyMissing = false;
}); });
_nextQuestion(); final disabledLevels = <int>{};
final itemsByLevel = <int, List<VocabularyItem>>{};
for (final item in _deck) {
(itemsByLevel[item.level] ??= []).add(item);
}
itemsByLevel.forEach((level, items) {
final allSrsItems = items
.expand((item) => item.srsItems.values)
.toList();
if (allSrsItems.isNotEmpty &&
allSrsItems.every((srs) => srs.disabled)) {
disabledLevels.add(level);
}
});
for (var i = 0; i < _tabController.length; i++) {
final mode = _modeForIndex(i);
var filteredDeck = _deck.where((item) {
if (disabledLevels.contains(item.level)) {
return false;
}
final srsItem = item.srsItems[mode.toString()];
return srsItem == null || !srsItem.disabled;
}).toList();
if (mode == QuizMode.audioToEnglish) {
filteredDeck = filteredDeck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
}
filteredDeck.shuffle(_random);
_sessionDecks[i] = filteredDeck;
_sessionDeckSizes[i] = filteredDeck.length;
}
for (var i = 0; i < _tabController.length; i++) {
_nextQuestion(i);
}
} catch (e) { } catch (e) {
setState(() { setState(() {
_status = 'Error: $e'; _status = 'Error: $e';
@@ -113,117 +172,128 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
.join(' '); .join(' ');
} }
VocabQuizMode get _mode { QuizMode _modeForIndex(int index) {
switch (_tabController.index) { switch (index) {
case 0: case 0:
return VocabQuizMode.vocabToEnglish; return QuizMode.vocabToEnglish;
case 1: case 1:
return VocabQuizMode.englishToVocab; return QuizMode.englishToVocab;
case 2: case 2:
return VocabQuizMode.audioToEnglish; return QuizMode.audioToEnglish;
default: default:
return VocabQuizMode.vocabToEnglish; return QuizMode.vocabToEnglish;
} }
} }
void _nextQuestion() { void _nextQuestion([int? index]) {
if (_deck.isEmpty) return; final tabIndex = index ?? _tabController.index;
final quizState = _quizStates[tabIndex];
final sessionDeck = _sessionDecks[tabIndex];
final mode = _modeForIndex(tabIndex);
List<VocabularyItem> deck = _deck; if (sessionDeck == null || sessionDeck.isEmpty) {
if (_mode == VocabQuizMode.audioToEnglish) { setState(() {
deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList(); quizState.current = null;
if (deck.isEmpty) { _status = 'Quiz complete!';
setState(() { });
_status = 'No vocabulary with audio found.'; return;
_current = null;
});
return;
}
} }
deck.sort((a, b) { quizState.current = sessionDeck.removeAt(0);
final aSrsItem = a.srsItems[_mode.toString()] ?? quizState.key = UniqueKey();
VocabSrsItem(vocabId: a.id, quizMode: _mode); quizState.correctAnswers = [];
final bSrsItem = b.srsItems[_mode.toString()] ?? quizState.options = [];
VocabSrsItem(vocabId: b.id, quizMode: _mode); quizState.selectedOption = null;
quizState.showResult = false;
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage); switch (mode) {
if (stageComparison != 0) { case QuizMode.vocabToEnglish:
return stageComparison; case QuizMode.audioToEnglish:
} quizState.correctAnswers = [quizState.current!.meanings.first];
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked); quizState.options = [
}); quizState.correctAnswers.first,
..._dg.generateVocabMeanings(quizState.current!, _deck, 3),
_current = deck.first; ].map(_toTitleCase).toList()..shuffle();
if (_mode == VocabQuizMode.audioToEnglish) {
_playCurrentAudio();
}
_correctAnswers = [];
_options = [];
switch (_mode) {
case VocabQuizMode.vocabToEnglish:
case VocabQuizMode.audioToEnglish:
_correctAnswers = [_current!.meanings.first];
_options = [
_correctAnswers.first,
..._dg.generateVocabMeanings(_current!, _deck, 3)
].map(_toTitleCase).toList()
..shuffle();
break; break;
case VocabQuizMode.englishToVocab: case QuizMode.englishToVocab:
_correctAnswers = [_current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
_options = [ quizState.options = [
_correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateVocab(_current!, _deck, 3) ..._dg.generateVocab(quizState.current!, _deck, 3),
]..shuffle(); ]..shuffle();
break; break;
default:
break;
} }
setState(() {}); setState(() {
_isAnswering = false;
});
if (mode == QuizMode.audioToEnglish) {
_playCurrentAudio(playOnLoad: true);
}
} }
Future<void> _playCurrentAudio() async { Future<void> _playCurrentAudio({bool playOnLoad = false}) 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'); if (playOnLoad && !_playNarrator) return;
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 { try {
await _audioPlayer.play(UrlSource(audioUrl)); await _audioPlayer.play(UrlSource(audioUrl));
} catch (e) { } finally {}
// Ignore player errors
}
} }
void _answer(String option) async { 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()) .map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim()); .contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false); final repo = Provider.of<VocabDeckRepository>(context, listen: false);
final current = _current!; final current = quizState.current!;
final tabIndex = _tabController.index;
final sessionDeck = _sessionDecks[tabIndex]!;
final srsKey = _mode.toString(); final srsKey = mode.toString();
var srsItemNullable = current.srsItems[srsKey]; var srsItemNullable = current.srsItems[srsKey];
final isNew = srsItemNullable == null; final isNew = srsItemNullable == null;
final srsItem = final srsItem =
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode); srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
setState(() { quizState.selectedOption = option;
_asked += 1; quizState.showResult = true;
if (isCorrect) { setState(() {});
_score += 1;
srsItem.srsStage += 1; if (isCorrect) {
} else { quizState.asked += 1;
srsItem.srsStage = max(0, srsItem.srsStage - 1); if (!quizState.wrongItems.contains(current.id)) {
quizState.score += 1;
} }
srsItem.lastAsked = DateTime.now(); srsItem.srsStage += 1;
current.srsItems[srsKey] = srsItem; } else {
}); srsItem.srsStage = max(0, srsItem.srsStage - 1);
sessionDeck.add(current);
sessionDeck.shuffle(_random);
quizState.wrongItems.add(current.id);
if (_playIncorrectSound) {
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
}
}
srsItem.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItem;
if (isNew) { if (isNew) {
await repo.insertVocabSrsItem(srsItem); await repo.insertVocabSrsItem(srsItem);
@@ -231,32 +301,33 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
await repo.updateVocabSrsItem(srsItem); await repo.updateVocabSrsItem(srsItem);
} }
final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish) final correctDisplay = (mode == QuizMode.vocabToEnglish)
? _toTitleCase(_correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: _correctAnswers.first; : quizState.correctAnswers.first;
if (!mounted) return;
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent, color: isCorrect
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
backgroundColor: const Color(0xFF222222), backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(snack);
ScaffoldMessenger.of(context).showSnackBar(snack);
}
if (isCorrect) { if (isCorrect) {
if (_playCorrectSound) { if (_playCorrectSound && !_playNarrator) {
await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); await _audioPlayer.play(AssetSource('sfx/correct.wav'));
} } else if (_playNarrator) {
if (_playAudio && _mode != VocabQuizMode.audioToEnglish) { final maleAudios = current.pronunciationAudios.where(
final maleAudios = (a) => a.gender == 'male',
current.pronunciationAudios.where((a) => a.gender == 'male'); );
if (maleAudios.isNotEmpty) { if (maleAudios.isNotEmpty) {
final completer = Completer<void>(); final completer = Completer<void>();
final sub = _audioPlayer.onPlayerComplete.listen((event) { final sub = _audioPlayer.onPlayerComplete.listen((event) {
@@ -266,18 +337,22 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
try { try {
await _audioPlayer.play(UrlSource(maleAudios.first.url)); await _audioPlayer.play(UrlSource(maleAudios.first.url));
await completer.future.timeout(const Duration(seconds: 5)); await completer.future.timeout(const Duration(seconds: 5));
} catch (e) {
// Ignore player errors
} finally { } finally {
await sub.cancel(); await sub.cancel();
} }
} }
} }
} else {
await Future.delayed(const Duration(milliseconds: 900));
} }
_nextQuestion(); setState(() {
_isAnswering = true;
});
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) {
_nextQuestion();
}
});
} }
@override @override
@@ -289,13 +364,19 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)), Text(
'WaniKani API key is not set.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
); );
if (!mounted) return;
_loadDeck(); _loadDeck();
}, },
child: const Text('Go to Settings'), child: const Text('Go to Settings'),
@@ -306,29 +387,21 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
); );
} }
Widget promptWidget; if (_loading) {
return Scaffold(
if (_current == null) { appBar: AppBar(
promptWidget = const SizedBox.shrink(); title: const Text('Vocabulary Quiz'),
} else if (_mode == VocabQuizMode.audioToEnglish) { bottom: TabBar(
promptWidget = IconButton( controller: _tabController,
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64), tabs: const [
onPressed: _playCurrentAudio, Tab(text: 'Vocab→English'),
Tab(text: 'English→Vocab'),
Tab(text: 'Listening'),
],
),
),
body: const Center(child: CircularProgressIndicator()),
); );
} 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( return Scaffold(
@@ -343,63 +416,132 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
], ],
), ),
), ),
backgroundColor: const Color(0xFF121212), body: TabBarView(
body: Padding( controller: _tabController,
padding: const EdgeInsets.all(16.0), children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
child: Column( ),
children: [ );
Row( }
Widget _buildQuizPage(int index) {
final quizState = _quizStates[index];
final mode = _modeForIndex(index);
if (quizState.current == null) {
return Center(
child: Text(
_status,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
Widget promptWidget;
if (quizState.current == null) {
promptWidget = const SizedBox.shrink();
} else if (mode == QuizMode.audioToEnglish) {
promptWidget = IconButton(
icon: Icon(
Icons.volume_up,
color: Theme.of(context).colorScheme.onSurface,
size: 64,
),
onPressed: _playCurrentAudio,
);
} else {
String promptText = '';
switch (mode) {
case QuizMode.vocabToEnglish:
promptText = quizState.current!.characters;
break;
case QuizMode.englishToVocab:
promptText = _toTitleCase(quizState.current!.meanings.first);
break;
case QuizMode.audioToEnglish:
break;
default:
break;
}
promptWidget = Text(
promptText,
style: TextStyle(
fontSize: 48,
color: Theme.of(context).colorScheme.onSurface,
),
);
}
return Padding(
key: quizState.key,
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: (_sessionDeckSizes[index] ?? 0) > 0
? quizState.asked / (_sessionDeckSizes[index] ?? 1)
: 0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
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: [ children: [
Expanded( OptionsGrid(
child: Text( options: quizState.options,
_status, onSelected: _isAnswering ? (option) {} : _answer,
style: const TextStyle(color: Colors.white), showResult: quizState.showResult,
selectedOption: quizState.selectedOption,
correctAnswers: quizState.correctAnswers,
),
const SizedBox(height: 8),
Text(
'Score: ${quizState.score} / ${quizState.asked}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
), ),
), ),
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),
),
],
),
),
],
),
), ),
); );
} }

View File

@@ -1,4 +1,3 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
@@ -23,18 +22,15 @@ class CustomDeckRepository {
} }
Future<void> updateCard(CustomKanjiItem item) async { Future<void> updateCard(CustomKanjiItem item) async {
final deck = await getCustomDeck(); await updateCards([item]);
final index = deck.indexWhere((element) => element.characters == item.characters);
if (index != -1) {
deck[index] = item;
await saveDeck(deck);
}
} }
Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async { Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async {
final deck = await getCustomDeck(); final deck = await getCustomDeck();
for (var item in itemsToUpdate) { for (var item in itemsToUpdate) {
final index = deck.indexWhere((element) => element.characters == item.characters); final index = deck.indexWhere(
(element) => element.characters == item.characters,
);
if (index != -1) { if (index != -1) {
deck[index] = item; deck[index] = item;
} }

View File

@@ -0,0 +1,27 @@
class DbConstants {
static const String settingsTable = 'settings';
static const String kanjiTable = 'kanji';
static const String srsItemsTable = 'srs_items';
static const String vocabularyTable = 'vocabulary';
static const String srsVocabItemsTable = 'srs_vocab_items';
static const String keyColumn = 'key';
static const String valueColumn = 'value';
static const String idColumn = 'id';
static const String levelColumn = 'level';
static const String charactersColumn = 'characters';
static const String meaningsColumn = 'meanings';
static const String onyomiColumn = 'onyomi';
static const String kunyomiColumn = 'kunyomi';
static const String readingsColumn = 'readings';
static const String pronunciationAudiosColumn = 'pronunciation_audios';
static const String kanjiIdColumn = 'kanjiId';
static const String vocabIdColumn = 'vocabId';
static const String quizModeColumn = 'quizMode';
static const String readingTypeColumn = 'readingType';
static const String srsStageColumn = 'srsStage';
static const String lastAskedColumn = 'lastAsked';
static const String disabledColumn = 'disabled';
}

View File

@@ -0,0 +1,65 @@
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'database_constants.dart';
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _db;
factory DatabaseHelper() {
return _instance;
}
DatabaseHelper._internal();
Future<Database> get db async {
if (_db != null) return _db!;
_db = await _openDb();
return _db!;
}
Future<void> close() async {
if (_db != null) {
await _db!.close();
_db = null;
}
}
Future<Database> _openDb() async {
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'wanikani_srs.db');
return openDatabase(
path,
version: 8,
onCreate: (db, version) async {
await db.execute(
'''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''',
);
await db.execute(
'''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''',
);
await db.execute(
'''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''',
);
await db.execute(
'''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''',
);
await db.execute(
'''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''',
);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 8) {
await db.execute(
'ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
);
await db.execute(
'ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
);
}
},
);
}
}

View File

@@ -1,15 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/srs_item.dart';
import '../api/wk_client.dart'; import '../api/wk_client.dart';
import 'database_constants.dart';
import 'database_helper.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
class DeckRepository { class DeckRepository {
Database? _db;
String? _apiKey; String? _apiKey;
Future<void> setApiKey(String apiKey) async { Future<void> setApiKey(String apiKey) async {
@@ -19,140 +18,73 @@ class DeckRepository {
String? get apiKey => _apiKey; String? get apiKey => _apiKey;
Future<Database> _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<void> saveApiKey(String apiKey) async { Future<void> saveApiKey(String apiKey) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('settings', { await db.insert(DbConstants.settingsTable, {
'key': 'apiKey', DbConstants.keyColumn: 'apiKey',
'value': apiKey, DbConstants.valueColumn: apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
Future<String?> loadApiKey() async { Future<String?> loadApiKey() async {
final envApiKey = dotenv.env['WANIKANI_API_KEY']; final db = await DatabaseHelper().db;
if (envApiKey != null && envApiKey.isNotEmpty) { final rows = await db.query(
_apiKey = envApiKey; DbConstants.settingsTable,
where: '${DbConstants.keyColumn} = ?',
whereArgs: ['apiKey'],
);
if (rows.isNotEmpty) {
_apiKey = rows.first[DbConstants.valueColumn] as String;
return _apiKey; return _apiKey;
} }
final db = await _openDb(); try {
final rows = await db.query( final envApiKey = dotenv.env['WANIKANI_API_KEY'];
'settings', if (envApiKey != null && envApiKey.isNotEmpty) {
where: 'key = ?', await saveApiKey(envApiKey);
whereArgs: ['apiKey'], _apiKey = envApiKey;
); return _apiKey;
if (rows.isNotEmpty) { }
_apiKey = rows.first['value'] as String; } catch (_) {}
return _apiKey;
}
return null; return null;
} }
Future<void> saveKanji(List<KanjiItem> items) async { Future<void> saveKanji(List<KanjiItem> items) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final batch = db.batch(); final batch = db.batch();
for (final it in items) { for (final it in items) {
batch.insert('kanji', { batch.insert(DbConstants.kanjiTable, {
'id': it.id, DbConstants.idColumn: it.id,
'level': it.level, DbConstants.levelColumn: it.level,
'characters': it.characters, DbConstants.charactersColumn: it.characters,
'meanings': it.meanings.join('|'), DbConstants.meaningsColumn: it.meanings.join('|'),
'onyomi': it.onyomi.join('|'), DbConstants.onyomiColumn: it.onyomi.join('|'),
'kunyomi': it.kunyomi.join('|'), DbConstants.kunyomiColumn: it.kunyomi.join('|'),
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
Future<List<KanjiItem>> loadKanji() async { Future<List<KanjiItem>> loadKanji() async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query('kanji'); final rows = await db.query(DbConstants.kanjiTable);
final kanjiItems = rows final kanjiItems = rows
.map( .map(
(r) => KanjiItem( (r) => KanjiItem(
id: r['id'] as int, id: r[DbConstants.idColumn] as int,
level: r['level'] as int? ?? 0, level: r[DbConstants.levelColumn] as int? ?? 0,
characters: r['characters'] as String, characters: r[DbConstants.charactersColumn] as String,
meanings: (r['meanings'] as String) meanings: (r[DbConstants.meaningsColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
onyomi: (r['onyomi'] as String) onyomi: (r[DbConstants.onyomiColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
kunyomi: (r['kunyomi'] as String) kunyomi: (r[DbConstants.kunyomiColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
@@ -160,8 +92,24 @@ class DeckRepository {
) )
.toList(); .toList();
final srsRows = await db.query(DbConstants.srsItemsTable);
final srsItemsByKanjiId = <int, List<SrsItem>>{};
for (final r in srsRows) {
final srsItem = SrsItem(
subjectId: r[DbConstants.kanjiIdColumn] as int,
quizMode: QuizMode.values.firstWhere(
(e) => e.toString() == r[DbConstants.quizModeColumn] as String,
),
readingType: r[DbConstants.readingTypeColumn] as String?,
srsStage: r[DbConstants.srsStageColumn] as int,
lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String),
disabled: (r[DbConstants.disabledColumn] as int? ?? 0) == 1,
);
srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem);
}
for (final item in kanjiItems) { for (final item in kanjiItems) {
final srsItems = await getSrsItems(item.id); final srsItems = srsItemsByKanjiId[item.id] ?? [];
for (final srsItem in srsItems) { for (final srsItem in srsItems) {
final key = srsItem.quizMode.toString() + (srsItem.readingType ?? ''); final key = srsItem.quizMode.toString() + (srsItem.readingType ?? '');
item.srsItems[key] = srsItem; item.srsItems[key] = srsItem;
@@ -171,47 +119,67 @@ class DeckRepository {
return kanjiItems; return kanjiItems;
} }
Future<List<SrsItem>> getSrsItems(int kanjiId) async { Future<void> updateSrsItems(List<SrsItem> items) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query( final batch = db.batch();
'srs_items', for (final item in items) {
where: 'kanjiId = ?', var where =
whereArgs: [kanjiId], '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?';
); final whereArgs = [item.subjectId, item.quizMode.toString()];
return rows.map((r) { if (item.readingType != null) {
return SrsItem( where += ' AND ${DbConstants.readingTypeColumn} = ?';
kanjiId: r['kanjiId'] as int, whereArgs.add(item.readingType!);
quizMode: QuizMode.values.firstWhere( } else {
(e) => e.toString() == r['quizMode'] as String, where += ' AND ${DbConstants.readingTypeColumn} IS NULL';
), }
readingType: r['readingType'] as String?,
srsStage: r['srsStage'] as int, batch.update(
lastAsked: DateTime.parse(r['lastAsked'] as String), DbConstants.srsItemsTable,
{
DbConstants.srsStageColumn: item.srsStage,
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
},
where: where,
whereArgs: whereArgs,
); );
}).toList(); }
await batch.commit(noResult: true);
} }
Future<void> updateSrsItem(SrsItem item) async { Future<void> updateSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
var where =
'${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ?';
final whereArgs = [item.subjectId, item.quizMode.toString()];
if (item.readingType != null) {
where += ' AND ${DbConstants.readingTypeColumn} = ?';
whereArgs.add(item.readingType!);
} else {
where += ' AND ${DbConstants.readingTypeColumn} IS NULL';
}
await db.update( await db.update(
'srs_items', DbConstants.srsItemsTable,
{ {
'srsStage': item.srsStage, DbConstants.srsStageColumn: item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
}, },
where: 'kanjiId = ? AND quizMode = ? AND readingType = ?', where: where,
whereArgs: [item.kanjiId, item.quizMode.toString(), item.readingType], whereArgs: whereArgs,
); );
} }
Future<void> insertSrsItem(SrsItem item) async { Future<void> insertSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('srs_items', { await db.insert(DbConstants.srsItemsTable, {
'kanjiId': item.kanjiId, DbConstants.kanjiIdColumn: item.subjectId,
'quizMode': item.quizMode.toString(), DbConstants.quizModeColumn: item.quizMode.toString(),
'readingType': item.readingType, DbConstants.readingTypeColumn: item.readingType,
'srsStage': item.srsStage, DbConstants.srsStageColumn: item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
DbConstants.disabledColumn: item.disabled ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
@@ -254,155 +222,4 @@ class DeckRepository {
await saveKanji(items); await saveKanji(items);
return items; return items;
} }
Future<List<VocabSrsItem>> 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<void> 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<void> 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<void> saveVocabulary(List<VocabularyItem> 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<List<VocabularyItem>> 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<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,
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<List<VocabularyItem>> 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 = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
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;
}
} }

View File

@@ -1,17 +1,30 @@
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/vocabulary_item.dart';
import 'dart:math'; import 'dart:math';
class DistractorGenerator { class DistractorGenerator {
final Random _rnd = Random(); final Random _rnd = Random();
List<String> generateMeanings(KanjiItem correct, List<KanjiItem> pool, int needed) { List<String> generateMeanings(
KanjiItem correct,
List<KanjiItem> pool,
int needed,
) {
final correctMeaning = correct.meanings.first; final correctMeaning = correct.meanings.first;
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final tokens = correctMeaning
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
final candidates = <String>[]; final candidates = <String>[];
for (final k in pool) { for (final k in pool) {
if (k.id == correct.id) continue; if (k.id == correct.id) continue;
for (final m in k.meanings) { for (final m in k.meanings) {
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) { if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m); candidates.add(m);
} }
@@ -38,8 +51,15 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateKanji(KanjiItem correct, List<KanjiItem> pool, int needed) { List<String> generateKanji(
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList(); KanjiItem correct,
List<KanjiItem> pool,
int needed,
) {
final others = pool
.map((k) => k.characters)
.where((c) => c != correct.characters)
.toList();
others.shuffle(_rnd); others.shuffle(_rnd);
final out = <String>[]; final out = <String>[];
for (final o in others) { for (final o in others) {
@@ -52,7 +72,11 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateReadings(String correct, List<KanjiItem> pool, int needed) { List<String> generateReadings(
String correct,
List<KanjiItem> pool,
int needed,
) {
final poolReadings = <String>[]; final poolReadings = <String>[];
for (final k in pool) { for (final k in pool) {
poolReadings.addAll(k.onyomi); poolReadings.addAll(k.onyomi);
@@ -71,14 +95,26 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateVocabMeanings(VocabularyItem correct, List<VocabularyItem> pool, int needed) { List<String> generateVocabMeanings(
VocabularyItem correct,
List<VocabularyItem> pool,
int needed,
) {
final correctMeaning = correct.meanings.first; final correctMeaning = correct.meanings.first;
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final tokens = correctMeaning
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
final candidates = <String>[]; final candidates = <String>[];
for (final k in pool) { for (final k in pool) {
if (k.id == correct.id) continue; if (k.id == correct.id) continue;
for (final m in k.meanings) { for (final m in k.meanings) {
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet(); final mTokens = m
.split(RegExp(r'\s+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toSet();
if (mTokens.intersection(tokens).isNotEmpty) { if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m); candidates.add(m);
} }
@@ -105,8 +141,15 @@ class DistractorGenerator {
return out; return out;
} }
List<String> generateVocab(VocabularyItem correct, List<VocabularyItem> pool, int needed) { List<String> generateVocab(
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList(); VocabularyItem correct,
List<VocabularyItem> pool,
int needed,
) {
final others = pool
.map((k) => k.characters)
.where((c) => c != correct.characters)
.toList();
others.shuffle(_rnd); others.shuffle(_rnd);
final out = <String>[]; final out = <String>[];
for (final o in others) { for (final o in others) {
@@ -120,4 +163,7 @@ class DistractorGenerator {
} }
} }
String _toTitleCase(String s) => s.split(' ').map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1))).join(' '); String _toTitleCase(String s) => s
.split(' ')
.map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1)))
.join(' ');

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

View File

@@ -0,0 +1,224 @@
import 'dart:async';
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import '../models/vocabulary_item.dart';
import '../models/srs_item.dart';
import '../api/wk_client.dart';
import 'database_helper.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
class VocabDeckRepository {
String? _apiKey;
Future<void> setApiKey(String apiKey) async {
_apiKey = apiKey;
await saveApiKey(apiKey);
}
String? get apiKey => _apiKey;
Future<void> saveApiKey(String apiKey) async {
final db = await DatabaseHelper().db;
await db.insert('settings', {
'key': 'apiKey',
'value': apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<String?> loadApiKey() async {
String? envApiKey;
try {
envApiKey = dotenv.env['WANIKANI_API_KEY'];
} catch (e) {
envApiKey = null;
}
if (envApiKey != null && envApiKey.isNotEmpty) {
_apiKey = envApiKey;
return _apiKey;
}
final db = await DatabaseHelper().db;
final rows = await db.query(
'settings',
where: 'key = ?',
whereArgs: ['apiKey'],
);
if (rows.isNotEmpty) {
_apiKey = rows.first['value'] as String;
return _apiKey;
}
return null;
}
Future<List<SrsItem>> getVocabSrsItems(int vocabId) async {
final db = await DatabaseHelper().db;
final rows = await db.query(
'srs_vocab_items',
where: 'vocabId = ?',
whereArgs: [vocabId],
);
return rows.map((r) {
return SrsItem(
subjectId: r['vocabId'] as int,
quizMode: QuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String,
),
srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String),
disabled: (r['disabled'] as int? ?? 0) == 1,
);
}).toList();
}
Future<void> updateSrsItems(List<SrsItem> items) async {
final db = await DatabaseHelper().db;
final batch = db.batch();
for (final item in items) {
batch.update(
'srs_vocab_items',
{
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
},
where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.subjectId, item.quizMode.toString()],
);
}
await batch.commit(noResult: true);
}
Future<void> updateVocabSrsItem(SrsItem item) async {
final db = await DatabaseHelper().db;
await db.update(
'srs_vocab_items',
{
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
},
where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.subjectId, item.quizMode.toString()],
);
}
Future<void> insertVocabSrsItem(SrsItem item) async {
final db = await DatabaseHelper().db;
await db.insert('srs_vocab_items', {
'vocabId': item.subjectId,
'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
'disabled': item.disabled ? 1 : 0,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveVocabulary(List<VocabularyItem> items) async {
final db = await DatabaseHelper().db;
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<List<VocabularyItem>> loadVocabulary() async {
final db = await DatabaseHelper().db;
final rows = await db.query('vocabulary');
final vocabItems = rows.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,
),
);
}
} finally {}
}
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<List<VocabularyItem>> 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 = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
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;
}
}

131
lib/src/themes.dart Normal file
View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
class SrsColors {
final Color level1;
final Color level2;
final Color level3;
final Color level4;
final Color level5;
final Color level6;
final Color level7;
final Color level8;
final Color level9;
const SrsColors({
required this.level1,
required this.level2,
required this.level3,
required this.level4,
required this.level5,
required this.level6,
required this.level7,
required this.level8,
required this.level9,
});
}
extension CustomTheme on ThemeData {
SrsColors get srsColors {
if (brightness == Brightness.dark) {
return const SrsColors(
level1: Color(0xFFE57373), // red
level2: Color(0xFFFFB74D), // orange
level3: Color(0xFFFFD54F), // yellow
level4: Color(0xFFDCE775), // lime
level5: Color(0xFFAED581), // light green
level6: Color(0xFF81C784), // green
level7: Color(0xFF4DB6AC), // teal
level8: Color(0xFF4FC3F7), // light blue
level9: Color(0xFF7986CB), // indigo
);
} else if (colorScheme.primary == const Color(0xFF7B6D53)) {
// Nier theme
return const SrsColors(
level1: Color(0xFFB71C1C), // dark red
level2: Color(0xFFD84315), // deep orange
level3: Color(0xFFF57F17), // yellow
level4: Color(0xFF9E9D24), // lime
level5: Color(0xFF558B2F), // light green
level6: Color(0xFF2E7D32), // green
level7: Color(0xFF00695C), // teal
level8: Color(0xFF0277BD), // light blue
level9: Color(0xFF283593), // indigo
);
} else {
// Light theme
return const SrsColors(
level1: Colors.red,
level2: Colors.orange,
level3: Colors.yellow,
level4: Colors.lightGreen,
level5: Colors.green,
level6: Colors.teal,
level7: Colors.cyan,
level8: Colors.blue,
level9: Colors.purple,
);
}
}
}
class Themes {
static final dark = ThemeData(
colorScheme: const ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFF90CAF9),
onPrimary: Colors.black,
secondary: Color(0xFFBBDEFB),
onSecondary: Colors.black,
tertiary: Color(0xFFA5D6A7),
onTertiary: Colors.black,
error: Color(0xFFEF9A9A),
onError: Colors.black,
surface: Color(0xFF121212),
onSurface: Colors.white,
surfaceContainer: Color(0xFF1E1E1E),
surfaceContainerHighest: Color(0xFF424242),
onSurfaceVariant: Colors.white70,
),
useMaterial3: true,
);
static final light = ThemeData(
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF1976D2),
onPrimary: Colors.white,
secondary: Color(0xFF42A5F5),
onSecondary: Colors.white,
tertiary: Color(0xFF66BB6A),
onTertiary: Colors.white,
error: Color(0xFFE57373),
onError: Colors.white,
surface: Color(0xFFFFFFFF),
onSurface: Colors.black,
surfaceContainer: Color(0xFFF5F5F5),
surfaceContainerHighest: Color(0xFFE0E0E0),
onSurfaceVariant: Colors.black54,
),
useMaterial3: true,
);
static final nier = ThemeData(
colorScheme: const ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF7B6D53),
onPrimary: Colors.white,
secondary: Color(0xFFA99A7E),
onSecondary: Colors.white,
tertiary: Color(0xFFA99A7E),
onTertiary: Colors.white,
error: Color(0xFFD32F2F),
onError: Colors.white,
surface: Color(0xFFCFCBAA),
onSurface: Color(0xFF333333),
surfaceContainer: Color(0xFFBDB898),
surfaceContainerHighest: Color(0xFFA8A388),
onSurfaceVariant: Color(0xFF545454),
),
useMaterial3: true,
);
}

View File

@@ -19,13 +19,19 @@ class KanjiCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final bgColor = backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface; final bgColor =
final fgColor = textColor ?? theme.textTheme.bodyMedium?.color ?? theme.colorScheme.onSurface; backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface;
final fgColor =
textColor ??
theme.textTheme.bodyMedium?.color ??
theme.colorScheme.onSurface;
return Card( return Card(
elevation: theme.cardTheme.elevation ?? 12, elevation: theme.cardTheme.elevation ?? 12,
color: bgColor, color: bgColor,
shape: theme.cardTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape:
theme.cardTheme.shape ??
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox( child: SizedBox(
width: 360, width: 360,
height: 240, height: 240,

View File

@@ -5,6 +5,10 @@ class OptionsGrid extends StatelessWidget {
final void Function(String) onSelected; final void Function(String) onSelected;
final Color? buttonColor; final Color? buttonColor;
final Color? textColor; final Color? textColor;
final bool isDisabled;
final String? selectedOption;
final List<String>? correctAnswers;
final bool showResult;
const OptionsGrid({ const OptionsGrid({
super.key, super.key,
@@ -12,6 +16,10 @@ class OptionsGrid extends StatelessWidget {
required this.onSelected, required this.onSelected,
this.buttonColor, this.buttonColor,
this.textColor, this.textColor,
this.isDisabled = false,
this.selectedOption,
this.correctAnswers,
this.showResult = false,
}); });
@override @override
@@ -27,13 +35,28 @@ class OptionsGrid extends StatelessWidget {
runSpacing: 10, runSpacing: 10,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: options.map((o) { children: options.map((o) {
Color currentButtonColor = bg;
Color currentTextColor = fg;
if (showResult) {
final normalizedOption = o.trim().toLowerCase();
if (correctAnswers != null &&
correctAnswers!
.map((e) => e.trim().toLowerCase())
.contains(normalizedOption)) {
currentButtonColor = theme.colorScheme.tertiary;
}
}
return SizedBox( return SizedBox(
width: 160, width: 160,
child: ElevatedButton( child: ElevatedButton(
onPressed: () => onSelected(o), onPressed: isDisabled || o == '---' ? null : () => onSelected(o),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: bg, backgroundColor: currentButtonColor,
foregroundColor: fg, foregroundColor: currentTextColor,
disabledBackgroundColor:
theme.colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -41,7 +64,9 @@ class OptionsGrid extends StatelessWidget {
), ),
child: Text( child: Text(
o, o,
style: TextStyle(fontSize: 20, color: fg), style: theme.textTheme.titleMedium?.copyWith(
color: currentTextColor,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),

View File

@@ -34,5 +34,5 @@ flutter_icons:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/sfx/confirm.mp3 - assets/sfx/correct.wav
- .env - assets/sfx/incorrect.wav