Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f1b9ba12e | ||
|
|
16da0f04ac | ||
|
|
e9f115a32a | ||
|
|
d5ff5eb12f | ||
| 732408997d | |||
|
|
de3501c3e4 | ||
|
|
4eb488e28c | ||
|
|
ad61292263 | ||
| 4a6fce37b2 | |||
|
|
d8edfa1686 | ||
|
|
cafec12888 |
Binary file not shown.
BIN
assets/sfx/correct.wav
Normal file
BIN
assets/sfx/correct.wav
Normal file
Binary file not shown.
BIN
assets/sfx/incorrect.wav
Normal file
BIN
assets/sfx/incorrect.wav
Normal file
Binary file not shown.
@@ -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 Consumer<ThemeModel>(
|
||||||
|
builder: (context, themeModel, child) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Hirameki SRS',
|
title: 'Hirameki SRS',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData.dark(useMaterial3: true),
|
theme: themeModel.currentTheme,
|
||||||
home: const StartScreen(),
|
home: const StartScreen(),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
19
lib/src/models/srs_item.dart
Normal file
19
lib/src/models/srs_item.dart
Normal 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();
|
||||||
|
}
|
||||||
38
lib/src/models/subject.dart
Normal file
38
lib/src/models/subject.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
lib/src/models/subject_factory.dart
Normal file
15
lib/src/models/subject_factory.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
lib/src/models/theme_model.dart
Normal file
13
lib/src/models/theme_model.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/src/models/vocabulary_item.dart
Normal file
56
lib/src/models/vocabulary_item.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hirameki_srs/src/themes.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/kanji_item.dart';
|
import '../models/kanji_item.dart';
|
||||||
|
import '../models/vocabulary_item.dart';
|
||||||
|
import '../models/srs_item.dart';
|
||||||
import '../services/deck_repository.dart';
|
import '../services/deck_repository.dart';
|
||||||
|
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
|
||||||
import '../services/custom_deck_repository.dart';
|
import '../services/custom_deck_repository.dart';
|
||||||
import '../models/custom_kanji_item.dart';
|
import '../models/custom_kanji_item.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'custom_card_details_screen.dart';
|
import 'custom_card_details_screen.dart';
|
||||||
|
import 'add_card_screen.dart';
|
||||||
|
|
||||||
class BrowseScreen extends StatefulWidget {
|
class BrowseScreen extends StatefulWidget {
|
||||||
const BrowseScreen({super.key});
|
const BrowseScreen({super.key});
|
||||||
@@ -14,7 +21,8 @@ class BrowseScreen extends StatefulWidget {
|
|||||||
State<BrowseScreen> createState() => _BrowseScreenState();
|
State<BrowseScreen> createState() => _BrowseScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderStateMixin {
|
class _BrowseScreenState extends State<BrowseScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late PageController _kanjiPageController;
|
late PageController _kanjiPageController;
|
||||||
late PageController _vocabPageController;
|
late PageController _vocabPageController;
|
||||||
@@ -46,7 +54,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
_vocabPageController = PageController();
|
_vocabPageController = PageController();
|
||||||
|
|
||||||
_tabController.addListener(() {
|
_tabController.addListener(() {
|
||||||
setState(() {}); // Rebuild to update the level selector
|
setState(() {});
|
||||||
});
|
});
|
||||||
|
|
||||||
_kanjiPageController.addListener(() {
|
_kanjiPageController.addListener(() {
|
||||||
@@ -90,13 +98,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
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;
|
||||||
_loadDecks();
|
_loadDecks();
|
||||||
},
|
},
|
||||||
child: const Text('Go to Settings'),
|
child: const Text('Go to Settings'),
|
||||||
@@ -111,9 +123,14 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
CircularProgressIndicator(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status, style: const TextStyle(color: Colors.white)),
|
Text(
|
||||||
|
_status,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -124,8 +141,11 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
|
|
||||||
Widget _buildCustomSrsTab() {
|
Widget _buildCustomSrsTab() {
|
||||||
if (_customDeck.isEmpty) {
|
if (_customDeck.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)),
|
child: Text(
|
||||||
|
'No custom cards yet.',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _buildCustomGridView(_customDeck);
|
return _buildCustomGridView(_customDeck);
|
||||||
@@ -135,11 +155,15 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
Map<int, List<dynamic>> groupedItems,
|
Map<int, List<dynamic>> groupedItems,
|
||||||
List<int> sortedLevels,
|
List<int> sortedLevels,
|
||||||
PageController pageController,
|
PageController pageController,
|
||||||
Widget Function(List<dynamic>) buildPageContent) {
|
Widget Function(List<dynamic>) buildPageContent,
|
||||||
|
dynamic repository,
|
||||||
|
) {
|
||||||
if (sortedLevels.isEmpty) {
|
if (sortedLevels.isEmpty) {
|
||||||
return const Center(
|
return Center(
|
||||||
child:
|
child: Text(
|
||||||
Text('No items to display.', style: TextStyle(color: Colors.white)),
|
'No items to display.',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,17 +173,34 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final level = sortedLevels[index];
|
final level = sortedLevels[index];
|
||||||
final levelItems = groupedItems[level]!;
|
final levelItems = groupedItems[level]!;
|
||||||
|
final bool isDisabled = levelItems.every(
|
||||||
|
(item) => (item as dynamic).srsItems.values.every(
|
||||||
|
(srs) => (srs as SrsItem).disabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
'Level $level',
|
'Level $level',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.bold),
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Checkbox(
|
||||||
|
value: !isDisabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleLevelExclusion(level, repository);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: buildPageContent(levelItems)),
|
Expanded(child: buildPageContent(levelItems)),
|
||||||
@@ -179,33 +220,62 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
color: const Color(0xFF1F1F1F),
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
height: 60,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: List.generate(levels.length, (index) {
|
children: List.generate(levels.length, (index) {
|
||||||
final level = levels[index];
|
final level = levels[index];
|
||||||
final isSelected = index == currentPage;
|
final isSelected = index == currentPage;
|
||||||
return Padding(
|
final items = isKanji ? _kanjiByLevel[level] : _vocabByLevel[level];
|
||||||
|
final bool isDisabled =
|
||||||
|
items?.every(
|
||||||
|
(item) => (item as dynamic).srsItems.values.every(
|
||||||
|
(srs) => (srs as SrsItem).disabled,
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
controller.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
controller.animateToPage(
|
||||||
|
index,
|
||||||
|
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: isSelected ? Colors.blueAccent : const Color(0xFF333333),
|
backgroundColor: isSelected
|
||||||
foregroundColor: Colors.white,
|
? Theme.of(context).colorScheme.primary
|
||||||
shape: const CircleBorder(),
|
: isDisabled
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
|
||||||
|
foregroundColor: isSelected
|
||||||
|
? Theme.of(context).colorScheme.onPrimary
|
||||||
|
: isDisabled
|
||||||
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||||
|
: Theme.of(context).colorScheme.onSurface,
|
||||||
|
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
),
|
||||||
|
|
||||||
child: Text(level.toString()),
|
child: Text(level.toString()),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +292,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
final item = items[index];
|
final item = items[index];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _showReadingsDialog(item),
|
onTap: () => _showReadingsDialog(item),
|
||||||
child:
|
child: _buildSrsItemCard(item),
|
||||||
_buildSrsItemCard(item.characters, item.srsItems.values.toList()),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -241,15 +310,29 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildVocabListTile(VocabularyItem item) {
|
Widget _buildVocabListTile(VocabularyItem item) {
|
||||||
final avgSrsStage = item.srsItems.isNotEmpty
|
final requiredModes = <String>[
|
||||||
? item.srsItems.values
|
QuizMode.vocabToEnglish.toString(),
|
||||||
.map((s) => s.srsStage)
|
QuizMode.englishToVocab.toString(),
|
||||||
.reduce((a, b) => a + b) /
|
QuizMode.audioToEnglish.toString(),
|
||||||
item.srsItems.length
|
];
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
return Card(
|
int minSrsStage = 9;
|
||||||
color: const Color(0xFF1E1E1E),
|
|
||||||
|
for (final mode in requiredModes) {
|
||||||
|
final srsItem = item.srsItems[mode];
|
||||||
|
if (srsItem == null) {
|
||||||
|
minSrsStage = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (srsItem.srsStage < minSrsStage) {
|
||||||
|
minSrsStage = srsItem.srsStage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showVocabDetailsDialog(context, item),
|
||||||
|
child: Card(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
@@ -258,7 +341,10 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.characters,
|
item.characters,
|
||||||
style: const TextStyle(fontSize: 24, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -269,40 +355,65 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.meanings.join(', '),
|
item.meanings.join(', '),
|
||||||
style: const TextStyle(color: Colors.grey),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildSrsIndicator(avgSrsStage.round()),
|
_buildSrsIndicator(minSrsStage),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSrsItemCard(String characters, List<dynamic> srsItems) {
|
Widget _buildSrsItemCard(KanjiItem item) {
|
||||||
final avgSrsStage = srsItems.isNotEmpty
|
final requiredModes = <String>[
|
||||||
? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) /
|
QuizMode.kanjiToEnglish.toString(),
|
||||||
srsItems.length
|
QuizMode.englishToKanji.toString(),
|
||||||
: 0.0;
|
];
|
||||||
|
if (item.onyomi.isNotEmpty) {
|
||||||
|
requiredModes.add('${QuizMode.reading}onyomi');
|
||||||
|
}
|
||||||
|
if (item.kunyomi.isNotEmpty) {
|
||||||
|
requiredModes.add('${QuizMode.reading}kunyomi');
|
||||||
|
}
|
||||||
|
|
||||||
|
int minSrsStage = 9;
|
||||||
|
|
||||||
|
for (final mode in requiredModes) {
|
||||||
|
final srsItem = item.srsItems[mode];
|
||||||
|
if (srsItem == null) {
|
||||||
|
minSrsStage = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (srsItem.srsStage < minSrsStage) {
|
||||||
|
minSrsStage = srsItem.srsStage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: const Color(0xFF1E1E1E),
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
characters,
|
item.characters,
|
||||||
style: const TextStyle(fontSize: 32, color: Colors.white),
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildSrsIndicator(avgSrsStage.round()),
|
_buildSrsIndicator(minSrsStage),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -317,8 +428,10 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 10,
|
height: 10,
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: level / 9.0, // Max SRS level is 9
|
value: level / 9.0,
|
||||||
backgroundColor: Colors.grey[800],
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
_getColorForSrsLevel(level),
|
_getColorForSrsLevel(level),
|
||||||
),
|
),
|
||||||
@@ -329,62 +442,126 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _getColorForSrsLevel(int level) {
|
Color _getColorForSrsLevel(int level) {
|
||||||
if (level >= 9) return Colors.purple;
|
final srsColors = Theme.of(context).srsColors;
|
||||||
if (level >= 8) return Colors.blue;
|
if (level >= 9) return srsColors.level9;
|
||||||
if (level >= 7) return Colors.lightBlue;
|
if (level >= 8) return srsColors.level8;
|
||||||
if (level >= 5) return Colors.green;
|
if (level >= 7) return srsColors.level7;
|
||||||
if (level >= 3) return Colors.yellow;
|
if (level >= 6) return srsColors.level6;
|
||||||
if (level >= 1) return Colors.orange;
|
if (level >= 5) return srsColors.level5;
|
||||||
return Colors.red;
|
if (level >= 4) return srsColors.level4;
|
||||||
|
if (level >= 3) return srsColors.level3;
|
||||||
|
if (level >= 2) return srsColors.level2;
|
||||||
|
if (level >= 1) return srsColors.level1;
|
||||||
|
return Colors.grey;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showReadingsDialog(KanjiItem kanji) {
|
void _showReadingsDialog(KanjiItem kanji) {
|
||||||
|
final srsScores = <String, int>{
|
||||||
|
'JP -> EN': 0,
|
||||||
|
'EN -> JP': 0,
|
||||||
|
'Reading (onyomi)': 0,
|
||||||
|
'Reading (kunyomi)': 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final entry in kanji.srsItems.entries) {
|
||||||
|
final srsItem = entry.value;
|
||||||
|
switch (srsItem.quizMode) {
|
||||||
|
case QuizMode.kanjiToEnglish:
|
||||||
|
srsScores['JP -> EN'] = srsItem.srsStage;
|
||||||
|
break;
|
||||||
|
case QuizMode.englishToKanji:
|
||||||
|
srsScores['EN -> JP'] = srsItem.srsStage;
|
||||||
|
break;
|
||||||
|
case QuizMode.reading:
|
||||||
|
if (srsItem.readingType == 'onyomi') {
|
||||||
|
srsScores['Reading (onyomi)'] = srsItem.srsStage;
|
||||||
|
} else if (srsItem.readingType == 'kunyomi') {
|
||||||
|
srsScores['Reading (kunyomi)'] = srsItem.srsStage;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
title: Text(
|
title: Text(
|
||||||
'Details for ${kanji.characters}',
|
'Details for ${kanji.characters}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
content: Column(
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Level: ${kanji.level}',
|
'Level: ${kanji.level}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (kanji.meanings.isNotEmpty)
|
if (kanji.meanings.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'Meanings: ${kanji.meanings.join(', ')}',
|
'Meanings: ${kanji.meanings.join(', ')}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (kanji.onyomi.isNotEmpty)
|
if (kanji.onyomi.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'On\'yomi: ${kanji.onyomi.join(', ')}',
|
'On\'yomi: ${kanji.onyomi.join(', ')}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (kanji.kunyomi.isNotEmpty)
|
if (kanji.kunyomi.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
|
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
|
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
|
||||||
const Text(
|
Text(
|
||||||
'No readings available.',
|
'No readings available.',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'SRS Scores:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...srsScores.entries.map(
|
||||||
|
(entry) => Text(
|
||||||
|
' ${entry.key}: ${entry.value}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Close',
|
child: Text(
|
||||||
style: TextStyle(color: Colors.blueAccent)),
|
'Close',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -395,9 +572,13 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
Future<void> _loadDecks() async {
|
Future<void> _loadDecks() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
final kanjiRepo = Provider.of<DeckRepository>(context, listen: false);
|
||||||
await repo.loadApiKey();
|
final vocabRepo = Provider.of<VocabDeckRepository>(
|
||||||
final apiKey = repo.apiKey;
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
await kanjiRepo.loadApiKey();
|
||||||
|
final apiKey = kanjiRepo.apiKey;
|
||||||
|
|
||||||
if (apiKey == null || apiKey.isEmpty) {
|
if (apiKey == null || apiKey.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -407,16 +588,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var kanji = await repo.loadKanji();
|
var kanji = await kanjiRepo.loadKanji();
|
||||||
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
|
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
|
||||||
setState(() => _status = 'Fetching kanji from WaniKani...');
|
setState(() => _status = 'Fetching kanji from WaniKani...');
|
||||||
kanji = await repo.fetchAndCacheFromWk(apiKey);
|
kanji = await kanjiRepo.fetchAndCacheFromWk(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
var vocab = await repo.loadVocabulary();
|
var vocab = await vocabRepo.loadVocabulary();
|
||||||
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
|
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
|
||||||
setState(() => _status = 'Fetching vocabulary from WaniKani...');
|
setState(() => _status = 'Fetching vocabulary from WaniKani...');
|
||||||
vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
|
vocab = await vocabRepo.fetchAndCacheVocabularyFromWk(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
_kanjiDeck = kanji;
|
_kanjiDeck = kanji;
|
||||||
@@ -455,11 +636,48 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
|
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleLevelExclusion(int level, dynamic repository) async {
|
||||||
|
final List<SrsItem> itemsToUpdate = [];
|
||||||
|
final bool currentlyDisabled;
|
||||||
|
|
||||||
|
if (repository is DeckRepository) {
|
||||||
|
final items = _kanjiByLevel[level] ?? [];
|
||||||
|
currentlyDisabled = items.every(
|
||||||
|
(item) => item.srsItems.values.every((srs) => srs.disabled),
|
||||||
|
);
|
||||||
|
for (final item in items) {
|
||||||
|
for (final srsItem in item.srsItems.values) {
|
||||||
|
itemsToUpdate.add(srsItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (repository is VocabDeckRepository) {
|
||||||
|
final items = _vocabByLevel[level] ?? [];
|
||||||
|
currentlyDisabled = items.every(
|
||||||
|
(item) => item.srsItems.values.every((srs) => srs.disabled),
|
||||||
|
);
|
||||||
|
for (final item in items) {
|
||||||
|
for (final srsItem in item.srsItems.values) {
|
||||||
|
itemsToUpdate.add(srsItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final item in itemsToUpdate) {
|
||||||
|
item.disabled = !currentlyDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.updateSrsItems(itemsToUpdate);
|
||||||
|
_loadDecks();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
|
appBar: _isSelectionMode
|
||||||
backgroundColor: const Color(0xFF121212),
|
? _buildSelectionAppBar()
|
||||||
|
: _buildDefaultAppBar(),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -471,14 +689,18 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
_kanjiByLevel,
|
_kanjiByLevel,
|
||||||
_kanjiSortedLevels,
|
_kanjiSortedLevels,
|
||||||
_kanjiPageController,
|
_kanjiPageController,
|
||||||
(items) => _buildGridView(items.cast<KanjiItem>())),
|
(items) => _buildGridView(items.cast<KanjiItem>()),
|
||||||
|
Provider.of<DeckRepository>(context, listen: false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildWaniKaniTab(
|
_buildWaniKaniTab(
|
||||||
_buildPaginatedView(
|
_buildPaginatedView(
|
||||||
_vocabByLevel,
|
_vocabByLevel,
|
||||||
_vocabSortedLevels,
|
_vocabSortedLevels,
|
||||||
_vocabPageController,
|
_vocabPageController,
|
||||||
(items) => _buildListView(items.cast<VocabularyItem>())),
|
(items) => _buildListView(items.cast<VocabularyItem>()),
|
||||||
|
Provider.of<VocabDeckRepository>(context, listen: false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildCustomSrsTab(),
|
_buildCustomSrsTab(),
|
||||||
],
|
],
|
||||||
@@ -487,10 +709,24 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(),
|
child: _tabController.index < 2
|
||||||
|
? _buildLevelSelector()
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: _tabController.index == 2
|
||||||
|
? FloatingActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.of(
|
||||||
|
context,
|
||||||
|
).push(MaterialPageRoute(builder: (_) => AddCardScreen()));
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadCustomDeck();
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,14 +757,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
),
|
),
|
||||||
title: Text('${_selectedItems.length} selected'),
|
title: Text('${_selectedItems.length} selected'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.select_all), onPressed: _selectAll),
|
||||||
icon: const Icon(Icons.select_all),
|
IconButton(icon: const Icon(Icons.delete), onPressed: _deleteSelected),
|
||||||
onPressed: _selectAll,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: _deleteSelected,
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_toggleIntervalIcon),
|
icon: Icon(_toggleIntervalIcon),
|
||||||
onPressed: _toggleIntervalForSelected,
|
onPressed: _toggleIntervalForSelected,
|
||||||
@@ -558,25 +788,30 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
void _deleteSelected() {
|
void _deleteSelected() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Delete Selected'),
|
title: const Text('Delete Selected'),
|
||||||
content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
|
content: Text(
|
||||||
|
'Are you sure you want to delete ${_selectedItems.length} cards?',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
() async {
|
||||||
for (final item in _selectedItems) {
|
for (final item in _selectedItems) {
|
||||||
await _customDeckRepository.deleteCard(item);
|
await _customDeckRepository.deleteCard(item);
|
||||||
}
|
}
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSelectionMode = false;
|
_isSelectionMode = false;
|
||||||
_selectedItems.clear();
|
_selectedItems.clear();
|
||||||
});
|
});
|
||||||
_loadCustomDeck();
|
_loadCustomDeck();
|
||||||
Navigator.of(context).pop();
|
}();
|
||||||
},
|
},
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
@@ -591,7 +826,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
}
|
}
|
||||||
final bool targetState = _selectedItems.any((item) => !item.useInterval);
|
final bool targetState = _selectedItems.any((item) => !item.useInterval);
|
||||||
|
|
||||||
final selectedCharacters = _selectedItems.map((item) => item.characters).toSet();
|
final selectedCharacters = _selectedItems
|
||||||
|
.map((item) => item.characters)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
final List<CustomKanjiItem> updatedItems = [];
|
final List<CustomKanjiItem> updatedItems = [];
|
||||||
for (final item in _selectedItems) {
|
for (final item in _selectedItems) {
|
||||||
@@ -600,8 +837,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
meaning: item.meaning,
|
meaning: item.meaning,
|
||||||
kanji: item.kanji,
|
kanji: item.kanji,
|
||||||
useInterval: targetState,
|
useInterval: targetState,
|
||||||
srsLevel: item.srsLevel,
|
srsData: item.srsData,
|
||||||
nextReview: item.nextReview,
|
|
||||||
);
|
);
|
||||||
updatedItems.add(updatedItem);
|
updatedItems.add(updatedItem);
|
||||||
}
|
}
|
||||||
@@ -653,26 +889,33 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context)
|
||||||
|
.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => CustomCardDetailsScreen(
|
builder: (_) => CustomCardDetailsScreen(
|
||||||
item: item,
|
item: item,
|
||||||
repository: _customDeckRepository,
|
repository: _customDeckRepository,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).then((_) => _loadCustomDeck());
|
)
|
||||||
|
.then((_) => _loadCustomDeck());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
side: isSelected
|
side: isSelected
|
||||||
? const BorderSide(color: Colors.blue, width: 2.0)
|
? BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2.0,
|
||||||
|
)
|
||||||
: BorderSide.none,
|
: BorderSide.none,
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
),
|
),
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Colors.blue.withOpacity(0.5)
|
? Theme.of(
|
||||||
: const Color(0xFF1E1E1E),
|
context,
|
||||||
|
).colorScheme.primary.withAlpha((255 * 0.5).round())
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@@ -683,20 +926,37 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
FittedBox(
|
FittedBox(
|
||||||
fit: BoxFit.scaleDown,
|
fit: BoxFit.scaleDown,
|
||||||
child: Text(
|
child: Text(
|
||||||
item.kanji ?? item.characters,
|
item.kanji?.isNotEmpty == true
|
||||||
style: const TextStyle(fontSize: 32, color: Colors.white),
|
? item.kanji!
|
||||||
|
: item.characters,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
item.meaning,
|
item.meaning,
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildSrsIndicator(item.srsLevel),
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final avgSrs =
|
||||||
|
(item.srsData.japaneseToEnglish +
|
||||||
|
item.srsData.englishToJapanese +
|
||||||
|
item.srsData.listeningComprehension) /
|
||||||
|
3;
|
||||||
|
return _buildSrsIndicator(avgSrs.round());
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -706,7 +966,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
right: 4,
|
right: 4,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.timer,
|
Icons.timer,
|
||||||
color: Colors.green,
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -719,3 +979,207 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _VocabDetailsDialog extends StatefulWidget {
|
||||||
|
final VocabularyItem vocab;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
|
const _VocabDetailsDialog({required this.vocab, required this.theme});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
||||||
|
List<Widget> _exampleSentences = [const CircularProgressIndicator()];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchExampleSentences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchExampleSentences() async {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}',
|
||||||
|
);
|
||||||
|
final response = await http.get(uri);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
|
final sentences = <Widget>[];
|
||||||
|
if (data['data'] != null && (data['data'] as List).isNotEmpty) {
|
||||||
|
for (final result in data['data']) {
|
||||||
|
if (result['japanese'] != null &&
|
||||||
|
(result['japanese'] as List).isNotEmpty &&
|
||||||
|
result['senses'] != null &&
|
||||||
|
(result['senses'] as List).isNotEmpty) {
|
||||||
|
final japaneseWord =
|
||||||
|
result['japanese'][0]['word'] ??
|
||||||
|
result['japanese'][0]['reading'];
|
||||||
|
final englishDefinition =
|
||||||
|
result['senses'][0]['english_definitions'].join(', ');
|
||||||
|
if (japaneseWord != null && englishDefinition != null) {
|
||||||
|
sentences.add(
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
japaneseWord,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
englishDefinition,
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sentences.isEmpty) {
|
||||||
|
sentences.add(
|
||||||
|
Text(
|
||||||
|
'No example sentences found.',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exampleSentences = sentences;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exampleSentences = [
|
||||||
|
Text(
|
||||||
|
'Failed to load example sentences.',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_exampleSentences = [
|
||||||
|
Text(
|
||||||
|
'Error loading example sentences.',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final srsScores = <String, int>{'JP -> EN': 0, 'EN -> JP': 0, 'Audio': 0};
|
||||||
|
|
||||||
|
for (final entry in widget.vocab.srsItems.entries) {
|
||||||
|
final srsItem = entry.value;
|
||||||
|
switch (srsItem.quizMode) {
|
||||||
|
case QuizMode.vocabToEnglish:
|
||||||
|
srsScores['JP -> EN'] = srsItem.srsStage;
|
||||||
|
break;
|
||||||
|
case QuizMode.englishToVocab:
|
||||||
|
srsScores['EN -> JP'] = srsItem.srsStage;
|
||||||
|
break;
|
||||||
|
case QuizMode.audioToEnglish:
|
||||||
|
srsScores['Audio'] = srsItem.srsStage;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Level: ${widget.vocab.level}',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (widget.vocab.meanings.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'Meanings: ${widget.vocab.meanings.join(', ')}',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (widget.vocab.readings.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'Readings: ${widget.vocab.readings.join(', ')}',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'SRS Scores:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...srsScores.entries.map(
|
||||||
|
(entry) => Text(
|
||||||
|
' ${entry.key}: ${entry.value}',
|
||||||
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Example Sentences:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
..._exampleSentences,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
|
||||||
|
final currentTheme = Theme.of(context);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: currentTheme.colorScheme.surfaceContainer,
|
||||||
|
title: Text(
|
||||||
|
'Details for ${vocab.characters}',
|
||||||
|
style: TextStyle(color: currentTheme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
content: _VocabDetailsDialog(vocab: vocab, theme: currentTheme),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: Text(
|
||||||
|
'Close',
|
||||||
|
style: TextStyle(color: currentTheme.colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,193 +27,404 @@ 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) {
|
||||||
|
quizState.asked += 1;
|
||||||
|
if (!quizState.wrongItems.contains(current.characters)) {
|
||||||
|
quizState.score += 1;
|
||||||
|
}
|
||||||
|
if (_playCorrectSound && !_playNarrator) {
|
||||||
|
await _audioPlayer.play(AssetSource('sfx/correct.wav'));
|
||||||
|
} else if (_playNarrator) {
|
||||||
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
|
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
|
||||||
widget.quizMode == CustomQuizMode.listeningComprehension) {
|
widget.quizMode == CustomQuizMode.englishToJapanese) {
|
||||||
_speak(currentItem.characters);
|
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(() {
|
setState(() {
|
||||||
_currentIndex = (_currentIndex + 1) % _shuffledDeck.length;
|
quizState.current = null;
|
||||||
_answered = false;
|
|
||||||
_correct = null;
|
|
||||||
_generateOptions();
|
|
||||||
});
|
});
|
||||||
|
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(() {
|
||||||
|
_isAnswering = false;
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
child: Column(
|
String subtitle = '';
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||||
if (widget.quizMode == CustomQuizMode.listeningComprehension)
|
promptWidget = IconButton(
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.volume_up, size: 64),
|
icon: const Icon(Icons.volume_up, size: 64),
|
||||||
onPressed: () => _speak(currentItem.characters),
|
onPressed: () => _speak(currentItem.characters),
|
||||||
)
|
);
|
||||||
else
|
} else if (widget.quizMode == CustomQuizMode.englishToJapanese) {
|
||||||
GestureDetector(
|
promptWidget = Text(
|
||||||
onTap: () => _speak(question),
|
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(
|
child: Text(
|
||||||
question,
|
promptText,
|
||||||
style: const TextStyle(fontSize: 48),
|
style: TextStyle(
|
||||||
|
fontSize: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 32),
|
}
|
||||||
if (_answered)
|
|
||||||
|
return Padding(
|
||||||
|
key: quizState.key,
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_correct! ? 'Correct!' : 'Incorrect, try again!',
|
'${quizState.asked} / $_sessionDeckSize',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
color: _correct! ? Colors.green : Colors.red,
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: _sessionDeckSize > 0
|
||||||
|
? quizState.asked / _sessionDeckSize
|
||||||
|
: 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: subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _shakeAnimation,
|
animation: _shakeAnimation,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
@@ -214,14 +434,22 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: OptionsGrid(
|
child: OptionsGrid(
|
||||||
options: _options,
|
options: quizState.options,
|
||||||
onSelected: _onOptionSelected,
|
onSelected: _isAnswering ? (option) {} : _onOptionSelected,
|
||||||
|
selectedOption: quizState.selectedOption,
|
||||||
|
correctAnswers: quizState.correctAnswers,
|
||||||
|
showResult: quizState.showResult,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_answered && _correct!)
|
const SizedBox(height: 8),
|
||||||
ElevatedButton(
|
Text(
|
||||||
onPressed: _nextQuestion,
|
'Score: ${quizState.score} / ${quizState.asked}',
|
||||||
child: const Text('Next'),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
} else if (item.onyomi.isNotEmpty) {
|
|
||||||
key += 'onyomi';
|
|
||||||
} else {
|
|
||||||
key += 'kunyomi';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
final aSrsItem = a.srsItems[srsKey(a)];
|
|
||||||
final bSrsItem = b.srsItems[srsKey(b)];
|
|
||||||
|
|
||||||
final aStage = aSrsItem?.srsStage ?? 0;
|
|
||||||
final bStage = bSrsItem?.srsStage ?? 0;
|
|
||||||
|
|
||||||
if (aStage != bStage) {
|
|
||||||
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());
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_current = _deck.first;
|
quizState.current = sessionDeck.removeAt(0);
|
||||||
|
quizState.key = UniqueKey();
|
||||||
|
|
||||||
_correctAnswers = [];
|
quizState.correctAnswers = [];
|
||||||
_options = [];
|
quizState.options = [];
|
||||||
_readingHint = '';
|
quizState.readingHint = '';
|
||||||
|
quizState.selectedOption = null;
|
||||||
|
quizState.showResult = false;
|
||||||
|
|
||||||
switch (_mode) {
|
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
|
||||||
|
.where((r) => !quizState.correctAnswers.contains(r))
|
||||||
.toSet()
|
.toSet()
|
||||||
.toList()
|
.toList()
|
||||||
..shuffle();
|
..shuffle();
|
||||||
_options = ([
|
quizState.options = ([
|
||||||
_correctAnswers[_random.nextInt(_correctAnswers.length)],
|
quizState.correctAnswers[_random.nextInt(
|
||||||
...distractors.take(3)
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
quizState.selectedOption = option;
|
||||||
|
|
||||||
|
quizState.showResult = true;
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
_score += 1;
|
quizState.asked += 1;
|
||||||
|
if (!quizState.wrongItems.contains(current.id)) {
|
||||||
|
quizState.score += 1;
|
||||||
|
}
|
||||||
srsItemForUpdate.srsStage += 1;
|
srsItemForUpdate.srsStage += 1;
|
||||||
if (_playCorrectSound) {
|
if (_playCorrectSound) {
|
||||||
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
_audioPlayer.play(AssetSource('sfx/correct.wav'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
|
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();
|
srsItemForUpdate.lastAsked = DateTime.now();
|
||||||
current.srsItems[srsKey] = srsItemForUpdate;
|
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,21 +438,78 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: const Color(0xFF121212),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
body: Padding(
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: Text(
|
'${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
|
||||||
_status,
|
style: TextStyle(
|
||||||
style: const TextStyle(color: Colors.white),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_loading)
|
|
||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
@@ -384,8 +525,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
child: KanjiCard(
|
child: KanjiCard(
|
||||||
characters: prompt,
|
characters: prompt,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
textColor: Colors.white,
|
textColor: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -396,22 +537,24 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
OptionsGrid(
|
OptionsGrid(
|
||||||
options: _options,
|
options: quizState.options,
|
||||||
onSelected: _answer,
|
onSelected: _isAnswering ? (option) {} : _answer,
|
||||||
buttonColor: const Color(0xFF1E1E1E),
|
showResult: quizState.showResult,
|
||||||
textColor: Colors.white,
|
selectedOption: quizState.selectedOption,
|
||||||
|
correctAnswers: quizState.correctAnswers,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Score: $_score / $_asked',
|
'Score: ${quizState.score} / ${quizState.asked}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
|
|
||||||
if (deck.isEmpty) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = 'No vocabulary with audio found.';
|
quizState.current = null;
|
||||||
_current = null;
|
_status = 'Quiz complete!';
|
||||||
});
|
});
|
||||||
return;
|
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);
|
||||||
|
|
||||||
|
quizState.selectedOption = option;
|
||||||
|
quizState.showResult = true;
|
||||||
|
setState(() {});
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_asked += 1;
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
_score += 1;
|
quizState.asked += 1;
|
||||||
|
if (!quizState.wrongItems.contains(current.id)) {
|
||||||
|
quizState.score += 1;
|
||||||
|
}
|
||||||
srsItem.srsStage += 1;
|
srsItem.srsStage += 1;
|
||||||
} else {
|
} else {
|
||||||
srsItem.srsStage = max(0, srsItem.srsStage - 1);
|
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();
|
srsItem.lastAsked = DateTime.now();
|
||||||
current.srsItems[srsKey] = srsItem;
|
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,19 +337,23 @@ 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isAnswering = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) {
|
||||||
_nextQuestion();
|
_nextQuestion();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -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,21 +416,93 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: const Color(0xFF121212),
|
body: TabBarView(
|
||||||
body: Padding(
|
controller: _tabController,
|
||||||
|
children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: Text(
|
'${quizState.asked} / ${_sessionDeckSizes[index] ?? 0}',
|
||||||
_status,
|
style: TextStyle(
|
||||||
style: const TextStyle(color: Colors.white),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_loading)
|
|
||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
@@ -370,12 +515,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
|||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
minHeight: 150,
|
minHeight: 150,
|
||||||
),
|
),
|
||||||
child: KanjiCard(
|
child: KanjiCard(characterWidget: promptWidget, subtitle: ''),
|
||||||
characterWidget: promptWidget,
|
|
||||||
subtitle: '',
|
|
||||||
backgroundColor: const Color(0xFF1E1E1E),
|
|
||||||
textColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -385,22 +525,24 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
OptionsGrid(
|
OptionsGrid(
|
||||||
options: _options,
|
options: quizState.options,
|
||||||
onSelected: _answer,
|
onSelected: _isAnswering ? (option) {} : _answer,
|
||||||
buttonColor: const Color(0xFF1E1E1E),
|
showResult: quizState.showResult,
|
||||||
textColor: Colors.white,
|
selectedOption: quizState.selectedOption,
|
||||||
|
correctAnswers: quizState.correctAnswers,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Score: $_score / $_asked',
|
'Score: ${quizState.score} / ${quizState.asked}',
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
27
lib/src/services/database_constants.dart
Normal file
27
lib/src/services/database_constants.dart
Normal 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';
|
||||||
|
}
|
||||||
65
lib/src/services/database_helper.dart
Normal file
65
lib/src/services/database_helper.dart
Normal 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
);
|
|
||||||
if (rows.isNotEmpty) {
|
|
||||||
_apiKey = rows.first['value'] as String;
|
|
||||||
return _apiKey;
|
return _apiKey;
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
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()];
|
||||||
|
if (item.readingType != null) {
|
||||||
|
where += ' AND ${DbConstants.readingTypeColumn} = ?';
|
||||||
|
whereArgs.add(item.readingType!);
|
||||||
|
} else {
|
||||||
|
where += ' AND ${DbConstants.readingTypeColumn} IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.update(
|
||||||
|
DbConstants.srsItemsTable,
|
||||||
|
{
|
||||||
|
DbConstants.srsStageColumn: item.srsStage,
|
||||||
|
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
||||||
|
DbConstants.disabledColumn: item.disabled ? 1 : 0,
|
||||||
|
},
|
||||||
|
where: where,
|
||||||
|
whereArgs: whereArgs,
|
||||||
);
|
);
|
||||||
return rows.map((r) {
|
}
|
||||||
return SrsItem(
|
await batch.commit(noResult: true);
|
||||||
kanjiId: r['kanjiId'] as int,
|
|
||||||
quizMode: QuizMode.values.firstWhere(
|
|
||||||
(e) => e.toString() == r['quizMode'] as String,
|
|
||||||
),
|
|
||||||
readingType: r['readingType'] as String?,
|
|
||||||
srsStage: r['srsStage'] as int,
|
|
||||||
lastAsked: DateTime.parse(r['lastAsked'] as String),
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
|
|||||||
56
lib/src/services/tts_service.dart
Normal file
56
lib/src/services/tts_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
224
lib/src/services/vocab_deck_repository.dart
Normal file
224
lib/src/services/vocab_deck_repository.dart
Normal 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
131
lib/src/themes.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user