diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0f0132b..5afdc62 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,5 +42,8 @@
+
+
+
diff --git a/lib/src/models/custom_kanji_item.dart b/lib/src/models/custom_kanji_item.dart
new file mode 100644
index 0000000..427a388
--- /dev/null
+++ b/lib/src/models/custom_kanji_item.dart
@@ -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 json) {
+ return CustomKanjiItem(
+ characters: json['characters'] as String,
+ meaning: json['meaning'] as String,
+ kanji: json['kanji'] as String?,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'characters': characters,
+ 'meaning': meaning,
+ 'kanji': kanji,
+ };
+ }
+}
diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart
new file mode 100644
index 0000000..c94aeef
--- /dev/null
+++ b/lib/src/screens/add_card_screen.dart
@@ -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 createState() => _AddCardScreenState();
+}
+
+class _AddCardScreenState extends State {
+ final _formKey = GlobalKey();
+ 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'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart
index e199b18..06a52ce 100644
--- a/lib/src/screens/browse_screen.dart
+++ b/lib/src/screens/browse_screen.dart
@@ -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 createState() => _BrowseScreenState();
}
-class _BrowseScreenState extends State
- with SingleTickerProviderStateMixin {
+class _BrowseScreenState extends State with SingleTickerProviderStateMixin {
late TabController _tabController;
late PageController _kanjiPageController;
late PageController _vocabPageController;
@@ -27,6 +27,7 @@ class _BrowseScreenState extends State
String _status = 'Loading...';
int _currentKanjiPage = 0;
int _currentVocabPage = 0;
+ bool _apiKeyMissing = false;
@override
void initState() {
@@ -58,6 +59,14 @@ class _BrowseScreenState extends State
_loadDecks();
}
+ @override
+ void dispose() {
+ _tabController.dispose();
+ _kanjiPageController.dispose();
+ _vocabPageController.dispose();
+ super.dispose();
+ }
+
Future _loadDecks() async {
setState(() => _loading = true);
try {
@@ -67,7 +76,7 @@ class _BrowseScreenState extends State
if (apiKey == null || apiKey.isEmpty) {
setState(() {
- _status = 'API key not set.';
+ _apiKeyMissing = true;
_loading = false;
});
return;
@@ -93,6 +102,7 @@ class _BrowseScreenState extends State
_loading = false;
_status =
'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
+ _apiKeyMissing = false;
});
} catch (e) {
setState(() {
@@ -122,23 +132,42 @@ class _BrowseScreenState extends State
@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
},
);
}
-
- @override
- void dispose() {
- _tabController.dispose();
- _kanjiPageController.dispose();
- _vocabPageController.dispose();
- super.dispose();
- }
-}
+}
\ No newline at end of file
diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart
new file mode 100644
index 0000000..cf924b0
--- /dev/null
+++ b/lib/src/screens/custom_quiz_screen.dart
@@ -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 deck;
+ final CustomQuizMode quizMode;
+
+ const CustomQuizScreen(
+ {super.key, required this.deck, required this.quizMode});
+
+ @override
+ State createState() => CustomQuizScreenState();
+}
+
+class CustomQuizScreenState extends State
+ with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
+ int _currentIndex = 0;
+ List _shuffledDeck = [];
+ List _options = [];
+ bool _answered = false;
+ bool? _correct;
+ late FlutterTts _flutterTts;
+ late AnimationController _shakeController;
+ late Animation _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(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 _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'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/custom_srs_screen.dart b/lib/src/screens/custom_srs_screen.dart
new file mode 100644
index 0000000..c38a221
--- /dev/null
+++ b/lib/src/screens/custom_srs_screen.dart
@@ -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 createState() => _CustomSrsScreenState();
+}
+
+class _CustomSrsScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
+ final _deckRepository = CustomDeckRepository();
+ List _deck = [];
+ final _quizScreenKeys = [
+ GlobalKey(),
+ GlobalKey(),
+ GlobalKey(),
+ ];
+
+ @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 _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),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart
index e80d93f..3c3d77e 100644
--- a/lib/src/screens/home_screen.dart
+++ b/lib/src/screens/home_screen.dart
@@ -27,7 +27,8 @@ class HomeScreen extends StatefulWidget {
State createState() => _HomeScreenState();
}
-class _HomeScreenState extends State {
+class _HomeScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
List _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
@@ -35,7 +36,6 @@ class _HomeScreenState extends State {
final Random _random = Random();
final _audioPlayer = AudioPlayer();
- QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
List _options = [];
List _correctAnswers = [];
@@ -43,15 +43,27 @@ class _HomeScreenState extends State {
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 _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -71,11 +83,10 @@ class _HomeScreenState extends State {
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 {
_deck = items;
_status = 'Loaded ${items.length} kanji';
_loading = false;
+ _apiKeyMissing = false;
});
_nextQuestion();
@@ -123,6 +135,19 @@ class _HomeScreenState extends State {
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 {
@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 {
}
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 {
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 {
),
);
}
-
- 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),
- );
- }
-}
+}
\ No newline at end of file
diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart
index 0a742f4..8105f48 100644
--- a/lib/src/screens/start_screen.dart
+++ b/lib/src/screens/start_screen.dart
@@ -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 createState() => _StartScreenState();
-}
-
-class _StartScreenState extends State {
- bool _loading = true;
- bool _hasApiKey = false;
-
- @override
- void initState() {
- super.initState();
- _checkApiKey();
- }
-
- Future _checkApiKey() async {
- final repo = Provider.of(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),
- ),
- ),
- ],
+ ],
+ ),
),
),
),
);
}
-}
+}
\ No newline at end of file
diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart
index 7d2b9e5..b47facb 100644
--- a/lib/src/screens/vocab_screen.dart
+++ b/lib/src/screens/vocab_screen.dart
@@ -18,14 +18,14 @@ class VocabScreen extends StatefulWidget {
State createState() => _VocabScreenState();
}
-class _VocabScreenState extends State {
+class _VocabScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
List _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final _audioPlayer = AudioPlayer();
- VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
List _options = [];
List _correctAnswers = [];
@@ -33,14 +33,26 @@ class _VocabScreenState extends State {
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 _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -61,11 +73,10 @@ class _VocabScreenState extends State {
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 {
_deck = items;
_status = 'Loaded ${items.length} vocabulary';
_loading = false;
+ _apiKeyMissing = false;
});
_nextQuestion();
@@ -101,6 +113,19 @@ class _VocabScreenState extends State {
.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 {
@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 {
}
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 {
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 {
),
),
);
-
}
-
- 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),
- );
- }
-}
+}
\ No newline at end of file
diff --git a/lib/src/services/custom_deck_repository.dart b/lib/src/services/custom_deck_repository.dart
new file mode 100644
index 0000000..0509dc6
--- /dev/null
+++ b/lib/src/services/custom_deck_repository.dart
@@ -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> getCustomDeck() async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonString = prefs.getString(_key);
+ if (jsonString != null) {
+ final List jsonList = json.decode(jsonString);
+ return jsonList.map((json) => CustomKanjiItem.fromJson(json)).toList();
+ }
+ return [];
+ }
+
+ Future addCard(CustomKanjiItem item) async {
+ final deck = await getCustomDeck();
+ deck.add(item);
+ await _saveDeck(deck);
+ }
+
+ Future _saveDeck(List deck) async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonList = deck.map((item) => item.toJson()).toList();
+ await prefs.setString(_key, json.encode(jsonList));
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index e649ab0..baf7823 100644
--- a/pubspec.lock
+++ b/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:
@@ -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:
diff --git a/pubspec.yaml b/pubspec.yaml
index ad9a205..7f9161f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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: