Compare commits
5 Commits
732408997d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ad880f79 | ||
|
|
5f1b9ba12e | ||
|
|
16da0f04ac | ||
|
|
e9f115a32a | ||
|
|
d5ff5eb12f |
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.
@@ -5,12 +5,12 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'src/services/deck_repository.dart';
|
import 'src/services/deck_repository.dart';
|
||||||
import 'src/screens/start_screen.dart';
|
import 'src/screens/start_screen.dart';
|
||||||
|
import 'src/services/tts_service.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
try {
|
try {
|
||||||
await dotenv.load(fileName: ".env");
|
await dotenv.load(fileName: ".env");
|
||||||
// No need to catch because the file is only needed for dev
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
@@ -19,6 +19,14 @@ void main() async {
|
|||||||
Provider<DeckRepository>(create: (_) => DeckRepository()),
|
Provider<DeckRepository>(create: (_) => DeckRepository()),
|
||||||
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
|
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
|
||||||
ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()),
|
ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()),
|
||||||
|
Provider<TtsService>(
|
||||||
|
create: (_) {
|
||||||
|
final ttsService = TtsService();
|
||||||
|
ttsService.initTts();
|
||||||
|
return ttsService;
|
||||||
|
},
|
||||||
|
dispose: (_, ttsService) => ttsService.dispose(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const WkApp(),
|
child: const WkApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class WkClient {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Subject createSubjectFromMap(Map<String, dynamic> map) {
|
static Subject createSubjectFromMap(Map<String, dynamic> map) {
|
||||||
final String object = map['object'];
|
final String object = map['object'];
|
||||||
if (object == 'kanji') {
|
if (object == 'kanji') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class SrsItem {
|
|||||||
final String? readingType;
|
final String? readingType;
|
||||||
int srsStage;
|
int srsStage;
|
||||||
DateTime lastAsked;
|
DateTime lastAsked;
|
||||||
|
bool disabled;
|
||||||
|
|
||||||
SrsItem({
|
SrsItem({
|
||||||
required this.subjectId,
|
required this.subjectId,
|
||||||
@@ -13,5 +14,6 @@ class SrsItem {
|
|||||||
this.readingType,
|
this.readingType,
|
||||||
this.srsStage = 0,
|
this.srsStage = 0,
|
||||||
DateTime? lastAsked,
|
DateTime? lastAsked,
|
||||||
|
this.disabled = false,
|
||||||
}) : lastAsked = lastAsked ?? DateTime.now();
|
}) : lastAsked = lastAsked ?? DateTime.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ class _AddCardScreenState extends State<AddCardScreen> {
|
|||||||
final _kanaKit = const KanaKit();
|
final _kanaKit = const KanaKit();
|
||||||
final _deckRepository = CustomDeckRepository();
|
final _deckRepository = CustomDeckRepository();
|
||||||
bool _useInterval = false;
|
bool _useInterval = false;
|
||||||
|
late FocusNode _japaneseFocusNode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_japaneseController.addListener(_convertToKana);
|
_japaneseController.addListener(_convertToKana);
|
||||||
|
_japaneseFocusNode = FocusNode();
|
||||||
|
_japaneseFocusNode.addListener(_onJapaneseFocusChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -31,13 +34,24 @@ class _AddCardScreenState extends State<AddCardScreen> {
|
|||||||
_japaneseController.dispose();
|
_japaneseController.dispose();
|
||||||
_englishController.dispose();
|
_englishController.dispose();
|
||||||
_kanjiController.dispose();
|
_kanjiController.dispose();
|
||||||
|
_japaneseFocusNode.removeListener(_onJapaneseFocusChange);
|
||||||
|
_japaneseFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _convertToKana() {
|
void _convertToKana() {
|
||||||
final text = _japaneseController.text;
|
final text = _japaneseController.text;
|
||||||
|
final selection = _japaneseController.selection;
|
||||||
|
final offset = selection.baseOffset;
|
||||||
|
|
||||||
|
if ((offset > 1 && text[offset - 1] == 'n' && text[offset - 2] != 'n') ||
|
||||||
|
(offset == 1 && text[offset - 1] == 'n')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final converted = _kanaKit.toKana(text);
|
final converted = _kanaKit.toKana(text);
|
||||||
if (text != converted) {
|
|
||||||
|
if (converted != text) {
|
||||||
_japaneseController.value = _japaneseController.value.copyWith(
|
_japaneseController.value = _japaneseController.value.copyWith(
|
||||||
text: converted,
|
text: converted,
|
||||||
selection: TextSelection.fromPosition(
|
selection: TextSelection.fromPosition(
|
||||||
@@ -47,6 +61,21 @@ class _AddCardScreenState extends State<AddCardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onJapaneseFocusChange() {
|
||||||
|
if (!_japaneseFocusNode.hasFocus) {
|
||||||
|
_forceNConversion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _forceNConversion() {
|
||||||
|
final text = _japaneseController.text;
|
||||||
|
if (text.isNotEmpty &&
|
||||||
|
text.endsWith('n') &&
|
||||||
|
_kanaKit.toKana(text) != text) {
|
||||||
|
_japaneseController.text = _kanaKit.toKana(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _saveCard() {
|
void _saveCard() {
|
||||||
if (_formKey.currentState!.validate()) {
|
if (_formKey.currentState!.validate()) {
|
||||||
final srsData = _useInterval
|
final srsData = _useInterval
|
||||||
@@ -83,6 +112,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _japaneseController,
|
controller: _japaneseController,
|
||||||
|
focusNode: _japaneseFocusNode,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Japanese (Kana)',
|
labelText: 'Japanese (Kana)',
|
||||||
hintText: 'Enter Japanese vocabulary or kanji',
|
hintText: 'Enter Japanese vocabulary or kanji',
|
||||||
|
|||||||
@@ -123,9 +123,14 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(color: Theme.of(context).colorScheme.primary),
|
CircularProgressIndicator(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.onSurface)),
|
Text(
|
||||||
|
_status,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -151,6 +156,7 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
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 Center(
|
return Center(
|
||||||
@@ -167,12 +173,31 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
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;
|
||||||
|
if (repository is DeckRepository) {
|
||||||
|
isDisabled = levelItems.every(
|
||||||
|
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every(
|
||||||
|
(srs) => srs.disabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (repository is VocabDeckRepository) {
|
||||||
|
isDisabled = levelItems.every(
|
||||||
|
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every(
|
||||||
|
(srs) => srs.disabled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
isDisabled = false; // Default to false if repository type is unknown
|
||||||
|
}
|
||||||
|
|
||||||
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: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
@@ -180,6 +205,14 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Checkbox(
|
||||||
|
value: !isDisabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
_toggleLevelExclusion(level, repository, index, pageController);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: buildPageContent(levelItems)),
|
Expanded(child: buildPageContent(levelItems)),
|
||||||
],
|
],
|
||||||
@@ -199,38 +232,68 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
height: 60,
|
|
||||||
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;
|
||||||
|
if (isKanji) {
|
||||||
|
isDisabled = items?.every(
|
||||||
|
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every(
|
||||||
|
(srs) => srs.disabled,
|
||||||
|
), ) ??
|
||||||
|
false;
|
||||||
|
} else {
|
||||||
|
isDisabled = items?.every(
|
||||||
|
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every(
|
||||||
|
(srs) => srs.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(
|
controller.animateToPage(
|
||||||
index,
|
index,
|
||||||
|
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
|
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: isDisabled
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
|
||||||
shape: const CircleBorder(),
|
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()),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +359,10 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.characters,
|
item.characters,
|
||||||
style: TextStyle(fontSize: 24, color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -307,7 +373,9 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.meanings.join(', '),
|
item.meanings.join(', '),
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -356,7 +424,10 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
item.characters,
|
item.characters,
|
||||||
style: TextStyle(fontSize: 32, color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -376,7 +447,9 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
height: 10,
|
height: 10,
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: level / 9.0,
|
value: level / 9.0,
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
_getColorForSrsLevel(level),
|
_getColorForSrsLevel(level),
|
||||||
),
|
),
|
||||||
@@ -445,29 +518,39 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Level: ${kanji.level}',
|
'Level: ${kanji.level}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
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: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
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: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
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: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
|
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
|
||||||
Text(
|
Text(
|
||||||
'No readings available.',
|
'No readings available.',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
@@ -482,7 +565,9 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
...srsScores.entries.map(
|
...srsScores.entries.map(
|
||||||
(entry) => Text(
|
(entry) => Text(
|
||||||
' ${entry.key}: ${entry.value}',
|
' ${entry.key}: ${entry.value}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -569,6 +654,106 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
|
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleLevelExclusion(
|
||||||
|
int level,
|
||||||
|
dynamic repository,
|
||||||
|
int currentPageIndex,
|
||||||
|
PageController pageController,
|
||||||
|
) async {
|
||||||
|
final List<SrsItem> itemsToUpdate = [];
|
||||||
|
List<dynamic> items = [];
|
||||||
|
bool currentlyDisabled = false;
|
||||||
|
|
||||||
|
if (repository is DeckRepository) {
|
||||||
|
items = _kanjiByLevel[level] ?? [];
|
||||||
|
currentlyDisabled = items.every(
|
||||||
|
(item) => (item as KanjiItem).srsItems.values.isNotEmpty && (item as KanjiItem).srsItems.values.cast<SrsItem>().every((srs) => srs.disabled),
|
||||||
|
);
|
||||||
|
for (final item in items) {
|
||||||
|
for (final srsItem in item.srsItems.values) {
|
||||||
|
itemsToUpdate.add(srsItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (repository is VocabDeckRepository) {
|
||||||
|
items = _vocabByLevel[level] ?? [];
|
||||||
|
currentlyDisabled = items.every(
|
||||||
|
(item) => (item as VocabularyItem).srsItems.values.isNotEmpty && (item as VocabularyItem).srsItems.values.cast<SrsItem>().every((srs) => srs.disabled),
|
||||||
|
);
|
||||||
|
for (final item in items) {
|
||||||
|
for (final srsItem in item.srsItems.values) {
|
||||||
|
itemsToUpdate.add(srsItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsToUpdate.isEmpty) {
|
||||||
|
// No SrsItems exist for this level, so create them and set their disabled status
|
||||||
|
for (final item in items) { // 'items' contains KanjiItem or VocabularyItem
|
||||||
|
// Determine quiz modes based on repository type
|
||||||
|
List<QuizMode> quizModes = [];
|
||||||
|
if (repository is DeckRepository) {
|
||||||
|
quizModes = [QuizMode.kanjiToEnglish, QuizMode.englishToKanji, QuizMode.reading];
|
||||||
|
} else if (repository is VocabDeckRepository) {
|
||||||
|
quizModes = [QuizMode.vocabToEnglish, QuizMode.englishToVocab, QuizMode.audioToEnglish];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final mode in quizModes) {
|
||||||
|
String? readingType;
|
||||||
|
if (mode == QuizMode.reading && repository is DeckRepository) {
|
||||||
|
// For reading mode, create SrsItems for both onyomi and kunyomi if they exist
|
||||||
|
if ((item as KanjiItem).onyomi.isNotEmpty) {
|
||||||
|
readingType = 'onyomi';
|
||||||
|
itemsToUpdate.add(SrsItem(
|
||||||
|
subjectId: item.id,
|
||||||
|
quizMode: mode,
|
||||||
|
readingType: readingType,
|
||||||
|
disabled: !currentlyDisabled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if ((item as KanjiItem).kunyomi.isNotEmpty) {
|
||||||
|
readingType = 'kunyomi';
|
||||||
|
itemsToUpdate.add(SrsItem(
|
||||||
|
subjectId: item.id,
|
||||||
|
quizMode: mode,
|
||||||
|
readingType: readingType,
|
||||||
|
disabled: !currentlyDisabled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsToUpdate.add(SrsItem(
|
||||||
|
subjectId: item.id,
|
||||||
|
quizMode: mode,
|
||||||
|
disabled: !currentlyDisabled,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Now insert these newly created SrsItems
|
||||||
|
if (repository is DeckRepository) {
|
||||||
|
for (final srsItem in itemsToUpdate) {
|
||||||
|
await (repository as DeckRepository).insertSrsItem(srsItem);
|
||||||
|
}
|
||||||
|
} else if (repository is VocabDeckRepository) {
|
||||||
|
for (final srsItem in itemsToUpdate) {
|
||||||
|
await (repository as VocabDeckRepository).insertVocabSrsItem(srsItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Existing SrsItems, so update them
|
||||||
|
for (final item in itemsToUpdate) {
|
||||||
|
item.disabled = !currentlyDisabled;
|
||||||
|
}
|
||||||
|
await repository.updateSrsItems(itemsToUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
|
if (pageController.hasClients) {
|
||||||
|
pageController.jumpToPage(currentPageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -587,6 +772,7 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
_kanjiSortedLevels,
|
_kanjiSortedLevels,
|
||||||
_kanjiPageController,
|
_kanjiPageController,
|
||||||
(items) => _buildGridView(items.cast<KanjiItem>()),
|
(items) => _buildGridView(items.cast<KanjiItem>()),
|
||||||
|
Provider.of<DeckRepository>(context, listen: false),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildWaniKaniTab(
|
_buildWaniKaniTab(
|
||||||
@@ -595,6 +781,7 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
_vocabSortedLevels,
|
_vocabSortedLevels,
|
||||||
_vocabPageController,
|
_vocabPageController,
|
||||||
(items) => _buildListView(items.cast<VocabularyItem>()),
|
(items) => _buildListView(items.cast<VocabularyItem>()),
|
||||||
|
Provider.of<VocabDeckRepository>(context, listen: false),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildCustomSrsTab(),
|
_buildCustomSrsTab(),
|
||||||
@@ -674,8 +861,7 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (_selectedItems.length == _customDeck.length) {
|
if (_selectedItems.length == _customDeck.length) {
|
||||||
_selectedItems.clear();
|
_selectedItems.clear();
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
_selectedItems = List.from(_customDeck);
|
_selectedItems = List.from(_customDeck);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -800,12 +986,17 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
child: Card(
|
child: Card(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
side: isSelected
|
side: isSelected
|
||||||
? BorderSide(color: Theme.of(context).colorScheme.primary, 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
|
||||||
? Theme.of(context).colorScheme.primary.withAlpha((255 * 0.5).round())
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withAlpha((255 * 0.5).round())
|
||||||
: Theme.of(context).colorScheme.surfaceContainer,
|
: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -855,7 +1046,11 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
right: 4,
|
right: 4,
|
||||||
child: Icon(Icons.timer, color: Theme.of(context).colorScheme.tertiary, size: 16),
|
child: Icon(
|
||||||
|
Icons.timer,
|
||||||
|
color: Theme.of(context).colorScheme.tertiary,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -869,8 +1064,9 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
|
|
||||||
class _VocabDetailsDialog extends StatefulWidget {
|
class _VocabDetailsDialog extends StatefulWidget {
|
||||||
final VocabularyItem vocab;
|
final VocabularyItem vocab;
|
||||||
|
final ThemeData theme;
|
||||||
|
|
||||||
const _VocabDetailsDialog({required this.vocab});
|
const _VocabDetailsDialog({required this.vocab, required this.theme});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
|
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
|
||||||
@@ -886,7 +1082,6 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchExampleSentences() async {
|
Future<void> _fetchExampleSentences() async {
|
||||||
final theme = Theme.of(context);
|
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(
|
final uri = Uri.parse(
|
||||||
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}',
|
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}',
|
||||||
@@ -913,11 +1108,15 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
japaneseWord,
|
japaneseWord,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
englishDefinition,
|
englishDefinition,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -931,7 +1130,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
sentences.add(
|
sentences.add(
|
||||||
Text(
|
Text(
|
||||||
'No example sentences found.',
|
'No example sentences found.',
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -946,7 +1145,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
_exampleSentences = [
|
_exampleSentences = [
|
||||||
Text(
|
Text(
|
||||||
'Failed to load example sentences.',
|
'Failed to load example sentences.',
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -958,7 +1157,7 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
_exampleSentences = [
|
_exampleSentences = [
|
||||||
Text(
|
Text(
|
||||||
'Error loading example sentences.',
|
'Error loading example sentences.',
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -994,39 +1193,45 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Level: ${widget.vocab.level}',
|
'Level: ${widget.vocab.level}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.vocab.meanings.isNotEmpty)
|
if (widget.vocab.meanings.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'Meanings: ${widget.vocab.meanings.join(', ')}',
|
'Meanings: ${widget.vocab.meanings.join(', ')}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (widget.vocab.readings.isNotEmpty)
|
if (widget.vocab.readings.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
'Readings: ${widget.vocab.readings.join(', ')}',
|
'Readings: ${widget.vocab.readings.join(', ')}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'SRS Scores:',
|
'SRS Scores:',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
...srsScores.entries.map(
|
...srsScores.entries.map(
|
||||||
(entry) => Text(
|
(entry) => Text(
|
||||||
' ${entry.key}: ${entry.value}',
|
' ${entry.key}: ${entry.value}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Example Sentences:',
|
'Example Sentences:',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
color: widget.theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
..._exampleSentences,
|
..._exampleSentences,
|
||||||
],
|
],
|
||||||
@@ -1036,22 +1241,23 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
|
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
|
||||||
|
final currentTheme = Theme.of(context);
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
backgroundColor: currentTheme.colorScheme.surfaceContainer,
|
||||||
title: Text(
|
title: Text(
|
||||||
'Details for ${vocab.characters}',
|
'Details for ${vocab.characters}',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(color: currentTheme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
content: _VocabDetailsDialog(vocab: vocab),
|
content: _VocabDetailsDialog(vocab: vocab, theme: currentTheme),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Close',
|
'Close',
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
style: TextStyle(color: currentTheme.colorScheme.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1059,4 +1265,3 @@ void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
|
||||||
import '../models/custom_kanji_item.dart';
|
import '../models/custom_kanji_item.dart';
|
||||||
import '../widgets/options_grid.dart';
|
import '../widgets/options_grid.dart';
|
||||||
import '../widgets/kanji_card.dart';
|
import '../widgets/kanji_card.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../services/tts_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
|
||||||
enum CustomQuizMode {
|
enum CustomQuizMode {
|
||||||
japaneseToEnglish,
|
japaneseToEnglish,
|
||||||
@@ -31,27 +34,37 @@ class CustomQuizScreen extends StatefulWidget {
|
|||||||
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 List<String> _incorrectlyAnsweredItems = [];
|
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,
|
||||||
@@ -59,168 +72,83 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
|
|||||||
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||||
CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
|
CurvedAnimation(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) {
|
if (widget.deck != oldWidget.deck && !widget.isActive) {
|
||||||
setState(() {
|
|
||||||
_shuffledDeck = widget.deck.toList()..shuffle();
|
_shuffledDeck = widget.deck.toList()..shuffle();
|
||||||
_currentIndex = 0;
|
_sessionDeckSize = _shuffledDeck.length;
|
||||||
_answered = false;
|
_nextQuestion();
|
||||||
_correct = null;
|
|
||||||
if (_shuffledDeck.isNotEmpty) {
|
|
||||||
_generateOptions();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (widget.useKanji != oldWidget.useKanji) {
|
if (widget.useKanji != oldWidget.useKanji) {
|
||||||
setState(() {
|
_nextQuestion();
|
||||||
_generateOptions();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void playAudio() {
|
void playAudio() async {
|
||||||
|
final quizState = _quizState;
|
||||||
if (widget.quizMode == CustomQuizMode.listeningComprehension &&
|
if (widget.quizMode == CustomQuizMode.listeningComprehension &&
|
||||||
_currentIndex < _shuffledDeck.length) {
|
quizState.current != null &&
|
||||||
_speak(_shuffledDeck[_currentIndex].characters);
|
_playNarrator) {
|
||||||
|
final ttsService = Provider.of<TtsService>(context, listen: false);
|
||||||
|
await ttsService.speak(quizState.current!.characters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTts() async {
|
|
||||||
_flutterTts = FlutterTts();
|
|
||||||
await _flutterTts.setLanguage("ja-JP");
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_flutterTts.stop();
|
|
||||||
_shakeController.dispose();
|
_shakeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _generateOptions() {
|
void _answer(String option) async {
|
||||||
final currentItem = _shuffledDeck[_currentIndex];
|
final quizState = _quizState;
|
||||||
if (widget.quizMode == CustomQuizMode.listeningComprehension ||
|
final current = quizState.current!;
|
||||||
widget.quizMode == CustomQuizMode.japaneseToEnglish) {
|
final isCorrect = quizState.correctAnswers
|
||||||
_options = [currentItem.meaning];
|
.map((a) => a.toLowerCase().trim())
|
||||||
} else {
|
.contains(option.toLowerCase().trim());
|
||||||
_options = [
|
|
||||||
widget.useKanji && currentItem.kanji != null
|
|
||||||
? currentItem.kanji!
|
|
||||||
: currentItem.characters,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
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) async {
|
setState(() {
|
||||||
final currentItem = _shuffledDeck[_currentIndex];
|
quizState.selectedOption = option;
|
||||||
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
quizState.showResult = true;
|
||||||
? (widget.useKanji && currentItem.kanji != null
|
_isAnswering = true;
|
||||||
? currentItem.kanji!
|
});
|
||||||
: currentItem.characters)
|
|
||||||
: currentItem.meaning;
|
|
||||||
final isCorrect = answer == correctAnswer;
|
|
||||||
|
|
||||||
if (currentItem.useInterval) {
|
if (current.useInterval) {
|
||||||
int currentSrsLevel;
|
_updateSrsLevel(current, isCorrect);
|
||||||
switch (widget.quizMode) {
|
|
||||||
case CustomQuizMode.japaneseToEnglish:
|
|
||||||
currentSrsLevel = currentItem.srsData.japaneseToEnglish;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.englishToJapanese:
|
|
||||||
currentSrsLevel = currentItem.srsData.englishToJapanese;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.listeningComprehension:
|
|
||||||
currentSrsLevel = currentItem.srsData.listeningComprehension;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
if (_incorrectlyAnsweredItems.contains(currentItem.characters)) {
|
|
||||||
_incorrectlyAnsweredItems.remove(currentItem.characters);
|
|
||||||
} else {
|
|
||||||
currentSrsLevel++;
|
|
||||||
}
|
|
||||||
final interval = pow(2, currentSrsLevel).toInt();
|
|
||||||
final newNextReview = DateTime.now().add(Duration(hours: interval));
|
|
||||||
switch (widget.quizMode) {
|
|
||||||
case CustomQuizMode.japaneseToEnglish:
|
|
||||||
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.englishToJapanese:
|
|
||||||
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.listeningComprehension:
|
|
||||||
currentItem.srsData.listeningComprehensionNextReview =
|
|
||||||
newNextReview;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!_incorrectlyAnsweredItems.contains(currentItem.characters)) {
|
|
||||||
_incorrectlyAnsweredItems.add(currentItem.characters);
|
|
||||||
}
|
|
||||||
currentSrsLevel = max(0, currentSrsLevel - 1);
|
|
||||||
final newNextReview = DateTime.now().add(const Duration(hours: 1));
|
|
||||||
switch (widget.quizMode) {
|
|
||||||
case CustomQuizMode.japaneseToEnglish:
|
|
||||||
currentItem.srsData.japaneseToEnglishNextReview = newNextReview;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.englishToJapanese:
|
|
||||||
currentItem.srsData.englishToJapaneseNextReview = newNextReview;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.listeningComprehension:
|
|
||||||
currentItem.srsData.listeningComprehensionNextReview =
|
|
||||||
newNextReview;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (widget.quizMode) {
|
|
||||||
case CustomQuizMode.japaneseToEnglish:
|
|
||||||
currentItem.srsData.japaneseToEnglish = currentSrsLevel;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.englishToJapanese:
|
|
||||||
currentItem.srsData.englishToJapanese = currentSrsLevel;
|
|
||||||
break;
|
|
||||||
case CustomQuizMode.listeningComprehension:
|
|
||||||
currentItem.srsData.listeningComprehension = currentSrsLevel;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
widget.onCardReviewed(currentItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
||||||
? (widget.useKanji && currentItem.kanji != null
|
? (widget.useKanji && current.kanji != null
|
||||||
? currentItem.kanji!
|
? current.kanji!
|
||||||
: currentItem.characters)
|
: current.characters)
|
||||||
: currentItem.meaning;
|
: current.meaning;
|
||||||
|
|
||||||
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 ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error,
|
color: isCorrect
|
||||||
|
? Theme.of(context).colorScheme.secondary
|
||||||
|
: Theme.of(context).colorScheme.error,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -232,76 +160,249 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
if (widget.quizMode == CustomQuizMode.japaneseToEnglish) {
|
quizState.asked += 1;
|
||||||
await _speak(currentItem.characters);
|
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 ||
|
||||||
|
widget.quizMode == CustomQuizMode.englishToJapanese) {
|
||||||
|
await _speak(current.characters);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
} else {
|
} else {
|
||||||
|
quizState.wrongItems.add(current.characters);
|
||||||
|
_shuffledDeck.add(current);
|
||||||
|
_shuffledDeck.shuffle();
|
||||||
|
if (_playIncorrectSound) {
|
||||||
|
await _audioPlayer.play(AssetSource('sfx/incorrect.wav'));
|
||||||
|
}
|
||||||
_shakeController.forward(from: 0);
|
_shakeController.forward(from: 0);
|
||||||
await Future.delayed(const Duration(milliseconds: 900));
|
await Future.delayed(const Duration(milliseconds: 900));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) {
|
||||||
_nextQuestion();
|
_nextQuestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _nextQuestion() {
|
|
||||||
setState(() {
|
|
||||||
_currentIndex++;
|
|
||||||
_answered = false;
|
|
||||||
_correct = null;
|
|
||||||
if (_currentIndex < _shuffledDeck.length) {
|
|
||||||
_generateOptions();
|
|
||||||
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
|
||||||
_speak(_shuffledDeck[_currentIndex].characters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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() {
|
||||||
|
final quizState = _quizState;
|
||||||
|
|
||||||
|
if (_shuffledDeck.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
quizState.current = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quizState.current = _shuffledDeck.removeAt(0);
|
||||||
|
quizState.key = UniqueKey();
|
||||||
|
|
||||||
|
quizState.correctAnswers = [];
|
||||||
|
quizState.options = [];
|
||||||
|
quizState.selectedOption = null;
|
||||||
|
quizState.showResult = false;
|
||||||
|
|
||||||
|
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
|
||||||
|
widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||||
|
quizState.correctAnswers = [quizState.current!.meaning];
|
||||||
|
quizState.options = [quizState.correctAnswers.first];
|
||||||
|
} else {
|
||||||
|
quizState.correctAnswers = [
|
||||||
|
widget.useKanji && quizState.current!.kanji != null
|
||||||
|
? quizState.current!.kanji!
|
||||||
|
: quizState.current!.characters,
|
||||||
|
];
|
||||||
|
quizState.options = [quizState.correctAnswers.first];
|
||||||
|
}
|
||||||
|
|
||||||
|
final otherItems = widget.deck
|
||||||
|
.where((item) => item.characters != quizState.current!.characters)
|
||||||
|
.toList();
|
||||||
|
otherItems.shuffle();
|
||||||
|
|
||||||
|
for (var i = 0; i < min(3, otherItems.length); i++) {
|
||||||
|
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
|
||||||
|
widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||||
|
quizState.options.add(otherItems[i].meaning);
|
||||||
|
} else {
|
||||||
|
quizState.options.add(
|
||||||
|
widget.useKanji && otherItems[i].kanji != null
|
||||||
|
? otherItems[i].kanji!
|
||||||
|
: otherItems[i].characters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (quizState.options.length < 4) {
|
||||||
|
quizState.options.add('---');
|
||||||
|
}
|
||||||
|
quizState.options.shuffle();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isAnswering = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||||
|
_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 || _currentIndex >= _shuffledDeck.length) {
|
final quizState = _quizState;
|
||||||
return Center(child: Text('Review session complete!', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
|
|
||||||
|
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);
|
|
||||||
|
|
||||||
Widget promptWidget;
|
Widget promptWidget;
|
||||||
|
String subtitle = '';
|
||||||
|
|
||||||
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||||
promptWidget = IconButton(
|
promptWidget = 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 if (widget.quizMode == CustomQuizMode.englishToJapanese) {
|
||||||
|
promptWidget = Text(
|
||||||
|
currentItem.meaning,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
|
final promptText = widget.useKanji && currentItem.kanji != null
|
||||||
|
? currentItem.kanji!
|
||||||
|
: currentItem.characters;
|
||||||
promptWidget = GestureDetector(
|
promptWidget = GestureDetector(
|
||||||
onTap: () => _speak(question),
|
onTap: () => _speak(promptText),
|
||||||
child: Text(
|
child: Text(
|
||||||
question,
|
promptText,
|
||||||
style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface),
|
style: TextStyle(
|
||||||
|
fontSize: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
key: quizState.key,
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${quizState.asked} / $_sessionDeckSize',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: 18),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
@@ -312,7 +413,10 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
|
|||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
minHeight: 150,
|
minHeight: 150,
|
||||||
),
|
),
|
||||||
child: KanjiCard(characterWidget: promptWidget, subtitle: ''),
|
child: KanjiCard(
|
||||||
|
characterWidget: promptWidget,
|
||||||
|
subtitle: subtitle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -330,11 +434,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: OptionsGrid(
|
child: OptionsGrid(
|
||||||
options: _options,
|
options: quizState.options,
|
||||||
onSelected: _onOptionSelected,
|
onSelected: _isAnswering ? (option) {} : _onOptionSelected,
|
||||||
correctAnswers: [],
|
selectedOption: quizState.selectedOption,
|
||||||
showResult: false,
|
correctAnswers: quizState.correctAnswers,
|
||||||
isDisabled: false,
|
showResult: quizState.showResult,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Score: ${quizState.score} / ${quizState.asked}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class _QuizState {
|
|||||||
Key key = UniqueKey();
|
Key key = UniqueKey();
|
||||||
String? selectedOption;
|
String? selectedOption;
|
||||||
bool showResult = false;
|
bool showResult = false;
|
||||||
|
Set<int> wrongItems = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@@ -52,8 +53,11 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
final _audioPlayer = AudioPlayer();
|
final _audioPlayer = AudioPlayer();
|
||||||
|
|
||||||
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
||||||
|
final _sessionDecks = <int, List<KanjiItem>>{};
|
||||||
|
final _sessionDeckSizes = <int, int>{};
|
||||||
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
||||||
|
|
||||||
|
bool _playIncorrectSound = true;
|
||||||
bool _playCorrectSound = true;
|
bool _playCorrectSound = true;
|
||||||
bool _apiKeyMissing = false;
|
bool _apiKeyMissing = false;
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -116,6 +121,49 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_apiKeyMissing = false;
|
_apiKeyMissing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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++) {
|
for (var i = 0; i < _tabController.length; i++) {
|
||||||
_nextQuestion(i);
|
_nextQuestion(i);
|
||||||
}
|
}
|
||||||
@@ -162,61 +210,20 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _nextQuestion([int? index]) {
|
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);
|
||||||
|
|
||||||
final quizState = _quizStates[index ?? _tabController.index];
|
if (sessionDeck == null || sessionDeck.isEmpty) {
|
||||||
final mode = _modeForIndex(index ?? _tabController.index);
|
setState(() {
|
||||||
|
quizState.current = null;
|
||||||
_deck.sort((a, b) {
|
_status = 'Quiz complete!';
|
||||||
int getSrsStage(KanjiItem item) {
|
|
||||||
if (mode == QuizMode.reading) {
|
|
||||||
final onyomiStage =
|
|
||||||
item.srsItems['${QuizMode.reading}onyomi']?.srsStage;
|
|
||||||
final kunyomiStage =
|
|
||||||
item.srsItems['${QuizMode.reading}kunyomi']?.srsStage;
|
|
||||||
|
|
||||||
if (onyomiStage != null && kunyomiStage != null) {
|
|
||||||
return min(onyomiStage, kunyomiStage);
|
|
||||||
}
|
|
||||||
return onyomiStage ?? kunyomiStage ?? 0;
|
|
||||||
}
|
|
||||||
return item.srsItems[mode.toString()]?.srsStage ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime getLastAsked(KanjiItem item) {
|
|
||||||
if (mode == QuizMode.reading) {
|
|
||||||
final onyomiLastAsked =
|
|
||||||
item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
|
|
||||||
final kunyomiLastAsked =
|
|
||||||
item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
|
|
||||||
|
|
||||||
if (onyomiLastAsked != null && kunyomiLastAsked != null) {
|
|
||||||
return onyomiLastAsked.isBefore(kunyomiLastAsked)
|
|
||||||
? onyomiLastAsked
|
|
||||||
: kunyomiLastAsked;
|
|
||||||
}
|
|
||||||
return onyomiLastAsked ??
|
|
||||||
kunyomiLastAsked ??
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
}
|
|
||||||
return item.srsItems[mode.toString()]?.lastAsked ??
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
final aStage = getSrsStage(a);
|
|
||||||
final bStage = getSrsStage(b);
|
|
||||||
|
|
||||||
if (aStage != bStage) {
|
|
||||||
return aStage.compareTo(bStage);
|
|
||||||
}
|
|
||||||
|
|
||||||
final aLastAsked = getLastAsked(a);
|
|
||||||
final bLastAsked = getLastAsked(b);
|
|
||||||
|
|
||||||
return aLastAsked.compareTo(bLastAsked);
|
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
quizState.current = _deck.first;
|
quizState.current = sessionDeck.removeAt(0);
|
||||||
quizState.key = UniqueKey();
|
quizState.key = UniqueKey();
|
||||||
|
|
||||||
quizState.correctAnswers = [];
|
quizState.correctAnswers = [];
|
||||||
@@ -265,8 +272,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
])..shuffle();
|
])..shuffle();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Handle other QuizMode cases if necessary, or throw an error
|
|
||||||
// if these modes are not expected in this context.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +289,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||||
final current = quizState.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) {
|
||||||
@@ -301,8 +308,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
readingType: readingType,
|
readingType: readingType,
|
||||||
);
|
);
|
||||||
|
|
||||||
quizState.asked += 1;
|
|
||||||
|
|
||||||
quizState.selectedOption = option;
|
quizState.selectedOption = option;
|
||||||
|
|
||||||
quizState.showResult = true;
|
quizState.showResult = true;
|
||||||
@@ -310,13 +315,22 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
|
quizState.asked += 1;
|
||||||
|
if (!quizState.wrongItems.contains(current.id)) {
|
||||||
quizState.score += 1;
|
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;
|
||||||
@@ -395,6 +409,23 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_loading) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Kanji Quiz'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Kanji→English'),
|
||||||
|
Tab(text: 'English→Kanji'),
|
||||||
|
Tab(text: 'Reading'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Kanji Quiz'),
|
title: const Text('Kanji Quiz'),
|
||||||
@@ -419,6 +450,18 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
final quizState = _quizStates[index];
|
final quizState = _quizStates[index];
|
||||||
final mode = _modeForIndex(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 prompt = '';
|
||||||
String subtitle = '';
|
String subtitle = '';
|
||||||
|
|
||||||
@@ -435,8 +478,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
subtitle = quizState.readingHint;
|
subtitle = quizState.readingHint;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Handle other QuizMode cases if necessary, or throw an error
|
|
||||||
// if these modes are not expected in this context.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,19 +487,28 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
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: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
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)
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -489,10 +539,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
OptionsGrid(
|
OptionsGrid(
|
||||||
options: quizState.options,
|
options: quizState.options,
|
||||||
onSelected: _isAnswering ? (option) {} : _answer,
|
onSelected: _isAnswering ? (option) {} : _answer,
|
||||||
isDisabled: false,
|
showResult: quizState.showResult,
|
||||||
selectedOption: null,
|
selectedOption: quizState.selectedOption,
|
||||||
correctAnswers: [],
|
correctAnswers: quizState.correctAnswers,
|
||||||
showResult: false,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -15,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() {
|
||||||
@@ -39,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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,17 +110,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Play audio for vocabulary',
|
'Play incorrect sound',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
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: Theme.of(context).colorScheme.primary,
|
activeThumbColor: Theme.of(context).colorScheme.primary,
|
||||||
@@ -133,7 +135,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Play sound on correct answer',
|
'Play correct sound',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
@@ -156,6 +158,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
'Theme',
|
'Theme',
|
||||||
|
|||||||
@@ -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()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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,13 +110,17 @@ 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: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ class _QuizState {
|
|||||||
Key key = UniqueKey();
|
Key key = UniqueKey();
|
||||||
String? selectedOption;
|
String? selectedOption;
|
||||||
bool showResult = false;
|
bool showResult = false;
|
||||||
List<VocabularyItem> shuffledDeck = [];
|
Set<int> wrongItems = {};
|
||||||
int currentIndex = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class VocabScreen extends StatefulWidget {
|
class VocabScreen extends StatefulWidget {
|
||||||
@@ -40,13 +39,17 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
bool _isAnswering = 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();
|
||||||
|
|
||||||
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
||||||
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
||||||
|
final _sessionDecks = <int, List<VocabularyItem>>{};
|
||||||
|
final _sessionDeckSizes = <int, int>{};
|
||||||
|
|
||||||
bool _playAudio = true;
|
bool _playIncorrectSound = true;
|
||||||
bool _playCorrectSound = true;
|
bool _playCorrectSound = true;
|
||||||
|
bool _playNarrator = true;
|
||||||
bool _apiKeyMissing = false;
|
bool _apiKeyMissing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,6 +57,9 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 3, vsync: this);
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
_tabController.addListener(() {
|
_tabController.addListener(() {
|
||||||
|
if (_tabController.index == 2 && !_tabController.indexIsChanging) {
|
||||||
|
_playCurrentAudio();
|
||||||
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
});
|
});
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
@@ -69,8 +75,9 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +116,43 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
_apiKeyMissing = false;
|
_apiKeyMissing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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++) {
|
for (var i = 0; i < _tabController.length; i++) {
|
||||||
_nextQuestion(i);
|
_nextQuestion(i);
|
||||||
}
|
}
|
||||||
@@ -142,47 +186,20 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _nextQuestion([int? index]) {
|
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);
|
||||||
|
|
||||||
final quizState = _quizStates[index ?? _tabController.index];
|
if (sessionDeck == null || sessionDeck.isEmpty) {
|
||||||
final mode = _modeForIndex(index ?? _tabController.index);
|
|
||||||
|
|
||||||
List<VocabularyItem> currentDeckForMode = _deck;
|
|
||||||
if (mode == QuizMode.audioToEnglish) {
|
|
||||||
currentDeckForMode = _deck
|
|
||||||
.where((item) => item.pronunciationAudios.isNotEmpty)
|
|
||||||
.toList();
|
|
||||||
if (currentDeckForMode.isEmpty) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = 'No vocabulary with audio found.';
|
|
||||||
quizState.current = null;
|
quizState.current = null;
|
||||||
|
_status = 'Quiz complete!';
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (quizState.shuffledDeck.isEmpty ||
|
|
||||||
quizState.currentIndex >= quizState.shuffledDeck.length) {
|
|
||||||
quizState.shuffledDeck = currentDeckForMode.toList();
|
|
||||||
quizState.shuffledDeck.sort((a, b) {
|
|
||||||
final aSrsItem =
|
|
||||||
a.srsItems[mode.toString()] ??
|
|
||||||
SrsItem(subjectId: a.id, quizMode: mode);
|
|
||||||
final bSrsItem =
|
|
||||||
b.srsItems[mode.toString()] ??
|
|
||||||
SrsItem(subjectId: b.id, quizMode: mode);
|
|
||||||
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
|
|
||||||
if (stageComparison != 0) {
|
|
||||||
return stageComparison;
|
|
||||||
}
|
|
||||||
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
|
|
||||||
});
|
|
||||||
quizState.currentIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
quizState.current = quizState.shuffledDeck[quizState.currentIndex];
|
|
||||||
quizState.currentIndex++;
|
|
||||||
|
|
||||||
|
quizState.current = sessionDeck.removeAt(0);
|
||||||
quizState.key = UniqueKey();
|
quizState.key = UniqueKey();
|
||||||
quizState.correctAnswers = [];
|
quizState.correctAnswers = [];
|
||||||
quizState.options = [];
|
quizState.options = [];
|
||||||
@@ -213,13 +230,17 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isAnswering = false;
|
_isAnswering = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (mode == QuizMode.audioToEnglish) {
|
||||||
|
_playCurrentAudio(playOnLoad: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playCurrentAudio({bool playOnLoad = false}) async {
|
Future<void> _playCurrentAudio({bool playOnLoad = false}) async {
|
||||||
final current = _currentQuizState.current;
|
final current = _currentQuizState.current;
|
||||||
if (current == null || current.pronunciationAudios.isEmpty) return;
|
if (current == null || current.pronunciationAudios.isEmpty) return;
|
||||||
|
|
||||||
if (playOnLoad && !_playAudio) return;
|
if (playOnLoad && !_playNarrator) return;
|
||||||
|
|
||||||
final maleAudios = current.pronunciationAudios.where(
|
final maleAudios = current.pronunciationAudios.where(
|
||||||
(a) => a.gender == 'male',
|
(a) => a.gender == 'male',
|
||||||
@@ -242,6 +263,8 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
|
|
||||||
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
||||||
final current = quizState.current!;
|
final current = quizState.current!;
|
||||||
|
final tabIndex = _tabController.index;
|
||||||
|
final sessionDeck = _sessionDecks[tabIndex]!;
|
||||||
|
|
||||||
final srsKey = mode.toString();
|
final srsKey = mode.toString();
|
||||||
|
|
||||||
@@ -250,16 +273,24 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
final srsItem =
|
final srsItem =
|
||||||
srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
|
srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
|
||||||
|
|
||||||
quizState.asked += 1;
|
|
||||||
quizState.selectedOption = option;
|
quizState.selectedOption = option;
|
||||||
quizState.showResult = true;
|
quizState.showResult = true;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
|
quizState.asked += 1;
|
||||||
|
if (!quizState.wrongItems.contains(current.id)) {
|
||||||
quizState.score += 1;
|
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;
|
||||||
@@ -291,10 +322,9 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
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) {
|
|
||||||
final maleAudios = current.pronunciationAudios.where(
|
final maleAudios = current.pronunciationAudios.where(
|
||||||
(a) => a.gender == 'male',
|
(a) => a.gender == 'male',
|
||||||
);
|
);
|
||||||
@@ -318,8 +348,12 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
_isAnswering = true;
|
_isAnswering = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 900), () {
|
||||||
|
if (mounted) {
|
||||||
_nextQuestion();
|
_nextQuestion();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -353,6 +387,23 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_loading) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Vocabulary Quiz'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Vocab→English'),
|
||||||
|
Tab(text: 'English→Vocab'),
|
||||||
|
Tab(text: 'Listening'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Vocabulary Quiz'),
|
title: const Text('Vocabulary Quiz'),
|
||||||
@@ -376,6 +427,18 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
final quizState = _quizStates[index];
|
final quizState = _quizStates[index];
|
||||||
final mode = _modeForIndex(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;
|
Widget promptWidget;
|
||||||
|
|
||||||
if (quizState.current == null) {
|
if (quizState.current == null) {
|
||||||
@@ -417,19 +480,28 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
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: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
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)
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -455,10 +527,9 @@ class _VocabScreenState extends State<VocabScreen>
|
|||||||
OptionsGrid(
|
OptionsGrid(
|
||||||
options: quizState.options,
|
options: quizState.options,
|
||||||
onSelected: _isAnswering ? (option) {} : _answer,
|
onSelected: _isAnswering ? (option) {} : _answer,
|
||||||
isDisabled: false,
|
showResult: quizState.showResult,
|
||||||
selectedOption: null,
|
selectedOption: quizState.selectedOption,
|
||||||
correctAnswers: [],
|
correctAnswers: quizState.correctAnswers,
|
||||||
showResult: false,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -29,7 +28,9 @@ class CustomDeckRepository {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
class DbConstants {
|
class DbConstants {
|
||||||
static const String settingsTable = 'settings';
|
static const String settingsTable = 'settings';
|
||||||
static const String kanjiTable = 'kanji';
|
static const String kanjiTable = 'kanji';
|
||||||
@@ -24,4 +23,5 @@ class DbConstants {
|
|||||||
static const String readingTypeColumn = 'readingType';
|
static const String readingTypeColumn = 'readingType';
|
||||||
static const String srsStageColumn = 'srsStage';
|
static const String srsStageColumn = 'srsStage';
|
||||||
static const String lastAskedColumn = 'lastAsked';
|
static const String lastAskedColumn = 'lastAsked';
|
||||||
|
static const String disabledColumn = 'disabled';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return openDatabase(
|
return openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 7,
|
version: 8,
|
||||||
onCreate: (db, version) async {
|
onCreate: (db, version) async {
|
||||||
await db.execute(
|
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)''',
|
'''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''',
|
||||||
@@ -41,51 +41,24 @@ class DatabaseHelper {
|
|||||||
'''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''',
|
'''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''',
|
'''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(
|
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)''',
|
'''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(
|
await db.execute(
|
||||||
'''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''',
|
'''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, ${DbConstants.disabledColumn} INTEGER DEFAULT 0, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''',
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onUpgrade: (db, oldVersion, newVersion) async {
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 8) {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''CREATE TABLE IF NOT EXISTS ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''',
|
'ALTER TABLE ${DbConstants.srsItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
|
||||||
);
|
|
||||||
}
|
|
||||||
if (oldVersion < 4) {
|
|
||||||
await db.execute(
|
|
||||||
'''CREATE TABLE IF NOT EXISTS ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (oldVersion < 5) {
|
|
||||||
await db.execute(
|
|
||||||
'''CREATE TABLE IF NOT EXISTS ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT)''',
|
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''CREATE TABLE IF NOT EXISTS ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''',
|
'ALTER TABLE ${DbConstants.srsVocabItemsTable} ADD COLUMN ${DbConstants.disabledColumn} INTEGER DEFAULT 0',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (oldVersion < 6) {
|
|
||||||
try {
|
|
||||||
await db.execute(
|
|
||||||
'ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.pronunciationAudiosColumn} TEXT',
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
// Ignore error, column might already exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (oldVersion < 7) {
|
|
||||||
try {
|
|
||||||
await db.execute('ALTER TABLE ${DbConstants.kanjiTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER');
|
|
||||||
await db.execute('ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER');
|
|
||||||
} catch (_) {
|
|
||||||
// Ignore error, column might already exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ class DeckRepository {
|
|||||||
_apiKey = envApiKey;
|
_apiKey = envApiKey;
|
||||||
return _apiKey;
|
return _apiKey;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
// dotenv is not initialized
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -105,6 +103,7 @@ class DeckRepository {
|
|||||||
readingType: r[DbConstants.readingTypeColumn] as String?,
|
readingType: r[DbConstants.readingTypeColumn] as String?,
|
||||||
srsStage: r[DbConstants.srsStageColumn] as int,
|
srsStage: r[DbConstants.srsStageColumn] as int,
|
||||||
lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String),
|
lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String),
|
||||||
|
disabled: (r[DbConstants.disabledColumn] as int? ?? 0) == 1,
|
||||||
);
|
);
|
||||||
srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem);
|
srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem);
|
||||||
}
|
}
|
||||||
@@ -120,16 +119,55 @@ class DeckRepository {
|
|||||||
return kanjiItems;
|
return kanjiItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateSrsItems(List<SrsItem> items) async {
|
||||||
|
final db = await DatabaseHelper().db;
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final item in items) {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.update(
|
||||||
|
DbConstants.srsItemsTable,
|
||||||
|
{
|
||||||
|
DbConstants.srsStageColumn: item.srsStage,
|
||||||
|
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
||||||
|
DbConstants.disabledColumn: item.disabled ? 1 : 0,
|
||||||
|
},
|
||||||
|
where: where,
|
||||||
|
whereArgs: whereArgs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit(noResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateSrsItem(SrsItem item) async {
|
Future<void> updateSrsItem(SrsItem item) async {
|
||||||
final db = await DatabaseHelper().db;
|
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(
|
||||||
DbConstants.srsItemsTable,
|
DbConstants.srsItemsTable,
|
||||||
{
|
{
|
||||||
DbConstants.srsStageColumn: item.srsStage,
|
DbConstants.srsStageColumn: item.srsStage,
|
||||||
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
||||||
|
DbConstants.disabledColumn: item.disabled ? 1 : 0,
|
||||||
},
|
},
|
||||||
where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?',
|
where: where,
|
||||||
whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType],
|
whereArgs: whereArgs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +179,7 @@ class DeckRepository {
|
|||||||
DbConstants.readingTypeColumn: item.readingType,
|
DbConstants.readingTypeColumn: item.readingType,
|
||||||
DbConstants.srsStageColumn: item.srsStage,
|
DbConstants.srsStageColumn: item.srsStage,
|
||||||
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
|
||||||
|
DbConstants.disabledColumn: item.disabled ? 1 : 0,
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,26 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -39,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) {
|
||||||
@@ -53,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);
|
||||||
@@ -72,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);
|
||||||
}
|
}
|
||||||
@@ -106,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) {
|
||||||
@@ -121,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ class VocabDeckRepository {
|
|||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<String?> loadApiKey() async {
|
Future<String?> loadApiKey() async {
|
||||||
String? envApiKey;
|
String? envApiKey;
|
||||||
try {
|
try {
|
||||||
@@ -68,10 +67,29 @@ class VocabDeckRepository {
|
|||||||
),
|
),
|
||||||
srsStage: r['srsStage'] as int,
|
srsStage: r['srsStage'] as int,
|
||||||
lastAsked: DateTime.parse(r['lastAsked'] as String),
|
lastAsked: DateTime.parse(r['lastAsked'] as String),
|
||||||
|
disabled: (r['disabled'] as int? ?? 0) == 1,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).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 {
|
Future<void> updateVocabSrsItem(SrsItem item) async {
|
||||||
final db = await DatabaseHelper().db;
|
final db = await DatabaseHelper().db;
|
||||||
await db.update(
|
await db.update(
|
||||||
@@ -79,6 +97,7 @@ class VocabDeckRepository {
|
|||||||
{
|
{
|
||||||
'srsStage': item.srsStage,
|
'srsStage': item.srsStage,
|
||||||
'lastAsked': item.lastAsked.toIso8601String(),
|
'lastAsked': item.lastAsked.toIso8601String(),
|
||||||
|
'disabled': item.disabled ? 1 : 0,
|
||||||
},
|
},
|
||||||
where: 'vocabId = ? AND quizMode = ?',
|
where: 'vocabId = ? AND quizMode = ?',
|
||||||
whereArgs: [item.subjectId, item.quizMode.toString()],
|
whereArgs: [item.subjectId, item.quizMode.toString()],
|
||||||
@@ -92,6 +111,7 @@ class VocabDeckRepository {
|
|||||||
'quizMode': item.quizMode.toString(),
|
'quizMode': item.quizMode.toString(),
|
||||||
'srsStage': item.srsStage,
|
'srsStage': item.srsStage,
|
||||||
'lastAsked': item.lastAsked.toIso8601String(),
|
'lastAsked': item.lastAsked.toIso8601String(),
|
||||||
|
'disabled': item.disabled ? 1 : 0,
|
||||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ extension CustomTheme on ThemeData {
|
|||||||
level8: Color(0xFF4FC3F7), // light blue
|
level8: Color(0xFF4FC3F7), // light blue
|
||||||
level9: Color(0xFF7986CB), // indigo
|
level9: Color(0xFF7986CB), // indigo
|
||||||
);
|
);
|
||||||
} else if (colorScheme.primary == const Color(0xFF7B6D53)) { // Nier theme
|
} else if (colorScheme.primary == const Color(0xFF7B6D53)) {
|
||||||
|
// Nier theme
|
||||||
return const SrsColors(
|
return const SrsColors(
|
||||||
level1: Color(0xFFB71C1C), // dark red
|
level1: Color(0xFFB71C1C), // dark red
|
||||||
level2: Color(0xFFD84315), // deep orange
|
level2: Color(0xFFD84315), // deep orange
|
||||||
@@ -50,7 +51,8 @@ extension CustomTheme on ThemeData {
|
|||||||
level8: Color(0xFF0277BD), // light blue
|
level8: Color(0xFF0277BD), // light blue
|
||||||
level9: Color(0xFF283593), // indigo
|
level9: Color(0xFF283593), // indigo
|
||||||
);
|
);
|
||||||
} else { // Light theme
|
} else {
|
||||||
|
// Light theme
|
||||||
return const SrsColors(
|
return const SrsColors(
|
||||||
level1: Colors.red,
|
level1: Colors.red,
|
||||||
level2: Colors.orange,
|
level2: Colors.orange,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -39,20 +39,24 @@ class OptionsGrid extends StatelessWidget {
|
|||||||
Color currentTextColor = fg;
|
Color currentTextColor = fg;
|
||||||
|
|
||||||
if (showResult) {
|
if (showResult) {
|
||||||
if (correctAnswers != null && correctAnswers!.contains(o)) {
|
final normalizedOption = o.trim().toLowerCase();
|
||||||
|
if (correctAnswers != null &&
|
||||||
|
correctAnswers!
|
||||||
|
.map((e) => e.trim().toLowerCase())
|
||||||
|
.contains(normalizedOption)) {
|
||||||
currentButtonColor = theme.colorScheme.tertiary;
|
currentButtonColor = theme.colorScheme.tertiary;
|
||||||
} else if (o == selectedOption) {
|
|
||||||
currentButtonColor = theme.colorScheme.error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 160,
|
width: 160,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: isDisabled ? null : () => onSelected(o),
|
onPressed: isDisabled || o == '---' ? null : () => onSelected(o),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: currentButtonColor,
|
backgroundColor: currentButtonColor,
|
||||||
foregroundColor: currentTextColor,
|
foregroundColor: currentTextColor,
|
||||||
|
disabledBackgroundColor:
|
||||||
|
theme.colorScheme.surfaceContainerHighest,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -60,7 +64,9 @@ class OptionsGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
o,
|
o,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(color: currentTextColor),
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: currentTextColor,
|
||||||
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ flutter_icons:
|
|||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/sfx/confirm.mp3
|
- assets/sfx/correct.wav
|
||||||
|
- assets/sfx/incorrect.wav
|
||||||
Reference in New Issue
Block a user