add custom srs

This commit is contained in:
Rene Kievits
2025-10-30 02:00:29 +01:00
parent fe5ac30294
commit b58a4020e1
12 changed files with 802 additions and 250 deletions

View File

@@ -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>

View File

@@ -0,0 +1,28 @@
class CustomKanjiItem {
final String characters;
final String meaning;
final String? kanji;
CustomKanjiItem({
required this.characters,
required this.meaning,
this.kanji,
});
factory CustomKanjiItem.fromJson(Map<String, dynamic> json) {
return CustomKanjiItem(
characters: json['characters'] as String,
meaning: json['meaning'] as String,
kanji: json['kanji'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'characters': characters,
'meaning': meaning,
'kanji': kanji,
};
}
}

View File

@@ -0,0 +1,120 @@
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();
@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 newItem = CustomKanjiItem(
characters: _japaneseController.text,
meaning: _englishController.text,
kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null,
);
_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: 32),
ElevatedButton(
onPressed: _saveCard,
child: const Text('Save Card'),
),
],
),
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/kanji_item.dart';
import '../services/deck_repository.dart';
import 'settings_screen.dart';
class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key});
@@ -10,8 +11,7 @@ class BrowseScreen extends StatefulWidget {
State<BrowseScreen> createState() => _BrowseScreenState();
}
class _BrowseScreenState extends State<BrowseScreen>
with SingleTickerProviderStateMixin {
class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
late PageController _kanjiPageController;
late PageController _vocabPageController;
@@ -27,6 +27,7 @@ class _BrowseScreenState extends State<BrowseScreen>
String _status = 'Loading...';
int _currentKanjiPage = 0;
int _currentVocabPage = 0;
bool _apiKeyMissing = false;
@override
void initState() {
@@ -58,6 +59,14 @@ class _BrowseScreenState extends State<BrowseScreen>
_loadDecks();
}
@override
void dispose() {
_tabController.dispose();
_kanjiPageController.dispose();
_vocabPageController.dispose();
super.dispose();
}
Future<void> _loadDecks() async {
setState(() => _loading = true);
try {
@@ -67,7 +76,7 @@ class _BrowseScreenState extends State<BrowseScreen>
if (apiKey == null || apiKey.isEmpty) {
setState(() {
_status = 'API key not set.';
_apiKeyMissing = true;
_loading = false;
});
return;
@@ -93,6 +102,7 @@ class _BrowseScreenState extends State<BrowseScreen>
_loading = false;
_status =
'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
_apiKeyMissing = false;
});
} catch (e) {
setState(() {
@@ -122,23 +132,42 @@ class _BrowseScreenState extends State<BrowseScreen>
@override
Widget build(BuildContext context) {
if (_apiKeyMissing) {
return Scaffold(
appBar: AppBar(title: const Text('Browse')),
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()),
);
_loadDecks();
},
child: const Text('Go to Settings'),
),
],
),
),
);
}
return Scaffold(
backgroundColor: const Color(0xFF121212),
appBar: AppBar(
title: const Text('Browse Items'),
backgroundColor: const Color(0xFF1F1F1F),
foregroundColor: Colors.white,
title: const Text('Browse'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Kanji'),
Tab(text: 'Vocabulary'),
],
labelColor: Colors.blueAccent,
unselectedLabelColor: Colors.grey,
indicatorColor: Colors.blueAccent,
),
),
backgroundColor: const Color(0xFF121212),
body: _loading
? Center(
child: Column(
@@ -438,12 +467,4 @@ class _BrowseScreenState extends State<BrowseScreen>
},
);
}
@override
void dispose() {
_tabController.dispose();
_kanjiPageController.dispose();
_vocabPageController.dispose();
super.dispose();
}
}
}

View File

@@ -0,0 +1,229 @@
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';
enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension }
class CustomQuizScreen extends StatefulWidget {
final List<CustomKanjiItem> deck;
final CustomQuizMode quizMode;
const CustomQuizScreen(
{super.key, required this.deck, required this.quizMode});
@override
State<CustomQuizScreen> createState() => CustomQuizScreenState();
}
class CustomQuizScreenState extends State<CustomQuizScreen>
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
int _currentIndex = 0;
List<CustomKanjiItem> _shuffledDeck = [];
List<String> _options = [];
bool _answered = false;
bool? _correct;
late FlutterTts _flutterTts;
late AnimationController _shakeController;
late Animation<double> _shakeAnimation;
bool _useKanji = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_shuffledDeck = widget.deck.toList()..shuffle();
_initTts();
_generateOptions();
_shakeController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _shakeController,
curve: Curves.elasticIn,
),
);
}
void playAudio() {
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
}
void _initTts() async {
_flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP");
if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters);
}
}
@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 = [_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(_useKanji && otherItems[i].kanji != null ? otherItems[i].kanji! : otherItems[i].characters);
}
}
_options.shuffle();
}
void _checkAnswer(String answer) {
final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
: currentItem.meaning;
final isCorrect = answer == correctAnswer;
setState(() {
_answered = true;
_correct = isCorrect;
});
if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(currentItem.characters);
}
} else {
_shakeController.forward(from: 0);
}
}
void _nextQuestion() {
setState(() {
_currentIndex = (_currentIndex + 1) % _shuffledDeck.length;
_answered = false;
_correct = null;
_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) {
super.build(context);
if (_shuffledDeck.isEmpty) {
return Scaffold(
appBar: AppBar(),
body: const Center(
child: Text('No cards in the deck!'),
),
);
}
final currentItem = _shuffledDeck[_currentIndex];
final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning
: (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
return Scaffold(
appBar: AppBar(
title: const Text('Quiz'),
actions: [
if (widget.quizMode != CustomQuizMode.listeningComprehension)
Row(
children: [
const Text('Kanji'),
Switch(
value: _useKanji,
onChanged: (value) {
setState(() {
_useKanji = value;
_generateOptions();
});
},
),
],
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.quizMode == CustomQuizMode.listeningComprehension)
IconButton(
icon: const Icon(Icons.volume_up, size: 64),
onPressed: () => _speak(currentItem.characters),
)
else
GestureDetector(
onTap: () => _speak(question),
child: Text(
question,
style: const TextStyle(fontSize: 48),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 32),
if (_answered)
Text(
_correct! ? 'Correct!' : 'Incorrect, try again!',
style: TextStyle(
fontSize: 24,
color: _correct! ? Colors.green : Colors.red,
),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _shakeAnimation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_shakeAnimation.value * 10, 0),
child: child,
);
},
child: OptionsGrid(
options: _options,
onSelected: _onOptionSelected,
),
),
if (_answered && _correct!)
ElevatedButton(
onPressed: _nextQuestion,
child: const Text('Next'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../models/custom_kanji_item.dart';
import '../services/custom_deck_repository.dart';
import 'add_card_screen.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 = [];
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();
}
});
_loadDeck();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadDeck() async {
final deck = await _deckRepository.getCustomDeck();
setState(() {
_deck = deck;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Custom SRS'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Jpn→Eng'),
Tab(text: 'Eng→Jpn'),
Tab(text: 'Listening'),
],
),
),
body: _deck.isEmpty
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
CustomQuizScreen(key: _quizScreenKeys[0], deck: _deck, quizMode: CustomQuizMode.japaneseToEnglish),
CustomQuizScreen(key: _quizScreenKeys[1], deck: _deck, quizMode: CustomQuizMode.englishToJapanese),
CustomQuizScreen(key: _quizScreenKeys[2], deck: _deck, quizMode: CustomQuizMode.listeningComprehension),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AddCardScreen()),
);
_loadDeck();
},
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -27,7 +27,8 @@ 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;
String _status = 'Loading deck...';
@@ -35,7 +36,6 @@ class _HomeScreenState extends State<HomeScreen> {
final Random _random = Random();
final _audioPlayer = AudioPlayer();
QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
@@ -43,15 +43,27 @@ class _HomeScreenState extends State<HomeScreen> {
int _score = 0;
int _asked = 0;
bool _playCorrectSound = true;
bool _apiKeyMissing = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
setState(() {});
_nextQuestion();
});
_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 +83,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,6 +102,7 @@ class _HomeScreenState extends State<HomeScreen> {
_deck = items;
_status = 'Loaded ${items.length} kanji';
_loading = false;
_apiKeyMissing = false;
});
_nextQuestion();
@@ -123,6 +135,19 @@ class _HomeScreenState extends State<HomeScreen> {
return _ReadingInfo(readingsList, hint);
}
QuizMode get _mode {
switch (_tabController.index) {
case 0:
return QuizMode.kanjiToEnglish;
case 1:
return QuizMode.englishToKanji;
case 2:
return QuizMode.reading;
default:
return QuizMode.kanjiToEnglish;
}
}
void _nextQuestion() {
if (_deck.isEmpty) return;
@@ -277,6 +302,30 @@ class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
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'),
),
],
),
),
);
}
String prompt = '';
String subtitle = '';
@@ -294,24 +343,18 @@ class _HomeScreenState extends State<HomeScreen> {
}
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();
},
)
],
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: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -328,17 +371,6 @@ class _HomeScreenState extends State<HomeScreen> {
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,
@@ -382,21 +414,4 @@ class _HomeScreenState extends State<HomeScreen> {
),
);
}
ChoiceChip _buildChoiceChip(String label, QuizMode 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),
);
}
}
}

View File

@@ -1,139 +1,120 @@
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(
child: Padding(
padding: const EdgeInsets.all(32),
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,
),
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]),
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)),
appBar: AppBar(
title: const Text('Hirameki SRS'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
),
],
),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const 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),
),
),
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 VocabScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
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),
),
),
child: const Text(
'Vocabulary Quiz',
style: TextStyle(fontSize: 18),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CustomSrsScreen()),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text(
'Custom SRS',
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),
),
),
],
],
),
),
),
),
);
}
}
}

View File

@@ -18,14 +18,14 @@ 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;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final _audioPlayer = AudioPlayer();
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
@@ -33,14 +33,26 @@ class _VocabScreenState extends State<VocabScreen> {
int _asked = 0;
bool _playAudio = true;
bool _playCorrectSound = true;
bool _apiKeyMissing = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
setState(() {});
_nextQuestion();
});
_loadSettings();
_loadDeck();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -61,11 +73,10 @@ class _VocabScreenState extends State<VocabScreen> {
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,6 +93,7 @@ class _VocabScreenState extends State<VocabScreen> {
_deck = items;
_status = 'Loaded ${items.length} vocabulary';
_loading = false;
_apiKeyMissing = false;
});
_nextQuestion();
@@ -101,6 +113,19 @@ class _VocabScreenState extends State<VocabScreen> {
.join(' ');
}
VocabQuizMode get _mode {
switch (_tabController.index) {
case 0:
return VocabQuizMode.vocabToEnglish;
case 1:
return VocabQuizMode.englishToVocab;
case 2:
return VocabQuizMode.audioToEnglish;
default:
return VocabQuizMode.vocabToEnglish;
}
}
void _nextQuestion() {
if (_deck.isEmpty) return;
@@ -257,6 +282,30 @@ class _VocabScreenState extends State<VocabScreen> {
@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.', 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'),
),
],
),
),
);
}
Widget promptWidget;
if (_current == null) {
@@ -283,24 +332,18 @@ class _VocabScreenState extends State<VocabScreen> {
}
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();
},
)
],
title: const Text('Vocabulary Quiz'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Vocab→English'),
Tab(text: 'English→Vocab'),
Tab(text: 'Listening'),
],
),
),
backgroundColor: const Color(0xFF121212),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -317,17 +360,6 @@ class _VocabScreenState extends State<VocabScreen> {
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,
@@ -370,23 +402,5 @@ class _VocabScreenState extends State<VocabScreen> {
),
),
);
}
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),
);
}
}
}

View File

@@ -0,0 +1,30 @@
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> _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));
}
}

View File

@@ -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:
@@ -315,6 +323,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 +416,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:

View File

@@ -8,12 +8,14 @@ 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
dev_dependencies:
flutter_test: