Compare commits
9 Commits
v1.0.0
...
4a6fce37b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a6fce37b2 | |||
|
|
d8edfa1686 | ||
|
|
cafec12888 | ||
|
|
78fc69aeb4 | ||
| 45d52dbc84 | |||
|
|
d8a5c27fb3 | ||
|
|
ee4fd7ffc1 | ||
|
|
b58a4020e1 | ||
|
|
fe5ac30294 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ app.*.map.json
|
||||
|
||||
*.jks
|
||||
gradle.properties
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
@@ -42,5 +42,8 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.untitled1
|
||||
package com.crylia.hirameki
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'src/services/deck_repository.dart';
|
||||
import 'src/screens/start_screen.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await dotenv.load(fileName: ".env");
|
||||
} catch (e) {
|
||||
// It's okay if the .env file is not found.
|
||||
// This is expected in release builds.
|
||||
}
|
||||
|
||||
runApp(
|
||||
Provider<DeckRepository>(
|
||||
create: (_) => DeckRepository(),
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<DeckRepository>(create: (_) => DeckRepository()),
|
||||
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
|
||||
],
|
||||
child: const WkApp(),
|
||||
),
|
||||
);
|
||||
@@ -26,4 +37,4 @@ class WkApp extends StatelessWidget {
|
||||
home: const StartScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
97
lib/src/models/custom_kanji_item.dart
Normal file
97
lib/src/models/custom_kanji_item.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
|
||||
class CustomKanjiItem {
|
||||
final String characters;
|
||||
final String meaning;
|
||||
final String? kanji;
|
||||
final bool useInterval;
|
||||
SrsData srsData;
|
||||
|
||||
CustomKanjiItem({
|
||||
required this.characters,
|
||||
required this.meaning,
|
||||
this.kanji,
|
||||
this.useInterval = false,
|
||||
SrsData? srsData,
|
||||
}) : srsData = srsData ?? SrsData();
|
||||
|
||||
factory CustomKanjiItem.fromJson(Map<String, dynamic> json) {
|
||||
SrsData srsData;
|
||||
if (json['srsData'] != null) {
|
||||
srsData = SrsData.fromJson(json['srsData']);
|
||||
if (json['nextReview'] != null) {
|
||||
final oldNextReview = DateTime.parse(json['nextReview'] as String);
|
||||
srsData.japaneseToEnglishNextReview ??= oldNextReview;
|
||||
srsData.englishToJapaneseNextReview ??= oldNextReview;
|
||||
srsData.listeningComprehensionNextReview ??= oldNextReview;
|
||||
}
|
||||
} else {
|
||||
DateTime? nextReview = json['nextReview'] != null ? DateTime.parse(json['nextReview'] as String) : null;
|
||||
srsData = SrsData(
|
||||
japaneseToEnglish: json['srsLevel'] as int? ?? 0,
|
||||
japaneseToEnglishNextReview: nextReview,
|
||||
englishToJapanese: json['srsLevel'] as int? ?? 0,
|
||||
englishToJapaneseNextReview: nextReview,
|
||||
listeningComprehension: json['srsLevel'] as int? ?? 0,
|
||||
listeningComprehensionNextReview: nextReview,
|
||||
);
|
||||
}
|
||||
|
||||
return CustomKanjiItem(
|
||||
characters: json['characters'] as String,
|
||||
meaning: json['meaning'] as String,
|
||||
kanji: json['kanji'] as String?,
|
||||
useInterval: json['useInterval'] as bool? ?? false,
|
||||
srsData: srsData,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'characters': characters,
|
||||
'meaning': meaning,
|
||||
'kanji': kanji,
|
||||
'useInterval': useInterval,
|
||||
'srsData': srsData.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SrsData {
|
||||
int japaneseToEnglish;
|
||||
DateTime? japaneseToEnglishNextReview;
|
||||
int englishToJapanese;
|
||||
DateTime? englishToJapaneseNextReview;
|
||||
int listeningComprehension;
|
||||
DateTime? listeningComprehensionNextReview;
|
||||
|
||||
SrsData({
|
||||
this.japaneseToEnglish = 0,
|
||||
this.japaneseToEnglishNextReview,
|
||||
this.englishToJapanese = 0,
|
||||
this.englishToJapaneseNextReview,
|
||||
this.listeningComprehension = 0,
|
||||
this.listeningComprehensionNextReview,
|
||||
});
|
||||
|
||||
factory SrsData.fromJson(Map<String, dynamic> json) {
|
||||
return SrsData(
|
||||
japaneseToEnglish: json['japaneseToEnglish'] as int? ?? 0,
|
||||
japaneseToEnglishNextReview: json['japaneseToEnglishNextReview'] != null ? DateTime.parse(json['japaneseToEnglishNextReview'] as String) : null,
|
||||
englishToJapanese: json['englishToJapanese'] as int? ?? 0,
|
||||
englishToJapaneseNextReview: json['englishToJapaneseNextReview'] != null ? DateTime.parse(json['englishToJapaneseNextReview'] as String) : null,
|
||||
listeningComprehension: json['listeningComprehension'] as int? ?? 0,
|
||||
listeningComprehensionNextReview: json['listeningComprehensionNextReview'] != null ? DateTime.parse(json['listeningComprehensionNextReview'] as String) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'japaneseToEnglish': japaneseToEnglish,
|
||||
'japaneseToEnglishNextReview': japaneseToEnglishNextReview?.toIso8601String(),
|
||||
'englishToJapanese': englishToJapanese,
|
||||
'englishToJapaneseNextReview': englishToJapaneseNextReview?.toIso8601String(),
|
||||
'listeningComprehension': listeningComprehension,
|
||||
'listeningComprehensionNextReview': listeningComprehensionNextReview?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
141
lib/src/screens/add_card_screen.dart
Normal file
141
lib/src/screens/add_card_screen.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:kana_kit/kana_kit.dart';
|
||||
import '../models/custom_kanji_item.dart';
|
||||
import '../services/custom_deck_repository.dart';
|
||||
|
||||
class AddCardScreen extends StatefulWidget {
|
||||
const AddCardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AddCardScreen> createState() => _AddCardScreenState();
|
||||
}
|
||||
|
||||
class _AddCardScreenState extends State<AddCardScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _japaneseController = TextEditingController();
|
||||
final _englishController = TextEditingController();
|
||||
final _kanjiController = TextEditingController();
|
||||
final _kanaKit = const KanaKit();
|
||||
final _deckRepository = CustomDeckRepository();
|
||||
bool _useInterval = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_japaneseController.addListener(_convertToKana);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_japaneseController.removeListener(_convertToKana);
|
||||
_japaneseController.dispose();
|
||||
_englishController.dispose();
|
||||
_kanjiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _convertToKana() {
|
||||
final text = _japaneseController.text;
|
||||
final converted = _kanaKit.toKana(text);
|
||||
if (text != converted) {
|
||||
_japaneseController.value = _japaneseController.value.copyWith(
|
||||
text: converted,
|
||||
selection: TextSelection.fromPosition(
|
||||
TextPosition(offset: converted.length),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _saveCard() {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final srsData = _useInterval
|
||||
? SrsData(
|
||||
japaneseToEnglishNextReview: DateTime.now(),
|
||||
englishToJapaneseNextReview: DateTime.now(),
|
||||
listeningComprehensionNextReview: DateTime.now(),
|
||||
)
|
||||
: SrsData();
|
||||
|
||||
final newItem = CustomKanjiItem(
|
||||
characters: _japaneseController.text,
|
||||
meaning: _englishController.text,
|
||||
kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null,
|
||||
useInterval: _useInterval,
|
||||
srsData: srsData,
|
||||
);
|
||||
_deckRepository.addCard(newItem);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add New Card'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _japaneseController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Japanese (Kana)',
|
||||
hintText: 'Enter Japanese vocabulary or kanji',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a Japanese term';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _kanjiController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Japanese (Kanji)',
|
||||
hintText: 'Enter the kanji (optional)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _englishController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'English',
|
||||
hintText: 'Enter the English meaning',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter an English meaning';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SwitchListTile(
|
||||
title: const Text('Use Interval-based SRS'),
|
||||
value: _useInterval,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_useInterval = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: _saveCard,
|
||||
child: const Text('Save Card'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
128
lib/src/screens/custom_card_details_screen.dart
Normal file
128
lib/src/screens/custom_card_details_screen.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/custom_kanji_item.dart';
|
||||
import '../services/custom_deck_repository.dart';
|
||||
|
||||
class CustomCardDetailsScreen extends StatefulWidget {
|
||||
final CustomKanjiItem item;
|
||||
final CustomDeckRepository repository;
|
||||
|
||||
const CustomCardDetailsScreen(
|
||||
{super.key, required this.item, required this.repository});
|
||||
|
||||
@override
|
||||
State<CustomCardDetailsScreen> createState() =>
|
||||
_CustomCardDetailsScreenState();
|
||||
}
|
||||
|
||||
class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
|
||||
late TextEditingController _japaneseController;
|
||||
late TextEditingController _englishController;
|
||||
late TextEditingController _kanjiController;
|
||||
late bool _useInterval;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_japaneseController = TextEditingController(text: widget.item.characters);
|
||||
_englishController = TextEditingController(text: widget.item.meaning);
|
||||
_kanjiController = TextEditingController(text: widget.item.kanji);
|
||||
_useInterval = widget.item.useInterval;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_japaneseController.dispose();
|
||||
_englishController.dispose();
|
||||
_kanjiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _saveChanges() {
|
||||
final updatedItem = CustomKanjiItem(
|
||||
characters: _japaneseController.text,
|
||||
meaning: _englishController.text,
|
||||
kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null,
|
||||
useInterval: _useInterval,
|
||||
srsData: widget.item.srsData,
|
||||
);
|
||||
widget.repository.updateCard(updatedItem);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
void _deleteCard() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Card'),
|
||||
content: const Text('Are you sure you want to delete this card?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.repository.deleteCard(widget.item);
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Edit Card'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: _deleteCard,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _japaneseController,
|
||||
decoration: const InputDecoration(labelText: 'Japanese (Kana)'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _kanjiController,
|
||||
decoration: const InputDecoration(labelText: 'Japanese (Kanji)'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _englishController,
|
||||
decoration: const InputDecoration(labelText: 'English'),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Use Interval SRS'),
|
||||
value: _useInterval,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_useInterval = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('SRS Levels', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('Jpn→Eng: ${widget.item.srsData.japaneseToEnglish} (Next review: ${widget.item.srsData.japaneseToEnglishNextReview?.toString() ?? 'N/A'})'),
|
||||
Text('Eng→Jpn: ${widget.item.srsData.englishToJapanese} (Next review: ${widget.item.srsData.englishToJapaneseNextReview?.toString() ?? 'N/A'})'),
|
||||
Text('Listening: ${widget.item.srsData.listeningComprehension} (Next review: ${widget.item.srsData.listeningComprehensionNextReview?.toString() ?? 'N/A'})'),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save Changes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
334
lib/src/screens/custom_quiz_screen.dart
Normal file
334
lib/src/screens/custom_quiz_screen.dart
Normal file
@@ -0,0 +1,334 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import '../models/custom_kanji_item.dart';
|
||||
import '../widgets/options_grid.dart';
|
||||
import '../widgets/kanji_card.dart';
|
||||
|
||||
enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension }
|
||||
|
||||
class CustomQuizScreen extends StatefulWidget {
|
||||
final List<CustomKanjiItem> deck;
|
||||
final CustomQuizMode quizMode;
|
||||
final Function(CustomKanjiItem) onCardReviewed;
|
||||
final bool useKanji;
|
||||
final bool isActive;
|
||||
|
||||
const CustomQuizScreen({
|
||||
super.key,
|
||||
required this.deck,
|
||||
required this.quizMode,
|
||||
required this.onCardReviewed,
|
||||
required this.useKanji,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomQuizScreen> createState() => CustomQuizScreenState();
|
||||
}
|
||||
|
||||
class CustomQuizScreenState extends State<CustomQuizScreen>
|
||||
with TickerProviderStateMixin {
|
||||
int _currentIndex = 0;
|
||||
List<CustomKanjiItem> _shuffledDeck = [];
|
||||
List<String> _options = [];
|
||||
bool _answered = false;
|
||||
bool? _correct;
|
||||
late FlutterTts _flutterTts;
|
||||
late AnimationController _shakeController;
|
||||
late Animation<double> _shakeAnimation;
|
||||
final List<String> _incorrectlyAnsweredItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shuffledDeck = widget.deck.toList()..shuffle();
|
||||
_initTts();
|
||||
if (_shuffledDeck.isNotEmpty) {
|
||||
_generateOptions();
|
||||
}
|
||||
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _shakeController,
|
||||
curve: Curves.elasticIn,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CustomQuizScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.deck != oldWidget.deck && !widget.isActive) {
|
||||
setState(() {
|
||||
_shuffledDeck = widget.deck.toList()..shuffle();
|
||||
_currentIndex = 0;
|
||||
_answered = false;
|
||||
_correct = null;
|
||||
if (_shuffledDeck.isNotEmpty) {
|
||||
_generateOptions();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (widget.useKanji != oldWidget.useKanji) {
|
||||
setState(() {
|
||||
_generateOptions();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void playAudio() {
|
||||
if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) {
|
||||
_speak(_shuffledDeck[_currentIndex].characters);
|
||||
}
|
||||
}
|
||||
|
||||
void _initTts() async {
|
||||
_flutterTts = FlutterTts();
|
||||
await _flutterTts.setLanguage("ja-JP");
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flutterTts.stop();
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _generateOptions() {
|
||||
final currentItem = _shuffledDeck[_currentIndex];
|
||||
if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) {
|
||||
_options = [currentItem.meaning];
|
||||
} else {
|
||||
_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 {
|
||||
final currentItem = _shuffledDeck[_currentIndex];
|
||||
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
||||
? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
|
||||
: currentItem.meaning;
|
||||
final isCorrect = answer == correctAnswer;
|
||||
|
||||
if (currentItem.useInterval) {
|
||||
int currentSrsLevel;
|
||||
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);
|
||||
}
|
||||
|
||||
// --- SnackBar Logic (new) ---
|
||||
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
||||
? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
|
||||
: currentItem.meaning;
|
||||
|
||||
final snack = SnackBar(
|
||||
content: Text(
|
||||
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
|
||||
style: TextStyle(
|
||||
color: isCorrect ? Colors.greenAccent : Colors.redAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFF222222),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(snack);
|
||||
}
|
||||
// --- End SnackBar Logic ---
|
||||
|
||||
if (isCorrect) {
|
||||
if (widget.quizMode == CustomQuizMode.japaneseToEnglish) {
|
||||
await _speak(currentItem.characters);
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // Small delay after correct answer
|
||||
} else {
|
||||
_shakeController.forward(from: 0);
|
||||
await Future.delayed(const Duration(milliseconds: 900)); // Delay for shake animation
|
||||
}
|
||||
|
||||
_nextQuestion();
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
_answered = false;
|
||||
_correct = null;
|
||||
if (_currentIndex < _shuffledDeck.length) {
|
||||
_generateOptions();
|
||||
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||
_speak(_shuffledDeck[_currentIndex].characters);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _speak(String text) async {
|
||||
await _flutterTts.speak(text);
|
||||
}
|
||||
|
||||
void _onOptionSelected(String option) {
|
||||
if (!(_answered && _correct!)) {
|
||||
_checkAnswer(option);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) {
|
||||
return const Center(
|
||||
child: Text('Review session complete!'),
|
||||
);
|
||||
}
|
||||
|
||||
final currentItem = _shuffledDeck[_currentIndex];
|
||||
final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
|
||||
? currentItem.meaning
|
||||
: (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
|
||||
|
||||
Widget promptWidget;
|
||||
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
|
||||
promptWidget = IconButton(
|
||||
icon: const Icon(Icons.volume_up, size: 64),
|
||||
onPressed: () => _speak(currentItem.characters),
|
||||
);
|
||||
} else {
|
||||
promptWidget = GestureDetector(
|
||||
onTap: () => _speak(question),
|
||||
child: Text(
|
||||
question,
|
||||
style: const TextStyle(fontSize: 48),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characterWidget: promptWidget,
|
||||
subtitle: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _shakeAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(_shakeAnimation.value * 10, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: OptionsGrid(
|
||||
options: _options,
|
||||
onSelected: _onOptionSelected,
|
||||
correctAnswers: [],
|
||||
showResult: false,
|
||||
isDisabled: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
lib/src/screens/custom_srs_screen.dart
Normal file
149
lib/src/screens/custom_srs_screen.dart
Normal file
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/custom_kanji_item.dart';
|
||||
import '../services/custom_deck_repository.dart';
|
||||
import 'custom_quiz_screen.dart';
|
||||
|
||||
class CustomSrsScreen extends StatefulWidget {
|
||||
const CustomSrsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CustomSrsScreen> createState() => _CustomSrsScreenState();
|
||||
}
|
||||
|
||||
class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final _deckRepository = CustomDeckRepository();
|
||||
List<CustomKanjiItem> _deck = [];
|
||||
bool _useKanji = false;
|
||||
final _quizScreenKeys = [
|
||||
GlobalKey<CustomQuizScreenState>(),
|
||||
GlobalKey<CustomQuizScreenState>(),
|
||||
GlobalKey<CustomQuizScreenState>(),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
final key = _quizScreenKeys[_tabController.index];
|
||||
key.currentState?.playAudio();
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
_loadDeck();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadDeck() async {
|
||||
final deck = await _deckRepository.getCustomDeck();
|
||||
setState(() {
|
||||
_deck = deck;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _updateCard(CustomKanjiItem item) async {
|
||||
final index = _deck.indexWhere((element) => element.characters == item.characters);
|
||||
if (index != -1) {
|
||||
setState(() {
|
||||
_deck[index] = item;
|
||||
});
|
||||
await _deckRepository.saveDeck(_deck);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
final jpnToEngReviewDeck = _deck.where((item) {
|
||||
if (!item.useInterval) return true;
|
||||
return item.srsData.japaneseToEnglishNextReview == null ||
|
||||
item.srsData.japaneseToEnglishNextReview!.isBefore(now);
|
||||
}).toList();
|
||||
|
||||
final engToJpnReviewDeck = _deck.where((item) {
|
||||
if (!item.useInterval) return true;
|
||||
return item.srsData.englishToJapaneseNextReview == null ||
|
||||
item.srsData.englishToJapaneseNextReview!.isBefore(now);
|
||||
}).toList();
|
||||
|
||||
final listeningReviewDeck = _deck.where((item) {
|
||||
if (!item.useInterval) return true;
|
||||
return item.srsData.listeningComprehensionNextReview == null ||
|
||||
item.srsData.listeningComprehensionNextReview!.isBefore(now);
|
||||
}).toList();
|
||||
|
||||
final allDecksEmpty = jpnToEngReviewDeck.isEmpty &&
|
||||
engToJpnReviewDeck.isEmpty &&
|
||||
listeningReviewDeck.isEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Custom SRS'),
|
||||
actions: [
|
||||
if (_tabController.index != 2)
|
||||
Row(
|
||||
children: [
|
||||
const Text('Kanji'),
|
||||
Switch(
|
||||
value: _useKanji,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_useKanji = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Jpn→Eng'),
|
||||
Tab(text: 'Eng→Jpn'),
|
||||
Tab(text: 'Listening'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: _deck.isEmpty
|
||||
? const Center(child: Text('Add cards to start quizzing!'))
|
||||
: allDecksEmpty
|
||||
? const Center(child: Text('No cards due for review.'))
|
||||
: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
CustomQuizScreen(
|
||||
key: _quizScreenKeys[0],
|
||||
deck: jpnToEngReviewDeck,
|
||||
quizMode: CustomQuizMode.japaneseToEnglish,
|
||||
onCardReviewed: _updateCard,
|
||||
useKanji: _useKanji,
|
||||
isActive: _tabController.index == 0,
|
||||
),
|
||||
CustomQuizScreen(
|
||||
key: _quizScreenKeys[1],
|
||||
deck: engToJpnReviewDeck,
|
||||
quizMode: CustomQuizMode.englishToJapanese,
|
||||
onCardReviewed: _updateCard,
|
||||
useKanji: _useKanji,
|
||||
isActive: _tabController.index == 1,
|
||||
),
|
||||
CustomQuizScreen(
|
||||
key: _quizScreenKeys[2],
|
||||
deck: listeningReviewDeck,
|
||||
quizMode: CustomQuizMode.listeningComprehension,
|
||||
onCardReviewed: _updateCard,
|
||||
useKanji: _useKanji,
|
||||
isActive: _tabController.index == 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,18 @@ class _ReadingInfo {
|
||||
_ReadingInfo(this.correctReadings, this.hint);
|
||||
}
|
||||
|
||||
class _QuizState {
|
||||
KanjiItem? current;
|
||||
List<String> options = [];
|
||||
List<String> correctAnswers = [];
|
||||
String readingHint = '';
|
||||
int score = 0;
|
||||
int asked = 0;
|
||||
Key key = UniqueKey();
|
||||
String? selectedOption;
|
||||
bool showResult = false;
|
||||
}
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key, this.distractorGenerator});
|
||||
|
||||
@@ -27,31 +39,43 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<KanjiItem> _deck = [];
|
||||
bool _loading = false;
|
||||
bool _isAnswering = false;
|
||||
String _status = 'Loading deck...';
|
||||
late final DistractorGenerator _dg;
|
||||
final Random _random = Random();
|
||||
final _audioPlayer = AudioPlayer();
|
||||
|
||||
QuizMode _mode = QuizMode.kanjiToEnglish;
|
||||
KanjiItem? _current;
|
||||
List<String> _options = [];
|
||||
List<String> _correctAnswers = [];
|
||||
String _readingHint = '';
|
||||
int _score = 0;
|
||||
int _asked = 0;
|
||||
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
||||
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
||||
|
||||
bool _playCorrectSound = true;
|
||||
bool _apiKeyMissing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
_nextQuestion();
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
_dg = widget.distractorGenerator ?? DistractorGenerator();
|
||||
_loadSettings();
|
||||
_loadDeck();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
@@ -71,11 +95,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final apiKey = repo.apiKey;
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_apiKeyMissing = true;
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,9 +114,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_deck = items;
|
||||
_status = 'Loaded ${items.length} kanji';
|
||||
_loading = false;
|
||||
_apiKeyMissing = false;
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
for (var i = 0; i < _tabController.length; i++) {
|
||||
_nextQuestion(i);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
@@ -123,127 +149,166 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
return _ReadingInfo(readingsList, hint);
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
QuizMode _modeForIndex(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return QuizMode.kanjiToEnglish;
|
||||
case 1:
|
||||
return QuizMode.englishToKanji;
|
||||
case 2:
|
||||
return QuizMode.reading;
|
||||
default:
|
||||
return QuizMode.kanjiToEnglish;
|
||||
}
|
||||
}
|
||||
|
||||
void _nextQuestion([int? index]) {
|
||||
if (_deck.isEmpty) return;
|
||||
|
||||
final quizState = _quizStates[index ?? _tabController.index];
|
||||
final mode = _modeForIndex(index ?? _tabController.index);
|
||||
|
||||
_deck.sort((a, b) {
|
||||
String srsKey(KanjiItem item) {
|
||||
var key = _mode.toString();
|
||||
if (_mode == QuizMode.reading) {
|
||||
if (item.onyomi.isNotEmpty && item.kunyomi.isNotEmpty) {
|
||||
key += _random.nextBool() ? 'onyomi' : 'kunyomi';
|
||||
} else if (item.onyomi.isNotEmpty) {
|
||||
key += 'onyomi';
|
||||
} else {
|
||||
key += 'kunyomi';
|
||||
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 key;
|
||||
return item.srsItems[mode.toString()]?.srsStage ?? 0;
|
||||
}
|
||||
|
||||
final aSrsItem = a.srsItems[srsKey(a)];
|
||||
final bSrsItem = b.srsItems[srsKey(b)];
|
||||
DateTime getLastAsked(KanjiItem item) {
|
||||
if (mode == QuizMode.reading) {
|
||||
final onyomiLastAsked = item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
|
||||
final kunyomiLastAsked = item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
|
||||
|
||||
final aStage = aSrsItem?.srsStage ?? 0;
|
||||
final bStage = bSrsItem?.srsStage ?? 0;
|
||||
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 =
|
||||
aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bLastAsked =
|
||||
bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final aLastAsked = getLastAsked(a);
|
||||
final bLastAsked = getLastAsked(b);
|
||||
|
||||
if (aLastAsked != bLastAsked) {
|
||||
return aLastAsked.compareTo(bLastAsked);
|
||||
}
|
||||
|
||||
return _random.nextDouble().compareTo(_random.nextDouble());
|
||||
return aLastAsked.compareTo(bLastAsked);
|
||||
});
|
||||
|
||||
_current = _deck.first;
|
||||
quizState.current = _deck.first;
|
||||
quizState.key = UniqueKey();
|
||||
|
||||
_correctAnswers = [];
|
||||
_options = [];
|
||||
_readingHint = '';
|
||||
quizState.correctAnswers = [];
|
||||
quizState.options = [];
|
||||
quizState.readingHint = '';
|
||||
quizState.selectedOption = null;
|
||||
quizState.showResult = false;
|
||||
|
||||
switch (_mode) {
|
||||
switch (mode) {
|
||||
case QuizMode.kanjiToEnglish:
|
||||
_correctAnswers = [_current!.meanings.first];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateMeanings(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.meanings.first];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateMeanings(quizState.current!, _deck, 3)
|
||||
].map(_toTitleCase).toList()
|
||||
..shuffle();
|
||||
break;
|
||||
|
||||
case QuizMode.englishToKanji:
|
||||
_correctAnswers = [_current!.characters];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateKanji(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.characters];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateKanji(quizState.current!, _deck, 3)
|
||||
]..shuffle();
|
||||
break;
|
||||
|
||||
case QuizMode.reading:
|
||||
final info = _pickReading(_current!);
|
||||
_correctAnswers = info.correctReadings;
|
||||
_readingHint = info.hint;
|
||||
final info = _pickReading(quizState.current!);
|
||||
quizState.correctAnswers = info.correctReadings;
|
||||
quizState.readingHint = info.hint;
|
||||
|
||||
final readingsSource = _readingHint.contains("on'yomi")
|
||||
final readingsSource = quizState.readingHint.contains("on'yomi")
|
||||
? _deck.expand((k) => k.onyomi)
|
||||
: _deck.expand((k) => k.kunyomi);
|
||||
|
||||
final distractors = readingsSource
|
||||
.where((r) => !_correctAnswers.contains(r))
|
||||
.where((r) => !quizState.correctAnswers.contains(r))
|
||||
.toSet()
|
||||
.toList()
|
||||
..shuffle();
|
||||
_options = ([
|
||||
_correctAnswers[_random.nextInt(_correctAnswers.length)],
|
||||
..shuffle();
|
||||
quizState.options = ([
|
||||
quizState.correctAnswers[_random.nextInt(quizState.correctAnswers.length)],
|
||||
...distractors.take(3)
|
||||
])
|
||||
..shuffle();
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_isAnswering = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _answer(String option) async {
|
||||
final isCorrect = _correctAnswers
|
||||
final quizState = _currentQuizState;
|
||||
final mode = _modeForIndex(_tabController.index);
|
||||
final isCorrect = quizState.correctAnswers
|
||||
.map((a) => a.toLowerCase().trim())
|
||||
.contains(option.toLowerCase().trim());
|
||||
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
final current = _current!;
|
||||
final current = quizState.current!;
|
||||
|
||||
String readingType = '';
|
||||
if (_mode == QuizMode.reading) {
|
||||
readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi';
|
||||
if (mode == QuizMode.reading) {
|
||||
readingType = quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi';
|
||||
}
|
||||
final srsKey = _mode.toString() + readingType;
|
||||
final srsKey = mode.toString() + readingType;
|
||||
|
||||
var srsItem = current.srsItems[srsKey];
|
||||
final isNew = srsItem == null;
|
||||
final srsItemForUpdate = srsItem ??=
|
||||
SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType);
|
||||
setState(() {
|
||||
_asked += 1;
|
||||
if (isCorrect) {
|
||||
_score += 1;
|
||||
srsItemForUpdate.srsStage += 1;
|
||||
if (_playCorrectSound) {
|
||||
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
||||
}
|
||||
} else {
|
||||
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
|
||||
SrsItem(kanjiId: current.id, quizMode: mode, readingType: readingType);
|
||||
|
||||
quizState.asked += 1;
|
||||
|
||||
quizState.selectedOption = option;
|
||||
|
||||
quizState.showResult = true;
|
||||
|
||||
setState(() {}); // Trigger UI rebuild to show selected/correct colors
|
||||
|
||||
|
||||
|
||||
if (isCorrect) {
|
||||
quizState.score += 1;
|
||||
srsItemForUpdate.srsStage += 1;
|
||||
if (_playCorrectSound) {
|
||||
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
||||
}
|
||||
srsItemForUpdate.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItemForUpdate;
|
||||
});
|
||||
} else {
|
||||
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
|
||||
}
|
||||
srsItemForUpdate.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItemForUpdate;
|
||||
|
||||
if (isNew) {
|
||||
await repo.insertSrsItem(srsItemForUpdate);
|
||||
@@ -251,11 +316,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
await repo.updateSrsItem(srsItemForUpdate);
|
||||
}
|
||||
|
||||
final correctDisplay = (_mode == QuizMode.kanjiToEnglish)
|
||||
? _toTitleCase(_correctAnswers.first)
|
||||
: (_mode == QuizMode.reading
|
||||
? _correctAnswers.join(', ')
|
||||
: _correctAnswers.first);
|
||||
final correctDisplay = (mode == QuizMode.kanjiToEnglish)
|
||||
? _toTitleCase(quizState.correctAnswers.first)
|
||||
: (mode == QuizMode.reading
|
||||
? quizState.correctAnswers.join(', ')
|
||||
: quizState.correctAnswers.first);
|
||||
|
||||
final snack = SnackBar(
|
||||
content: Text(
|
||||
@@ -272,131 +337,144 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(snack);
|
||||
}
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
|
||||
setState(() {
|
||||
_isAnswering = true; // Disable input after showing result
|
||||
});
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 900), () => _nextQuestion());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String prompt = '';
|
||||
String subtitle = '';
|
||||
|
||||
switch (_mode) {
|
||||
case QuizMode.kanjiToEnglish:
|
||||
prompt = _current?.characters ?? '';
|
||||
break;
|
||||
case QuizMode.englishToKanji:
|
||||
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : '';
|
||||
break;
|
||||
case QuizMode.reading:
|
||||
prompt = _current?.characters ?? '';
|
||||
subtitle = _readingHint;
|
||||
break;
|
||||
if (_apiKeyMissing) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Kanji Quiz')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadDeck();
|
||||
},
|
||||
child: const Text('Go to Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
appBar: AppBar(
|
||||
title: const Text('Hirameki SRS - Kanji'),
|
||||
backgroundColor: const Color(0xFF1F1F1F),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 2,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadSettings();
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_buildChoiceChip('Kanji→English', QuizMode.kanjiToEnglish),
|
||||
_buildChoiceChip('English→Kanji', QuizMode.englishToKanji),
|
||||
_buildChoiceChip('Reading', QuizMode.reading),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characters: prompt,
|
||||
subtitle: subtitle,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: _options,
|
||||
onSelected: _answer,
|
||||
buttonColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: $_score / $_asked',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: const Text('Kanji Quiz'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Kanji→English'),
|
||||
Tab(text: 'English→Kanji'),
|
||||
Tab(text: 'Reading'),
|
||||
],
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildQuizPage(0),
|
||||
_buildQuizPage(1),
|
||||
_buildQuizPage(2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _buildChoiceChip(String label, QuizMode mode) {
|
||||
final selected = _mode == mode;
|
||||
return ChoiceChip(
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
|
||||
Widget _buildQuizPage(int index) {
|
||||
final quizState = _quizStates[index];
|
||||
final mode = _modeForIndex(index);
|
||||
|
||||
String prompt = '';
|
||||
String subtitle = '';
|
||||
|
||||
if (quizState.current != null) {
|
||||
switch (mode) {
|
||||
case QuizMode.kanjiToEnglish:
|
||||
prompt = quizState.current!.characters;
|
||||
break;
|
||||
case QuizMode.englishToKanji:
|
||||
prompt = _toTitleCase(quizState.current!.meanings.first);
|
||||
break;
|
||||
case QuizMode.reading:
|
||||
prompt = quizState.current!.characters;
|
||||
subtitle = quizState.readingHint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
key: quizState.key,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characters: prompt,
|
||||
subtitle: subtitle,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: quizState.options,
|
||||
onSelected: _isAnswering ? (option) {} : _answer,
|
||||
isDisabled: false,
|
||||
selectedOption: null,
|
||||
correctAnswers: [],
|
||||
showResult: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: ${quizState.score} / ${quizState.asked}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
selected: selected,
|
||||
onSelected: (v) {
|
||||
setState(() => _mode = mode);
|
||||
_nextQuestion();
|
||||
},
|
||||
selectedColor: Colors.blueAccent,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/deck_repository.dart';
|
||||
import 'package:hirameki_srs/src/screens/settings_screen.dart';
|
||||
import 'browse_screen.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'vocab_screen.dart';
|
||||
import 'custom_srs_screen.dart';
|
||||
|
||||
class StartScreen extends StatefulWidget {
|
||||
class StartScreen extends StatelessWidget {
|
||||
const StartScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StartScreen> createState() => _StartScreenState();
|
||||
}
|
||||
|
||||
class _StartScreenState extends State<StartScreen> {
|
||||
bool _loading = true;
|
||||
bool _hasApiKey = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkApiKey();
|
||||
}
|
||||
|
||||
Future<void> _checkApiKey() async {
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
await repo.loadApiKey();
|
||||
|
||||
setState(() {
|
||||
_hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Color(0xFF121212),
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: Colors.blueAccent),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
body: Center(
|
||||
appBar: AppBar(
|
||||
title: const Text('Hirameki SRS'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFF121212),
|
||||
Colors.grey[900]!,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
padding: const EdgeInsets.all(16),
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.8,
|
||||
children: [
|
||||
_buildModeCard(
|
||||
context,
|
||||
title: 'Kanji Quiz',
|
||||
icon: Icons.extension,
|
||||
description: 'Test your knowledge of kanji characters.',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildModeCard(
|
||||
context,
|
||||
title: 'Vocabulary Quiz',
|
||||
icon: Icons.school,
|
||||
description: 'Practice vocabulary from your WaniKani deck.',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const VocabScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildModeCard(
|
||||
context,
|
||||
title: 'Browse Items',
|
||||
icon: Icons.grid_view,
|
||||
description: 'Look through your kanji and vocabulary decks.',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const BrowseScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildModeCard(
|
||||
context,
|
||||
title: 'Custom SRS',
|
||||
icon: Icons.create,
|
||||
description: 'Create and study your own custom flashcards.',
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const CustomSrsScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeCard(BuildContext context, {
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required String description,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to Hirameki SRS!',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontSize: 28, color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_hasApiKey
|
||||
? 'Your API key is set. You can start the quiz!'
|
||||
: 'Before you start, please set up your WaniKani API key in the settings.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: Colors.grey[300]),
|
||||
title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => HomeScreen()),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text(
|
||||
'Kanji Quiz',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const VocabScreen()),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text(
|
||||
'Vocabulary Quiz',
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const BrowseScreen()),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.deepPurpleAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text(
|
||||
'Browse Items',
|
||||
style: TextStyle(fontSize: 18),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
description,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -136,4 +131,4 @@ class _StartScreenState extends State<StartScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/kanji_item.dart';
|
||||
import '../services/deck_repository.dart';
|
||||
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
|
||||
import '../services/distractor_generator.dart';
|
||||
import '../widgets/kanji_card.dart';
|
||||
import '../widgets/options_grid.dart';
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class _QuizState {
|
||||
VocabularyItem? current;
|
||||
List<String> options = [];
|
||||
List<String> correctAnswers = [];
|
||||
int score = 0;
|
||||
int asked = 0;
|
||||
Key key = UniqueKey();
|
||||
String? selectedOption;
|
||||
bool showResult = false;
|
||||
List<VocabularyItem> shuffledDeck = [];
|
||||
int currentIndex = 0;
|
||||
}
|
||||
|
||||
class VocabScreen extends StatefulWidget {
|
||||
const VocabScreen({super.key});
|
||||
|
||||
@@ -18,29 +31,42 @@ class VocabScreen extends StatefulWidget {
|
||||
State<VocabScreen> createState() => _VocabScreenState();
|
||||
}
|
||||
|
||||
class _VocabScreenState extends State<VocabScreen> {
|
||||
class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<VocabularyItem> _deck = [];
|
||||
bool _loading = false;
|
||||
bool _isAnswering = false;
|
||||
String _status = 'Loading deck...';
|
||||
final DistractorGenerator _dg = DistractorGenerator();
|
||||
final _audioPlayer = AudioPlayer();
|
||||
|
||||
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
|
||||
VocabularyItem? _current;
|
||||
List<String> _options = [];
|
||||
List<String> _correctAnswers = [];
|
||||
int _score = 0;
|
||||
int _asked = 0;
|
||||
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
||||
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
||||
|
||||
bool _playAudio = true;
|
||||
bool _playCorrectSound = true;
|
||||
bool _apiKeyMissing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
_nextQuestion();
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
_loadSettings();
|
||||
_loadDeck();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
@@ -56,16 +82,15 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
||||
await repo.loadApiKey();
|
||||
final apiKey = repo.apiKey;
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_apiKeyMissing = true;
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,9 +107,12 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
_deck = items;
|
||||
_status = 'Loaded ${items.length} vocabulary';
|
||||
_loading = false;
|
||||
_apiKeyMissing = false;
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
for (var i = 0; i < _tabController.length; i++) {
|
||||
_nextQuestion(i);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
@@ -101,70 +129,97 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
VocabQuizMode _modeForIndex(int index) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
return VocabQuizMode.vocabToEnglish;
|
||||
case 1:
|
||||
return VocabQuizMode.englishToVocab;
|
||||
case 2:
|
||||
return VocabQuizMode.audioToEnglish;
|
||||
default:
|
||||
return VocabQuizMode.vocabToEnglish;
|
||||
}
|
||||
}
|
||||
|
||||
void _nextQuestion([int? index]) {
|
||||
if (_deck.isEmpty) return;
|
||||
|
||||
List<VocabularyItem> deck = _deck;
|
||||
if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
deck = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
|
||||
if (deck.isEmpty) {
|
||||
final quizState = _quizStates[index ?? _tabController.index];
|
||||
final mode = _modeForIndex(index ?? _tabController.index);
|
||||
|
||||
List<VocabularyItem> currentDeckForMode = _deck;
|
||||
if (mode == VocabQuizMode.audioToEnglish) {
|
||||
currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList();
|
||||
if (currentDeckForMode.isEmpty) {
|
||||
setState(() {
|
||||
_status = 'No vocabulary with audio found.';
|
||||
_current = null;
|
||||
quizState.current = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
deck.sort((a, b) {
|
||||
final aSrsItem = a.srsItems[_mode.toString()] ??
|
||||
VocabSrsItem(vocabId: a.id, quizMode: _mode);
|
||||
final bSrsItem = b.srsItems[_mode.toString()] ??
|
||||
VocabSrsItem(vocabId: b.id, quizMode: _mode);
|
||||
// If it's a new session or we've gone through all shuffled items, re-shuffle
|
||||
if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) {
|
||||
quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy
|
||||
// Apply sorting based on SRS stages here, but only once per shuffle
|
||||
quizState.shuffledDeck.sort((a, b) {
|
||||
final aSrsItem = a.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: a.id, quizMode: mode);
|
||||
final bSrsItem = b.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: 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;
|
||||
}
|
||||
|
||||
final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
|
||||
if (stageComparison != 0) {
|
||||
return stageComparison;
|
||||
}
|
||||
return aSrsItem.lastAsked.compareTo(bSrsItem.lastAsked);
|
||||
});
|
||||
quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck
|
||||
quizState.currentIndex++; // Advance index
|
||||
|
||||
_current = deck.first;
|
||||
if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
quizState.key = UniqueKey();
|
||||
if (mode == VocabQuizMode.audioToEnglish) {
|
||||
_playCurrentAudio();
|
||||
}
|
||||
|
||||
_correctAnswers = [];
|
||||
_options = [];
|
||||
quizState.correctAnswers = [];
|
||||
quizState.options = [];
|
||||
quizState.selectedOption = null;
|
||||
quizState.showResult = false;
|
||||
|
||||
switch (_mode) {
|
||||
switch (mode) {
|
||||
case VocabQuizMode.vocabToEnglish:
|
||||
case VocabQuizMode.audioToEnglish:
|
||||
_correctAnswers = [_current!.meanings.first];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateVocabMeanings(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.meanings.first];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateVocabMeanings(quizState.current!, _deck, 3)
|
||||
].map(_toTitleCase).toList()
|
||||
..shuffle();
|
||||
break;
|
||||
|
||||
case VocabQuizMode.englishToVocab:
|
||||
_correctAnswers = [_current!.characters];
|
||||
_options = [
|
||||
_correctAnswers.first,
|
||||
..._dg.generateVocab(_current!, _deck, 3)
|
||||
quizState.correctAnswers = [quizState.current!.characters];
|
||||
quizState.options = [
|
||||
quizState.correctAnswers.first,
|
||||
..._dg.generateVocab(quizState.current!, _deck, 3)
|
||||
]..shuffle();
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
setState(() {
|
||||
_isAnswering = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _playCurrentAudio() async {
|
||||
if (_current == null || _current!.pronunciationAudios.isEmpty) return;
|
||||
final current = _currentQuizState.current;
|
||||
if (current == null || current.pronunciationAudios.isEmpty) return;
|
||||
|
||||
final maleAudios = _current!.pronunciationAudios.where((a) => a.gender == 'male');
|
||||
final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : _current!.pronunciationAudios.first.url);
|
||||
final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male');
|
||||
final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url);
|
||||
|
||||
try {
|
||||
await _audioPlayer.play(UrlSource(audioUrl));
|
||||
@@ -174,31 +229,35 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
}
|
||||
|
||||
void _answer(String option) async {
|
||||
final isCorrect = _correctAnswers
|
||||
final quizState = _currentQuizState;
|
||||
final mode = _modeForIndex(_tabController.index);
|
||||
final isCorrect = quizState.correctAnswers
|
||||
.map((a) => a.toLowerCase().trim())
|
||||
.contains(option.toLowerCase().trim());
|
||||
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
final current = _current!;
|
||||
final repo = Provider.of<VocabDeckRepository>(context, listen: false);
|
||||
final current = quizState.current!;
|
||||
|
||||
final srsKey = _mode.toString();
|
||||
final srsKey = mode.toString();
|
||||
|
||||
var srsItemNullable = current.srsItems[srsKey];
|
||||
final isNew = srsItemNullable == null;
|
||||
final srsItem =
|
||||
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: _mode);
|
||||
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode);
|
||||
|
||||
setState(() {
|
||||
_asked += 1;
|
||||
if (isCorrect) {
|
||||
_score += 1;
|
||||
srsItem.srsStage += 1;
|
||||
} else {
|
||||
srsItem.srsStage = max(0, srsItem.srsStage - 1);
|
||||
}
|
||||
srsItem.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItem;
|
||||
});
|
||||
quizState.asked += 1;
|
||||
quizState.selectedOption = option;
|
||||
quizState.showResult = true;
|
||||
setState(() {}); // Trigger UI rebuild to show selected/correct colors
|
||||
|
||||
if (isCorrect) {
|
||||
quizState.score += 1;
|
||||
srsItem.srsStage += 1;
|
||||
} else {
|
||||
srsItem.srsStage = max(0, srsItem.srsStage - 1);
|
||||
}
|
||||
srsItem.lastAsked = DateTime.now();
|
||||
current.srsItems[srsKey] = srsItem;
|
||||
|
||||
if (isNew) {
|
||||
await repo.insertVocabSrsItem(srsItem);
|
||||
@@ -206,9 +265,9 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
await repo.updateVocabSrsItem(srsItem);
|
||||
}
|
||||
|
||||
final correctDisplay = (_mode == VocabQuizMode.vocabToEnglish)
|
||||
? _toTitleCase(_correctAnswers.first)
|
||||
: _correctAnswers.first;
|
||||
final correctDisplay = (mode == VocabQuizMode.vocabToEnglish)
|
||||
? _toTitleCase(quizState.correctAnswers.first)
|
||||
: quizState.correctAnswers.first;
|
||||
|
||||
final snack = SnackBar(
|
||||
content: Text(
|
||||
@@ -229,7 +288,7 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
if (_playCorrectSound) {
|
||||
await _audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
||||
}
|
||||
if (_playAudio && _mode != VocabQuizMode.audioToEnglish) {
|
||||
if (_playAudio && mode != VocabQuizMode.audioToEnglish) {
|
||||
final maleAudios =
|
||||
current.pronunciationAudios.where((a) => a.gender == 'male');
|
||||
if (maleAudios.isNotEmpty) {
|
||||
@@ -249,31 +308,86 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
// No fixed delay for incorrect answers
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isAnswering = true; // Disable input after showing result
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_apiKeyMissing) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Vocabulary Quiz')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('WaniKani API key is not set.'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadDeck();
|
||||
},
|
||||
child: const Text('Go to Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildQuizPage(0),
|
||||
_buildQuizPage(1),
|
||||
_buildQuizPage(2),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuizPage(int index) {
|
||||
final quizState = _quizStates[index];
|
||||
final mode = _modeForIndex(index);
|
||||
|
||||
Widget promptWidget;
|
||||
|
||||
if (_current == null) {
|
||||
if (quizState.current == null) {
|
||||
promptWidget = const SizedBox.shrink();
|
||||
} else if (_mode == VocabQuizMode.audioToEnglish) {
|
||||
} else if (mode == VocabQuizMode.audioToEnglish) {
|
||||
promptWidget = IconButton(
|
||||
icon: const Icon(Icons.volume_up, color: Colors.white, size: 64),
|
||||
onPressed: _playCurrentAudio,
|
||||
);
|
||||
} else {
|
||||
String promptText = '';
|
||||
switch (_mode) {
|
||||
switch (mode) {
|
||||
case VocabQuizMode.vocabToEnglish:
|
||||
promptText = _current?.characters ?? '';
|
||||
promptText = quizState.current!.characters;
|
||||
break;
|
||||
case VocabQuizMode.englishToVocab:
|
||||
promptText = _current != null ? _toTitleCase(_current!.meanings.first) : '';
|
||||
promptText = _toTitleCase(quizState.current!.meanings.first);
|
||||
break;
|
||||
case VocabQuizMode.audioToEnglish:
|
||||
// Handled above
|
||||
@@ -282,111 +396,62 @@ class _VocabScreenState extends State<VocabScreen> {
|
||||
promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
appBar: AppBar(
|
||||
title: const Text('Hirameki SRS - Vocab'),
|
||||
backgroundColor: const Color(0xFF1F1F1F),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 2,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
_loadSettings();
|
||||
},
|
||||
)
|
||||
return Padding(
|
||||
key: quizState.key,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characterWidget: promptWidget,
|
||||
subtitle: '',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: quizState.options,
|
||||
onSelected: _isAnswering ? (option) {} : _answer,
|
||||
isDisabled: false,
|
||||
selectedOption: null,
|
||||
correctAnswers: [],
|
||||
showResult: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: ${quizState.score} / ${quizState.asked}',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_buildChoiceChip('Vocab→English', VocabQuizMode.vocabToEnglish),
|
||||
_buildChoiceChip('English→Vocab', VocabQuizMode.englishToVocab),
|
||||
_buildChoiceChip('Listening', VocabQuizMode.audioToEnglish),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characterWidget: promptWidget,
|
||||
subtitle: '',
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: _options,
|
||||
onSelected: _answer,
|
||||
buttonColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: $_score / $_asked',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
ChoiceChip _buildChoiceChip(String label, VocabQuizMode mode) {
|
||||
final selected = _mode == mode;
|
||||
return ChoiceChip(
|
||||
label: Text(
|
||||
label,
|
||||
style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
|
||||
),
|
||||
selected: selected,
|
||||
onSelected: (v) {
|
||||
setState(() => _mode = mode);
|
||||
_nextQuestion();
|
||||
},
|
||||
selectedColor: Colors.blueAccent,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
lib/src/services/custom_deck_repository.dart
Normal file
56
lib/src/services/custom_deck_repository.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/custom_kanji_item.dart';
|
||||
|
||||
class CustomDeckRepository {
|
||||
static const _key = 'custom_deck';
|
||||
|
||||
Future<List<CustomKanjiItem>> getCustomDeck() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_key);
|
||||
if (jsonString != null) {
|
||||
final List<dynamic> jsonList = json.decode(jsonString);
|
||||
return jsonList.map((json) => CustomKanjiItem.fromJson(json)).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<void> addCard(CustomKanjiItem item) async {
|
||||
final deck = await getCustomDeck();
|
||||
deck.add(item);
|
||||
await saveDeck(deck);
|
||||
}
|
||||
|
||||
Future<void> updateCard(CustomKanjiItem item) async {
|
||||
final deck = await getCustomDeck();
|
||||
final index = deck.indexWhere((element) => element.characters == item.characters);
|
||||
if (index != -1) {
|
||||
deck[index] = item;
|
||||
await saveDeck(deck);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async {
|
||||
final deck = await getCustomDeck();
|
||||
for (var item in itemsToUpdate) {
|
||||
final index = deck.indexWhere((element) => element.characters == item.characters);
|
||||
if (index != -1) {
|
||||
deck[index] = item;
|
||||
}
|
||||
}
|
||||
await saveDeck(deck);
|
||||
}
|
||||
|
||||
Future<void> deleteCard(CustomKanjiItem item) async {
|
||||
final deck = await getCustomDeck();
|
||||
deck.removeWhere((element) => element.characters == item.characters);
|
||||
await saveDeck(deck);
|
||||
}
|
||||
|
||||
Future<void> saveDeck(List<CustomKanjiItem> deck) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = deck.map((item) => item.toJson()).toList();
|
||||
await prefs.setString(_key, json.encode(jsonList));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/kanji_item.dart';
|
||||
import '../api/wk_client.dart';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
class DeckRepository {
|
||||
Database? _db;
|
||||
String? _apiKey;
|
||||
@@ -98,6 +99,20 @@ class DeckRepository {
|
||||
}
|
||||
|
||||
Future<String?> loadApiKey() async {
|
||||
String? envApiKey;
|
||||
try {
|
||||
envApiKey = dotenv.env['WANIKANI_API_KEY'];
|
||||
} catch (e) {
|
||||
// dotenv is not initialized, so we can't get the key.
|
||||
// This is expected in release builds.
|
||||
envApiKey = null;
|
||||
}
|
||||
|
||||
if (envApiKey != null && envApiKey.isNotEmpty) {
|
||||
_apiKey = envApiKey;
|
||||
return _apiKey;
|
||||
}
|
||||
|
||||
final db = await _openDb();
|
||||
final rows = await db.query(
|
||||
'settings',
|
||||
@@ -247,154 +262,5 @@ class DeckRepository {
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async {
|
||||
final db = await _openDb();
|
||||
final rows = await db.query(
|
||||
'srs_vocab_items',
|
||||
where: 'vocabId = ?',
|
||||
whereArgs: [vocabId],
|
||||
);
|
||||
return rows.map((r) {
|
||||
return VocabSrsItem(
|
||||
vocabId: r['vocabId'] as int,
|
||||
quizMode: VocabQuizMode.values.firstWhere(
|
||||
(e) => e.toString() == r['quizMode'] as String,
|
||||
),
|
||||
srsStage: r['srsStage'] as int,
|
||||
lastAsked: DateTime.parse(r['lastAsked'] as String),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> updateVocabSrsItem(VocabSrsItem item) async {
|
||||
final db = await _openDb();
|
||||
await db.update(
|
||||
'srs_vocab_items',
|
||||
{
|
||||
'srsStage': item.srsStage,
|
||||
'lastAsked': item.lastAsked.toIso8601String(),
|
||||
},
|
||||
where: 'vocabId = ? AND quizMode = ?',
|
||||
whereArgs: [item.vocabId, item.quizMode.toString()],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertVocabSrsItem(VocabSrsItem item) async {
|
||||
final db = await _openDb();
|
||||
await db.insert('srs_vocab_items', {
|
||||
'vocabId': item.vocabId,
|
||||
'quizMode': item.quizMode.toString(),
|
||||
'srsStage': item.srsStage,
|
||||
'lastAsked': item.lastAsked.toIso8601String(),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> saveVocabulary(List<VocabularyItem> items) async {
|
||||
final db = await _openDb();
|
||||
final batch = db.batch();
|
||||
for (final it in items) {
|
||||
final audios = it.pronunciationAudios
|
||||
.map((a) => {'url': a.url, 'gender': a.gender})
|
||||
.toList();
|
||||
batch.insert('vocabulary', {
|
||||
'id': it.id,
|
||||
'level': it.level,
|
||||
'characters': it.characters,
|
||||
'meanings': it.meanings.join('|'),
|
||||
'readings': it.readings.join('|'),
|
||||
'pronunciation_audios': jsonEncode(audios),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<VocabularyItem>> loadVocabulary() async {
|
||||
final db = await _openDb();
|
||||
final rows = await db.query('vocabulary');
|
||||
final vocabItems = rows.map((r) {
|
||||
final audiosRaw = r['pronunciation_audios'] as String?;
|
||||
final List<PronunciationAudio> audios = [];
|
||||
if (audiosRaw != null && audiosRaw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(audiosRaw) as List;
|
||||
for (final audioData in decoded) {
|
||||
audios.add(
|
||||
PronunciationAudio(
|
||||
url: audioData['url'] as String,
|
||||
gender: audioData['gender'] as String,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Error decoding, so we'll just have no audio for this item
|
||||
}
|
||||
}
|
||||
return VocabularyItem(
|
||||
id: r['id'] as int,
|
||||
level: r['level'] as int? ?? 0,
|
||||
characters: r['characters'] as String,
|
||||
meanings: (r['meanings'] as String)
|
||||
.split('|')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(),
|
||||
readings: (r['readings'] as String)
|
||||
.split('|')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(),
|
||||
pronunciationAudios: audios,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
for (final item in vocabItems) {
|
||||
final srsItems = await getVocabSrsItems(item.id);
|
||||
for (final srsItem in srsItems) {
|
||||
final key = srsItem.quizMode.toString();
|
||||
item.srsItems[key] = srsItem;
|
||||
}
|
||||
}
|
||||
|
||||
return vocabItems;
|
||||
}
|
||||
|
||||
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([
|
||||
String? apiKey,
|
||||
]) async {
|
||||
final key = apiKey ?? _apiKey;
|
||||
if (key == null) throw Exception('API key not set');
|
||||
|
||||
final client = WkClient(key);
|
||||
final assignments = await client.fetchAllAssignments(
|
||||
subjectTypes: ['vocabulary'],
|
||||
);
|
||||
|
||||
final unlocked = <int>{};
|
||||
for (final a in assignments) {
|
||||
final data = a['data'] as Map<String, dynamic>;
|
||||
final sidRaw = data['subject_id'];
|
||||
if (sidRaw == null) continue;
|
||||
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
|
||||
if (sid == null) continue;
|
||||
final started = data['started_at'];
|
||||
final srs = data['srs_stage'];
|
||||
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
|
||||
if (isUnlocked) unlocked.add(sid);
|
||||
}
|
||||
|
||||
if (unlocked.isEmpty) return [];
|
||||
|
||||
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
|
||||
final items = subjects
|
||||
.where(
|
||||
(s) =>
|
||||
s['object'] == 'vocabulary' ||
|
||||
(s['data'] != null &&
|
||||
(s['data'] as Map)['object_type'] == 'vocabulary'),
|
||||
)
|
||||
.map((s) => VocabularyItem.fromSubject(s))
|
||||
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
await saveVocabulary(items);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
}
|
||||
280
lib/src/services/vocab_deck_repository.dart
Normal file
280
lib/src/services/vocab_deck_repository.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/kanji_item.dart';
|
||||
import '../api/wk_client.dart';
|
||||
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
class VocabDeckRepository {
|
||||
Database? _db;
|
||||
String? _apiKey;
|
||||
|
||||
Future<void> setApiKey(String apiKey) async {
|
||||
_apiKey = apiKey;
|
||||
await saveApiKey(apiKey);
|
||||
}
|
||||
|
||||
String? get apiKey => _apiKey;
|
||||
|
||||
Future<Database> _openDb() async {
|
||||
if (_db != null) return _db!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = join(dir.path, 'wanikani_srs.db');
|
||||
|
||||
_db = await openDatabase(
|
||||
path,
|
||||
version: 7,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute(
|
||||
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
|
||||
);
|
||||
await db.execute(
|
||||
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
|
||||
);
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
await db.execute(
|
||||
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
// Migration from version 2 to 3 was flawed, so we just drop the columns if they exist
|
||||
}
|
||||
if (oldVersion < 4) {
|
||||
await db.execute(
|
||||
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
|
||||
);
|
||||
// We are not migrating the old srs data, as it was not mode-specific.
|
||||
// Old columns will be dropped.
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
await db.execute(
|
||||
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
|
||||
);
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
try {
|
||||
await db.execute(
|
||||
'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT',
|
||||
);
|
||||
} catch (_) {
|
||||
// Ignore error, column might already exist
|
||||
}
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
try {
|
||||
await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER');
|
||||
await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER');
|
||||
} catch (_) {
|
||||
// Ignore error, column might already exist
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<void> saveApiKey(String apiKey) async {
|
||||
final db = await _openDb();
|
||||
await db.insert('settings', {
|
||||
'key': 'apiKey',
|
||||
'value': apiKey,
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<String?> loadApiKey() async {
|
||||
String? envApiKey;
|
||||
try {
|
||||
envApiKey = dotenv.env['WANIKANI_API_KEY'];
|
||||
} catch (e) {
|
||||
// dotenv is not initialized, so we can't get the key.
|
||||
// This is expected in release builds.
|
||||
envApiKey = null;
|
||||
}
|
||||
|
||||
if (envApiKey != null && envApiKey.isNotEmpty) {
|
||||
_apiKey = envApiKey;
|
||||
return _apiKey;
|
||||
}
|
||||
|
||||
final db = await _openDb();
|
||||
final rows = await db.query(
|
||||
'settings',
|
||||
where: 'key = ?',
|
||||
whereArgs: ['apiKey'],
|
||||
);
|
||||
if (rows.isNotEmpty) {
|
||||
_apiKey = rows.first['value'] as String;
|
||||
return _apiKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async {
|
||||
final db = await _openDb();
|
||||
final rows = await db.query(
|
||||
'srs_vocab_items',
|
||||
where: 'vocabId = ?',
|
||||
whereArgs: [vocabId],
|
||||
);
|
||||
return rows.map((r) {
|
||||
return VocabSrsItem(
|
||||
vocabId: r['vocabId'] as int,
|
||||
quizMode: VocabQuizMode.values.firstWhere(
|
||||
(e) => e.toString() == r['quizMode'] as String,
|
||||
),
|
||||
srsStage: r['srsStage'] as int,
|
||||
lastAsked: DateTime.parse(r['lastAsked'] as String),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> updateVocabSrsItem(VocabSrsItem item) async {
|
||||
final db = await _openDb();
|
||||
await db.update(
|
||||
'srs_vocab_items',
|
||||
{
|
||||
'srsStage': item.srsStage,
|
||||
'lastAsked': item.lastAsked.toIso8601String(),
|
||||
},
|
||||
where: 'vocabId = ? AND quizMode = ?',
|
||||
whereArgs: [item.vocabId, item.quizMode.toString()],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> insertVocabSrsItem(VocabSrsItem item) async {
|
||||
final db = await _openDb();
|
||||
await db.insert('srs_vocab_items', {
|
||||
'vocabId': item.vocabId,
|
||||
'quizMode': item.quizMode.toString(),
|
||||
'srsStage': item.srsStage,
|
||||
'lastAsked': item.lastAsked.toIso8601String(),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
|
||||
Future<void> saveVocabulary(List<VocabularyItem> items) async {
|
||||
final db = await _openDb();
|
||||
final batch = db.batch();
|
||||
for (final it in items) {
|
||||
final audios = it.pronunciationAudios
|
||||
.map((a) => {'url': a.url, 'gender': a.gender})
|
||||
.toList();
|
||||
batch.insert('vocabulary', {
|
||||
'id': it.id,
|
||||
'level': it.level,
|
||||
'characters': it.characters,
|
||||
'meanings': it.meanings.join('|'),
|
||||
'readings': it.readings.join('|'),
|
||||
'pronunciation_audios': jsonEncode(audios),
|
||||
}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<VocabularyItem>> loadVocabulary() async {
|
||||
final db = await _openDb();
|
||||
final rows = await db.query('vocabulary');
|
||||
final vocabItems = rows.map((r) {
|
||||
final audiosRaw = r['pronunciation_audios'] as String?;
|
||||
final List<PronunciationAudio> audios = [];
|
||||
if (audiosRaw != null && audiosRaw.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(audiosRaw) as List;
|
||||
for (final audioData in decoded) {
|
||||
audios.add(
|
||||
PronunciationAudio(
|
||||
url: audioData['url'] as String,
|
||||
gender: audioData['gender'] as String,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Error decoding, so we'll just have no audio for this item
|
||||
}
|
||||
}
|
||||
return VocabularyItem(
|
||||
id: r['id'] as int,
|
||||
level: r['level'] as int? ?? 0,
|
||||
characters: r['characters'] as String,
|
||||
meanings: (r['meanings'] as String)
|
||||
.split('|')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(),
|
||||
readings: (r['readings'] as String)
|
||||
.split('|')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList(),
|
||||
pronunciationAudios: audios,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
for (final item in vocabItems) {
|
||||
final srsItems = await getVocabSrsItems(item.id);
|
||||
for (final srsItem in srsItems) {
|
||||
final key = srsItem.quizMode.toString();
|
||||
item.srsItems[key] = srsItem;
|
||||
}
|
||||
}
|
||||
|
||||
return vocabItems;
|
||||
}
|
||||
|
||||
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([
|
||||
String? apiKey,
|
||||
]) async {
|
||||
final key = apiKey ?? _apiKey;
|
||||
if (key == null) throw Exception('API key not set');
|
||||
|
||||
final client = WkClient(key);
|
||||
final assignments = await client.fetchAllAssignments(
|
||||
subjectTypes: ['vocabulary'],
|
||||
);
|
||||
|
||||
final unlocked = <int>{};
|
||||
for (final a in assignments) {
|
||||
final data = a['data'] as Map<String, dynamic>;
|
||||
final sidRaw = data['subject_id'];
|
||||
if (sidRaw == null) continue;
|
||||
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
|
||||
if (sid == null) continue;
|
||||
final started = data['started_at'];
|
||||
final srs = data['srs_stage'];
|
||||
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
|
||||
if (isUnlocked) unlocked.add(sid);
|
||||
}
|
||||
|
||||
if (unlocked.isEmpty) return [];
|
||||
|
||||
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
|
||||
final items = subjects
|
||||
.where(
|
||||
(s) =>
|
||||
s['object'] == 'vocabulary' ||
|
||||
(s['data'] != null &&
|
||||
(s['data'] as Map)['object_type'] == 'vocabulary'),
|
||||
)
|
||||
.map((s) => VocabularyItem.fromSubject(s))
|
||||
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
await saveVocabulary(items);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ class OptionsGrid extends StatelessWidget {
|
||||
final void Function(String) onSelected;
|
||||
final Color? buttonColor;
|
||||
final Color? textColor;
|
||||
final bool isDisabled;
|
||||
final String? selectedOption;
|
||||
final List<String>? correctAnswers;
|
||||
final bool showResult;
|
||||
|
||||
const OptionsGrid({
|
||||
super.key,
|
||||
@@ -12,6 +16,10 @@ class OptionsGrid extends StatelessWidget {
|
||||
required this.onSelected,
|
||||
this.buttonColor,
|
||||
this.textColor,
|
||||
this.isDisabled = false,
|
||||
this.selectedOption,
|
||||
this.correctAnswers,
|
||||
this.showResult = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -27,13 +35,24 @@ class OptionsGrid extends StatelessWidget {
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
children: options.map((o) {
|
||||
Color currentButtonColor = bg;
|
||||
Color currentTextColor = fg;
|
||||
|
||||
if (showResult) {
|
||||
if (correctAnswers != null && correctAnswers!.contains(o)) {
|
||||
currentButtonColor = Colors.green;
|
||||
} else if (o == selectedOption) {
|
||||
currentButtonColor = Colors.red;
|
||||
}
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 160,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => onSelected(o),
|
||||
onPressed: isDisabled ? null : () => onSelected(o),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: bg,
|
||||
foregroundColor: fg,
|
||||
backgroundColor: currentButtonColor,
|
||||
foregroundColor: currentTextColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -41,7 +60,7 @@ class OptionsGrid extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
o,
|
||||
style: TextStyle(fontSize: 20, color: fg),
|
||||
style: TextStyle(fontSize: 20, color: currentTextColor),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -49,4 +68,4 @@ class OptionsGrid extends StatelessWidget {
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
pubspec.lock
32
pubspec.lock
@@ -185,6 +185,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
checks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checks
|
||||
sha256: "016871c84732c1ac9856b8940236d5a5802ba638b3bd3e0ea7027b51a35f7aa7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -294,6 +302,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_dotenv:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_dotenv
|
||||
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -315,6 +331,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_tts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_tts
|
||||
sha256: cbb3fd43b946e62398560235469e6113e4fe26c40eab1b7cb5e7c417503fb3a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.5"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -400,6 +424,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
kana_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: kana_kit
|
||||
sha256: "4e99cfddae947971c327ef3d8d82d35cf036c046c7f460583785d48c0f777fa3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
17
pubspec.yaml
17
pubspec.yaml
@@ -8,12 +8,15 @@ dependencies:
|
||||
audioplayers: any
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: any
|
||||
path: any
|
||||
path_provider: any
|
||||
provider: any
|
||||
shared_preferences: any
|
||||
sqflite: any
|
||||
shared_preferences: ^2.5.3
|
||||
sqflite: ^2.4.2
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
provider: ^6.1.5
|
||||
http: ^1.5.0
|
||||
kana_kit: ^2.1.1
|
||||
flutter_tts: ^3.8.5
|
||||
flutter_dotenv: ^5.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -31,4 +34,4 @@ flutter_icons:
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/sfx/confirm.mp3
|
||||
- assets/sfx/confirm.mp3
|
||||
Reference in New Issue
Block a user