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: