From ee4fd7ffc1509b5fc7c911e93cdd1567bc44f516 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Thu, 30 Oct 2025 03:44:04 +0100 Subject: [PATCH] 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