From b58a4020e1a21df911ac13f6350026a0203dab43 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Thu, 30 Oct 2025 02:00:29 +0100 Subject: [PATCH 1/3] add custom srs --- android/app/src/main/AndroidManifest.xml | 3 + lib/src/models/custom_kanji_item.dart | 28 +++ lib/src/screens/add_card_screen.dart | 120 ++++++++++ lib/src/screens/browse_screen.dart | 59 +++-- lib/src/screens/custom_quiz_screen.dart | 229 +++++++++++++++++++ lib/src/screens/custom_srs_screen.dart | 85 +++++++ lib/src/screens/home_screen.dart | 119 +++++----- lib/src/screens/start_screen.dart | 221 ++++++++---------- lib/src/screens/vocab_screen.dart | 120 +++++----- lib/src/services/custom_deck_repository.dart | 30 +++ pubspec.lock | 24 ++ pubspec.yaml | 14 +- 12 files changed, 802 insertions(+), 250 deletions(-) create mode 100644 lib/src/models/custom_kanji_item.dart create mode 100644 lib/src/screens/add_card_screen.dart create mode 100644 lib/src/screens/custom_quiz_screen.dart create mode 100644 lib/src/screens/custom_srs_screen.dart create mode 100644 lib/src/services/custom_deck_repository.dart 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: -- 2.49.1 From ee4fd7ffc1509b5fc7c911e93cdd1567bc44f516 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Thu, 30 Oct 2025 03:44:04 +0100 Subject: [PATCH 2/3] add a shit ton of feature for the custom srs --- .gitignore | 3 + lib/main.dart | 2 + lib/src/models/custom_kanji_item.dart | 14 + lib/src/screens/add_card_screen.dart | 13 + lib/src/screens/browse_screen.dart | 472 +++++++++++++----- .../screens/custom_card_details_screen.dart | 127 +++++ lib/src/screens/custom_quiz_screen.dart | 173 +++---- lib/src/screens/custom_srs_screen.dart | 69 ++- lib/src/screens/start_screen.dart | 188 +++---- lib/src/services/custom_deck_repository.dart | 19 +- lib/src/services/deck_repository.dart | 8 + pubspec.lock | 8 + pubspec.yaml | 2 + 13 files changed, 787 insertions(+), 311 deletions(-) create mode 100644 lib/src/screens/custom_card_details_screen.dart diff --git a/.gitignore b/.gitignore index d12fe35..0716726 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ app.*.map.json *.jks gradle.properties + +# Environment variables +.env \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e7b4dd9..ba8558a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'src/services/deck_repository.dart'; import 'src/screens/start_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: ".env"); runApp( Provider( diff --git a/lib/src/models/custom_kanji_item.dart b/lib/src/models/custom_kanji_item.dart index 427a388..2d8bd0e 100644 --- a/lib/src/models/custom_kanji_item.dart +++ b/lib/src/models/custom_kanji_item.dart @@ -3,11 +3,17 @@ class CustomKanjiItem { final String characters; final String meaning; final String? kanji; + final bool useInterval; + int srsLevel; + DateTime? nextReview; CustomKanjiItem({ required this.characters, required this.meaning, this.kanji, + this.useInterval = false, + this.srsLevel = 0, + this.nextReview, }); factory CustomKanjiItem.fromJson(Map json) { @@ -15,6 +21,11 @@ class CustomKanjiItem { characters: json['characters'] as String, meaning: json['meaning'] as String, kanji: json['kanji'] as String?, + useInterval: json['useInterval'] as bool? ?? false, + srsLevel: json['srsLevel'] as int? ?? 0, + nextReview: json['nextReview'] != null + ? DateTime.parse(json['nextReview'] as String) + : null, ); } @@ -23,6 +34,9 @@ class CustomKanjiItem { 'characters': characters, 'meaning': meaning, 'kanji': kanji, + 'useInterval': useInterval, + 'srsLevel': srsLevel, + 'nextReview': nextReview?.toIso8601String(), }; } } diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart index c94aeef..c3cd3de 100644 --- a/lib/src/screens/add_card_screen.dart +++ b/lib/src/screens/add_card_screen.dart @@ -18,6 +18,7 @@ class _AddCardScreenState extends State { final _kanjiController = TextEditingController(); final _kanaKit = const KanaKit(); final _deckRepository = CustomDeckRepository(); + bool _useInterval = false; @override void initState() { @@ -53,6 +54,8 @@ class _AddCardScreenState extends State { characters: _japaneseController.text, meaning: _englishController.text, kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null, + useInterval: _useInterval, + nextReview: _useInterval ? DateTime.now() : null, ); _deckRepository.addCard(newItem); Navigator.of(context).pop(); @@ -106,6 +109,16 @@ class _AddCardScreenState extends State { return null; }, ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Use Interval-based SRS'), + value: _useInterval, + onChanged: (value) { + setState(() { + _useInterval = value; + }); + }, + ), const SizedBox(height: 32), ElevatedButton( onPressed: _saveCard, diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index 06a52ce..a36ae9d 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/kanji_item.dart'; import '../services/deck_repository.dart'; +import '../services/custom_deck_repository.dart'; +import '../models/custom_kanji_item.dart'; import 'settings_screen.dart'; +import 'custom_card_details_screen.dart'; class BrowseScreen extends StatefulWidget { const BrowseScreen({super.key}); @@ -18,11 +21,17 @@ class _BrowseScreenState extends State with SingleTickerProviderSt List _kanjiDeck = []; List _vocabDeck = []; + List _customDeck = []; Map> _kanjiByLevel = {}; Map> _vocabByLevel = {}; List _kanjiSortedLevels = []; List _vocabSortedLevels = []; + final _customDeckRepository = CustomDeckRepository(); + + bool _isSelectionMode = false; + List _selectedItems = []; + bool _loading = true; String _status = 'Loading...'; int _currentKanjiPage = 0; @@ -32,7 +41,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); _kanjiPageController = PageController(); _vocabPageController = PageController(); @@ -57,6 +66,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt }); _loadDecks(); + _loadCustomDeck(); } @override @@ -67,144 +77,58 @@ class _BrowseScreenState extends State with SingleTickerProviderSt super.dispose(); } - Future _loadDecks() async { - setState(() => _loading = true); - try { - final repo = Provider.of(context, listen: false); - await repo.loadApiKey(); - final apiKey = repo.apiKey; - - if (apiKey == null || apiKey.isEmpty) { - setState(() { - _apiKeyMissing = true; - _loading = false; - }); - return; - } - - var kanji = await repo.loadKanji(); - if (kanji.isEmpty || kanji.every((k) => k.level == 0)) { - setState(() => _status = 'Fetching kanji from WaniKani...'); - kanji = await repo.fetchAndCacheFromWk(apiKey); - } - - var vocab = await repo.loadVocabulary(); - if (vocab.isEmpty || vocab.every((v) => v.level == 0)) { - setState(() => _status = 'Fetching vocabulary from WaniKani...'); - vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey); - } - - _kanjiDeck = kanji; - _vocabDeck = vocab; - _groupItemsByLevel(); - - setState(() { - _loading = false; - _status = - 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.'; - _apiKeyMissing = false; - }); - } catch (e) { - setState(() { - _status = 'Error: $e'; - _loading = false; - }); - } + Future _loadCustomDeck() async { + final customDeck = await _customDeckRepository.getCustomDeck(); + setState(() { + _customDeck = customDeck; + }); } - void _groupItemsByLevel() { - _kanjiByLevel = {}; - for (final item in _kanjiDeck) { - if (item.level > 0) { - (_kanjiByLevel[item.level] ??= []).add(item); - } - } - _kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort(); - - _vocabByLevel = {}; - for (final item in _vocabDeck) { - if (item.level > 0) { - (_vocabByLevel[item.level] ??= []).add(item); - } - } - _vocabSortedLevels = _vocabByLevel.keys.toList()..sort(); - } - - @override - Widget build(BuildContext context) { + Widget _buildWaniKaniTab(Widget child) { 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 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( - appBar: AppBar( - title: const Text('Browse'), - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Kanji'), - Tab(text: 'Vocabulary'), + if (_loading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(color: Colors.blueAccent), + const SizedBox(height: 16), + Text(_status, style: const TextStyle(color: Colors.white)), ], ), - ), - backgroundColor: const Color(0xFF121212), - body: _loading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(color: Colors.blueAccent), - const SizedBox(height: 16), - Text(_status, style: const TextStyle(color: Colors.white)), - ], - ), - ) - : Column( - children: [ - Expanded( - child: TabBarView( - controller: _tabController, - children: [ - _buildPaginatedView( - _kanjiByLevel, - _kanjiSortedLevels, - _kanjiPageController, - (items) => _buildGridView(items.cast())), - _buildPaginatedView( - _vocabByLevel, - _vocabSortedLevels, - _vocabPageController, - (items) => _buildListView(items.cast())), - ], - ), - ), - SafeArea( - top: false, - child: _buildLevelSelector(), - ), - ], - ), - ); + ); + } + + return child; + } + + Widget _buildCustomSrsTab() { + if (_customDeck.isEmpty) { + return const Center( + child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)), + ); + } + return _buildCustomGridView(_customDeck); } Widget _buildPaginatedView( @@ -452,7 +376,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) const Text( 'No readings available.', - style: TextStyle(color: Colors.white), + style: const TextStyle(color: Colors.white), ), ], ), @@ -467,4 +391,290 @@ class _BrowseScreenState extends State with SingleTickerProviderSt }, ); } + + Future _loadDecks() async { + setState(() => _loading = true); + try { + final repo = Provider.of(context, listen: false); + await repo.loadApiKey(); + final apiKey = repo.apiKey; + + if (apiKey == null || apiKey.isEmpty) { + setState(() { + _apiKeyMissing = true; + _loading = false; + }); + return; + } + + var kanji = await repo.loadKanji(); + if (kanji.isEmpty || kanji.every((k) => k.level == 0)) { + setState(() => _status = 'Fetching kanji from WaniKani...'); + kanji = await repo.fetchAndCacheFromWk(apiKey); + } + + var vocab = await repo.loadVocabulary(); + if (vocab.isEmpty || vocab.every((v) => v.level == 0)) { + setState(() => _status = 'Fetching vocabulary from WaniKani...'); + vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey); + } + + _kanjiDeck = kanji; + _vocabDeck = vocab; + _groupItemsByLevel(); + + setState(() { + _loading = false; + _status = + 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.'; + _apiKeyMissing = false; + }); + } catch (e) { + setState(() { + _status = 'Error: $e'; + _loading = false; + }); + } + } + + void _groupItemsByLevel() { + _kanjiByLevel = {}; + for (final item in _kanjiDeck) { + if (item.level > 0) { + (_kanjiByLevel[item.level] ??= []).add(item); + } + } + _kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort(); + + _vocabByLevel = {}; + for (final item in _vocabDeck) { + if (item.level > 0) { + (_vocabByLevel[item.level] ??= []).add(item); + } + } + _vocabSortedLevels = _vocabByLevel.keys.toList()..sort(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), + backgroundColor: const Color(0xFF121212), + body: Column( + children: [ + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildWaniKaniTab( + _buildPaginatedView( + _kanjiByLevel, + _kanjiSortedLevels, + _kanjiPageController, + (items) => _buildGridView(items.cast())), + ), + _buildWaniKaniTab( + _buildPaginatedView( + _vocabByLevel, + _vocabSortedLevels, + _vocabPageController, + (items) => _buildListView(items.cast())), + ), + _buildCustomSrsTab(), + ], + ), + ), + if (!_isSelectionMode) + SafeArea( + top: false, + child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(), + ), + ], + ), + ); + } + + AppBar _buildDefaultAppBar() { + return AppBar( + title: const Text('Browse'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Kanji'), + Tab(text: 'Vocabulary'), + Tab(text: 'Custom SRS'), + ], + ), + ); + } + + AppBar _buildSelectionAppBar() { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _isSelectionMode = false; + _selectedItems.clear(); + }); + }, + ), + title: Text('${_selectedItems.length} selected'), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: _selectAll, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: _deleteSelected, + ), + IconButton( + icon: const Icon(Icons.timer_off), + onPressed: _toggleIntervalForSelected, + ), + ], + ); + } + + void _selectAll() { + setState(() { + if (_selectedItems.length == _customDeck.length) { + _selectedItems.clear(); + } else { + _selectedItems = List.from(_customDeck); + } + }); + } + + void _deleteSelected() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Selected'), + content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + for (final item in _selectedItems) { + await _customDeckRepository.deleteCard(item); + } + setState(() { + _isSelectionMode = false; + _selectedItems.clear(); + }); + _loadCustomDeck(); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ), + ); + } + + void _toggleIntervalForSelected() { + for (final item in _selectedItems) { + final updatedItem = CustomKanjiItem( + characters: item.characters, + meaning: item.meaning, + kanji: item.kanji, + useInterval: !item.useInterval, + srsLevel: item.srsLevel, + nextReview: item.nextReview, + ); + _customDeckRepository.updateCard(updatedItem); + } + setState(() { + _isSelectionMode = false; + _selectedItems.clear(); + }); + _loadCustomDeck(); + } + + Widget _buildCustomGridView(List items) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + childAspectRatio: 1.2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isSelected = _selectedItems.contains(item); + return GestureDetector( + onLongPress: () { + setState(() { + _isSelectionMode = true; + _selectedItems.add(item); + }); + }, + onTap: () { + if (_isSelectionMode) { + setState(() { + if (isSelected) { + _selectedItems.remove(item); + if (_selectedItems.isEmpty) { + _isSelectionMode = false; + } + } else { + _selectedItems.add(item); + } + }); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CustomCardDetailsScreen( + item: item, + repository: _customDeckRepository, + ), + ), + ).then((_) => _loadCustomDeck()); + } + }, + child: Card( + color: isSelected + ? Colors.blue.withOpacity(0.5) + : item.useInterval + ? Color.lerp(const Color(0xFF1E1E1E), Colors.blue, 0.1) + : const Color(0xFF1E1E1E), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item.kanji ?? item.characters, + style: const TextStyle(fontSize: 32, color: Colors.white), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + Text( + item.meaning, + style: const TextStyle(color: Colors.grey, fontSize: 16), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + _buildSrsIndicator(item.srsLevel), + ], + ), + ), + ), + ); + }, + padding: const EdgeInsets.all(8), + ); + } + + } \ No newline at end of file diff --git a/lib/src/screens/custom_card_details_screen.dart b/lib/src/screens/custom_card_details_screen.dart new file mode 100644 index 0000000..c303127 --- /dev/null +++ b/lib/src/screens/custom_card_details_screen.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import '../models/custom_kanji_item.dart'; +import '../services/custom_deck_repository.dart'; + +class CustomCardDetailsScreen extends StatefulWidget { + final CustomKanjiItem item; + final CustomDeckRepository repository; + + const CustomCardDetailsScreen( + {super.key, required this.item, required this.repository}); + + @override + State createState() => + _CustomCardDetailsScreenState(); +} + +class _CustomCardDetailsScreenState extends State { + late TextEditingController _japaneseController; + late TextEditingController _englishController; + late TextEditingController _kanjiController; + late bool _useInterval; + late int _srsLevel; + + @override + void initState() { + super.initState(); + _japaneseController = TextEditingController(text: widget.item.characters); + _englishController = TextEditingController(text: widget.item.meaning); + _kanjiController = TextEditingController(text: widget.item.kanji); + _useInterval = widget.item.useInterval; + _srsLevel = widget.item.srsLevel; + } + + @override + void dispose() { + _japaneseController.dispose(); + _englishController.dispose(); + _kanjiController.dispose(); + super.dispose(); + } + + void _saveChanges() { + final updatedItem = CustomKanjiItem( + characters: _japaneseController.text, + meaning: _englishController.text, + kanji: _kanjiController.text, + useInterval: _useInterval, + srsLevel: _srsLevel, + nextReview: widget.item.nextReview, + ); + widget.repository.updateCard(updatedItem); + Navigator.of(context).pop(true); + } + + void _deleteCard() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Card'), + content: const Text('Are you sure you want to delete this card?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + widget.repository.deleteCard(widget.item); + Navigator.of(context).pop(); + Navigator.of(context).pop(true); + }, + child: const Text('Delete'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Edit Card'), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: _deleteCard, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: _japaneseController, + decoration: const InputDecoration(labelText: 'Japanese (Kana)'), + ), + TextFormField( + controller: _kanjiController, + decoration: const InputDecoration(labelText: 'Japanese (Kanji)'), + ), + TextFormField( + controller: _englishController, + decoration: const InputDecoration(labelText: 'English'), + ), + SwitchListTile( + title: const Text('Use Interval SRS'), + value: _useInterval, + onChanged: (value) { + setState(() { + _useInterval = value; + }); + }, + ), + Text('SRS Level: $_srsLevel'), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _saveChanges, + child: const Text('Save Changes'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart index cf924b0..b304703 100644 --- a/lib/src/screens/custom_quiz_screen.dart +++ b/lib/src/screens/custom_quiz_screen.dart @@ -9,16 +9,23 @@ enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehensi class CustomQuizScreen extends StatefulWidget { final List deck; final CustomQuizMode quizMode; + final Function(CustomKanjiItem) onCardReviewed; + final bool useKanji; - const CustomQuizScreen( - {super.key, required this.deck, required this.quizMode}); + const CustomQuizScreen({ + super.key, + required this.deck, + required this.quizMode, + required this.onCardReviewed, + required this.useKanji, + }); @override State createState() => CustomQuizScreenState(); } class CustomQuizScreenState extends State - with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { + with TickerProviderStateMixin { int _currentIndex = 0; List _shuffledDeck = []; List _options = []; @@ -27,17 +34,15 @@ class CustomQuizScreenState extends State 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(); + if (_shuffledDeck.isNotEmpty) { + _generateOptions(); + } _shakeController = AnimationController( duration: const Duration(milliseconds: 500), @@ -51,6 +56,16 @@ class CustomQuizScreenState extends State ); } + @override + void didUpdateWidget(CustomQuizScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.useKanji != oldWidget.useKanji) { + setState(() { + _generateOptions(); + }); + } + } + void playAudio() { if (widget.quizMode == CustomQuizMode.listeningComprehension) { _speak(_shuffledDeck[_currentIndex].characters); @@ -60,7 +75,7 @@ class CustomQuizScreenState extends State void _initTts() async { _flutterTts = FlutterTts(); await _flutterTts.setLanguage("ja-JP"); - if (widget.quizMode == CustomQuizMode.listeningComprehension) { + if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) { _speak(_shuffledDeck[_currentIndex].characters); } } @@ -77,7 +92,7 @@ class CustomQuizScreenState extends State if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) { _options = [currentItem.meaning]; } else { - _options = [_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters]; + _options = [widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters]; } final otherItems = widget.deck .where((item) => item.characters != currentItem.characters) @@ -87,7 +102,7 @@ class CustomQuizScreenState extends State 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.add(widget.useKanji && otherItems[i].kanji != null ? otherItems[i].kanji! : otherItems[i].characters); } } _options.shuffle(); @@ -96,10 +111,22 @@ class CustomQuizScreenState extends State void _checkAnswer(String answer) { final currentItem = _shuffledDeck[_currentIndex]; final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) - ? (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) + ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) : currentItem.meaning; final isCorrect = answer == correctAnswer; + if (currentItem.useInterval) { + if (isCorrect) { + currentItem.srsLevel++; + final interval = pow(2, currentItem.srsLevel).toInt(); + currentItem.nextReview = DateTime.now().add(Duration(hours: interval)); + } else { + currentItem.srsLevel = max(0, currentItem.srsLevel - 1); + currentItem.nextReview = DateTime.now().add(const Duration(hours: 1)); + } + widget.onCardReviewed(currentItem); + } + setState(() { _answered = true; _correct = isCorrect; @@ -139,91 +166,65 @@ class CustomQuizScreenState extends State @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!'), - ), + return const Center( + child: Text('Review session complete!'), ); } final currentItem = _shuffledDeck[_currentIndex]; final question = (widget.quizMode == CustomQuizMode.englishToJapanese) ? currentItem.meaning - : (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); + : (widget.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(); - }); - }, - ), - ], + return 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'), ), ], ), - 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 index c38a221..8727e28 100644 --- a/lib/src/screens/custom_srs_screen.dart +++ b/lib/src/screens/custom_srs_screen.dart @@ -15,6 +15,8 @@ class _CustomSrsScreenState extends State with SingleTickerProv late TabController _tabController; final _deckRepository = CustomDeckRepository(); List _deck = []; + List _reviewDeck = []; + bool _useKanji = false; final _quizScreenKeys = [ GlobalKey(), GlobalKey(), @@ -30,6 +32,7 @@ class _CustomSrsScreenState extends State with SingleTickerProv final key = _quizScreenKeys[_tabController.index]; key.currentState?.playAudio(); } + setState(() {}); }); _loadDeck(); } @@ -42,16 +45,52 @@ class _CustomSrsScreenState extends State with SingleTickerProv Future _loadDeck() async { final deck = await _deckRepository.getCustomDeck(); + final now = DateTime.now(); + final reviewDeck = deck.where((item) { + if (!item.useInterval) { + return true; + } + return item.nextReview == null || item.nextReview!.isBefore(now); + }).toList(); + setState(() { _deck = deck; + _reviewDeck = reviewDeck; }); } + Future _updateCard(CustomKanjiItem item) async { + final index = _deck.indexWhere((element) => element.characters == item.characters); + if (index != -1) { + setState(() { + _deck[index] = item; + _reviewDeck.removeWhere((element) => element.characters == item.characters); + }); + await _deckRepository.saveDeck(_deck); + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Custom SRS'), + actions: [ + if (_tabController.index != 2) + Row( + children: [ + const Text('Kanji'), + Switch( + value: _useKanji, + onChanged: (value) { + setState(() { + _useKanji = value; + }); + }, + ), + ], + ), + ], bottom: TabBar( controller: _tabController, tabs: const [ @@ -62,13 +101,33 @@ class _CustomSrsScreenState extends State with SingleTickerProv ), ), body: _deck.isEmpty - ? const Center(child: CircularProgressIndicator()) - : TabBarView( + ? const Center(child: Text('Add cards to start quizzing!')) + : _reviewDeck.isEmpty + ? const Center(child: Text('No cards due for review.')) + : 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), + CustomQuizScreen( + key: _quizScreenKeys[0], + deck: _reviewDeck, + quizMode: CustomQuizMode.japaneseToEnglish, + onCardReviewed: _updateCard, + useKanji: _useKanji, + ), + CustomQuizScreen( + key: _quizScreenKeys[1], + deck: _reviewDeck, + quizMode: CustomQuizMode.englishToJapanese, + onCardReviewed: _updateCard, + useKanji: _useKanji, + ), + CustomQuizScreen( + key: _quizScreenKeys[2], + deck: _reviewDeck, + quizMode: CustomQuizMode.listeningComprehension, + onCardReviewed: _updateCard, + useKanji: _useKanji, + ), ], ), floatingActionButton: FloatingActionButton( diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart index 8105f48..61876c5 100644 --- a/lib/src/screens/start_screen.dart +++ b/lib/src/screens/start_screen.dart @@ -24,94 +24,108 @@ class StartScreen extends StatelessWidget { ), ], ), - 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), - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const VocabScreen()), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(horizontal: 32, vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: const Text( - 'Vocabulary Quiz', - style: TextStyle(fontSize: 18), - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const BrowseScreen()), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepPurpleAccent, - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(horizontal: 32, vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: const Text( - 'Browse Items', - style: TextStyle(fontSize: 18), - ), - ), - const SizedBox(height: 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), - ), - ), - ], + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF121212), + Colors.grey[900]!, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(16), + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.8, + children: [ + _buildModeCard( + context, + title: 'Kanji Quiz', + icon: Icons.extension, + description: 'Test your knowledge of kanji characters.', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const HomeScreen()), + ); + }, ), + _buildModeCard( + context, + title: 'Vocabulary Quiz', + icon: Icons.school, + description: 'Practice vocabulary from your WaniKani deck.', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const VocabScreen()), + ); + }, + ), + _buildModeCard( + context, + title: 'Browse Items', + icon: Icons.grid_view, + description: 'Look through your kanji and vocabulary decks.', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BrowseScreen()), + ); + }, + ), + _buildModeCard( + context, + title: 'Custom SRS', + icon: Icons.create, + description: 'Create and study your own custom flashcards.', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CustomSrsScreen()), + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildModeCard(BuildContext context, { + required String title, + required IconData icon, + required String description, + required VoidCallback onTap, + }) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Expanded( + child: Text( + description, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + softWrap: true, + ), + ), + ], ), ), ), diff --git a/lib/src/services/custom_deck_repository.dart b/lib/src/services/custom_deck_repository.dart index 0509dc6..a1f9723 100644 --- a/lib/src/services/custom_deck_repository.dart +++ b/lib/src/services/custom_deck_repository.dart @@ -19,10 +19,25 @@ class CustomDeckRepository { Future addCard(CustomKanjiItem item) async { final deck = await getCustomDeck(); deck.add(item); - await _saveDeck(deck); + await saveDeck(deck); } - Future _saveDeck(List deck) async { + Future updateCard(CustomKanjiItem item) async { + final deck = await getCustomDeck(); + final index = deck.indexWhere((element) => element.characters == item.characters); + if (index != -1) { + deck[index] = item; + await saveDeck(deck); + } + } + + Future deleteCard(CustomKanjiItem item) async { + final deck = await getCustomDeck(); + deck.removeWhere((element) => element.characters == item.characters); + 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/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 200af1c..3d447ff 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -6,6 +6,8 @@ import 'package:sqflite/sqflite.dart'; import '../models/kanji_item.dart'; import '../api/wk_client.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + class DeckRepository { Database? _db; String? _apiKey; @@ -98,6 +100,12 @@ class DeckRepository { } Future loadApiKey() async { + final envApiKey = dotenv.env['WANIKANI_API_KEY']; + if (envApiKey != null && envApiKey.isNotEmpty) { + _apiKey = envApiKey; + return _apiKey; + } + final db = await _openDb(); final rows = await db.query( 'settings', diff --git a/pubspec.lock b/pubspec.lock index baf7823..58b4324 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -302,6 +302,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f9161f..ad2c70d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: http: ^1.5.0 kana_kit: ^2.1.1 flutter_tts: ^3.8.5 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: @@ -34,3 +35,4 @@ flutter: uses-material-design: true assets: - assets/sfx/confirm.mp3 + - .env -- 2.49.1 From d8a5c27fb3c7f754b8b1643fabab1f4e81e83ea1 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Thu, 30 Oct 2025 17:41:53 +0100 Subject: [PATCH 3/3] finish custom srs for now --- lib/src/screens/browse_screen.dart | 111 +++++++++++++------ lib/src/services/custom_deck_repository.dart | 11 ++ 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index a36ae9d..909bb2f 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -530,13 +530,21 @@ class _BrowseScreenState extends State with SingleTickerProviderSt onPressed: _deleteSelected, ), IconButton( - icon: const Icon(Icons.timer_off), + icon: Icon(_toggleIntervalIcon), onPressed: _toggleIntervalForSelected, ), ], ); } + IconData get _toggleIntervalIcon { + if (_selectedItems.isEmpty) { + return Icons.timer_off; + } + final bool willEnable = _selectedItems.any((item) => !item.useInterval); + return willEnable ? Icons.timer : Icons.timer_off; + } + void _selectAll() { setState(() { if (_selectedItems.length == _customDeck.length) { @@ -577,23 +585,40 @@ class _BrowseScreenState extends State with SingleTickerProviderSt ); } - void _toggleIntervalForSelected() { + Future _toggleIntervalForSelected() async { + if (_selectedItems.isEmpty) { + return; + } + final bool targetState = _selectedItems.any((item) => !item.useInterval); + + final selectedCharacters = _selectedItems.map((item) => item.characters).toSet(); + + final List updatedItems = []; for (final item in _selectedItems) { final updatedItem = CustomKanjiItem( characters: item.characters, meaning: item.meaning, kanji: item.kanji, - useInterval: !item.useInterval, + useInterval: targetState, srsLevel: item.srsLevel, nextReview: item.nextReview, ); - _customDeckRepository.updateCard(updatedItem); + updatedItems.add(updatedItem); } + + await _customDeckRepository.updateCards(updatedItems); + await _loadCustomDeck(); + + final newSelectedItems = _customDeck + .where((item) => selectedCharacters.contains(item.characters)) + .toList(); + setState(() { - _isSelectionMode = false; - _selectedItems.clear(); + _selectedItems = newSelectedItems; + if (_selectedItems.isEmpty) { + _isSelectionMode = false; + } }); - _loadCustomDeck(); } Widget _buildCustomGridView(List items) { @@ -639,35 +664,53 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } }, child: Card( + shape: RoundedRectangleBorder( + side: isSelected + ? const BorderSide(color: Colors.blue, width: 2.0) + : BorderSide.none, + borderRadius: BorderRadius.circular(12.0), + ), color: isSelected ? Colors.blue.withOpacity(0.5) - : item.useInterval - ? Color.lerp(const Color(0xFF1E1E1E), Colors.blue, 0.1) - : const Color(0xFF1E1E1E), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - item.kanji ?? item.characters, - style: const TextStyle(fontSize: 32, color: Colors.white), - textAlign: TextAlign.center, + : const Color(0xFF1E1E1E), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + item.kanji ?? item.characters, + style: const TextStyle(fontSize: 32, color: Colors.white), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + Text( + item.meaning, + style: const TextStyle(color: Colors.grey, fontSize: 16), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + _buildSrsIndicator(item.srsLevel), + ], + ), + ), + if (item.useInterval) + Positioned( + top: 4, + right: 4, + child: Icon( + Icons.timer, + color: Colors.green, + size: 16, ), ), - const SizedBox(height: 8), - Text( - item.meaning, - style: const TextStyle(color: Colors.grey, fontSize: 16), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 8), - _buildSrsIndicator(item.srsLevel), - ], - ), + ], ), ), ); @@ -675,6 +718,4 @@ class _BrowseScreenState extends State with SingleTickerProviderSt padding: const EdgeInsets.all(8), ); } - - -} \ No newline at end of file +} diff --git a/lib/src/services/custom_deck_repository.dart b/lib/src/services/custom_deck_repository.dart index a1f9723..0e21da4 100644 --- a/lib/src/services/custom_deck_repository.dart +++ b/lib/src/services/custom_deck_repository.dart @@ -31,6 +31,17 @@ class CustomDeckRepository { } } + Future updateCards(List itemsToUpdate) async { + final deck = await getCustomDeck(); + for (var item in itemsToUpdate) { + final index = deck.indexWhere((element) => element.characters == item.characters); + if (index != -1) { + deck[index] = item; + } + } + await saveDeck(deck); + } + Future deleteCard(CustomKanjiItem item) async { final deck = await getCustomDeck(); deck.removeWhere((element) => element.characters == item.characters); -- 2.49.1