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/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/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 new file mode 100644 index 0000000..2d8bd0e --- /dev/null +++ b/lib/src/models/custom_kanji_item.dart @@ -0,0 +1,42 @@ + +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) { + return 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, + ); + } + + Map toJson() { + return { + '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 new file mode 100644 index 0000000..c3cd3de --- /dev/null +++ b/lib/src/screens/add_card_screen.dart @@ -0,0 +1,133 @@ + +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(); + bool _useInterval = false; + + @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, + useInterval: _useInterval, + nextReview: _useInterval ? DateTime.now() : 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: 16), + SwitchListTile( + title: const Text('Use Interval-based SRS'), + value: _useInterval, + onChanged: (value) { + setState(() { + _useInterval = value; + }); + }, + ), + 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..909bb2f 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -2,6 +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}); @@ -10,28 +14,34 @@ 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; 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; int _currentVocabPage = 0; + bool _apiKeyMissing = false; @override void initState() { super.initState(); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 3, vsync: this); _kanjiPageController = PageController(); _vocabPageController = PageController(); @@ -56,126 +66,69 @@ class _BrowseScreenState extends State }); _loadDecks(); - } - - 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(() { - _status = 'API key not set.'; - _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.'; - }); - } 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(); + _loadCustomDeck(); } @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF121212), - appBar: AppBar( - title: const Text('Browse Items'), - backgroundColor: const Color(0xFF1F1F1F), - foregroundColor: Colors.white, - bottom: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Kanji'), - Tab(text: 'Vocabulary'), - ], - labelColor: Colors.blueAccent, - unselectedLabelColor: Colors.grey, - indicatorColor: Colors.blueAccent, - ), - ), - 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(), - ), - ], + void dispose() { + _tabController.dispose(); + _kanjiPageController.dispose(); + _vocabPageController.dispose(); + super.dispose(); + } + + Future _loadCustomDeck() async { + final customDeck = await _customDeckRepository.getCustomDeck(); + setState(() { + _customDeck = customDeck; + }); + } + + Widget _buildWaniKaniTab(Widget child) { + if (_apiKeyMissing) { + 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'), ), - ); + ], + ), + ); + } + + 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)), + ], + ), + ); + } + + 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( @@ -423,7 +376,7 @@ class _BrowseScreenState extends State if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) const Text( 'No readings available.', - style: TextStyle(color: Colors.white), + style: const TextStyle(color: Colors.white), ), ], ), @@ -439,11 +392,330 @@ class _BrowseScreenState extends State ); } + 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 - void dispose() { - _tabController.dispose(); - _kanjiPageController.dispose(); - _vocabPageController.dispose(); - super.dispose(); + 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: 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) { + _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'), + ), + ], + ), + ); + } + + 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: targetState, + srsLevel: item.srsLevel, + nextReview: item.nextReview, + ); + updatedItems.add(updatedItem); + } + + await _customDeckRepository.updateCards(updatedItems); + await _loadCustomDeck(); + + final newSelectedItems = _customDeck + .where((item) => selectedCharacters.contains(item.characters)) + .toList(); + + setState(() { + _selectedItems = newSelectedItems; + if (_selectedItems.isEmpty) { + _isSelectionMode = false; + } + }); + } + + 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( + 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) + : 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, + ), + ), + ], + ), + ), + ); + }, + padding: const EdgeInsets.all(8), + ); } } 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 new file mode 100644 index 0000000..b304703 --- /dev/null +++ b/lib/src/screens/custom_quiz_screen.dart @@ -0,0 +1,230 @@ +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; + final Function(CustomKanjiItem) onCardReviewed; + final bool useKanji; + + 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 { + int _currentIndex = 0; + List _shuffledDeck = []; + List _options = []; + bool _answered = false; + bool? _correct; + late FlutterTts _flutterTts; + late AnimationController _shakeController; + late Animation _shakeAnimation; + + @override + void initState() { + super.initState(); + _shuffledDeck = widget.deck.toList()..shuffle(); + _initTts(); + if (_shuffledDeck.isNotEmpty) { + _generateOptions(); + } + + _shakeController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _shakeAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _shakeController, + curve: Curves.elasticIn, + ), + ); + } + + @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); + } + } + + void _initTts() async { + _flutterTts = FlutterTts(); + await _flutterTts.setLanguage("ja-JP"); + if (_shuffledDeck.isNotEmpty && 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 = [widget.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(widget.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) + ? (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; + }); + + 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) { + if (_shuffledDeck.isEmpty) { + return const Center( + child: Text('Review session complete!'), + ); + } + + final currentItem = _shuffledDeck[_currentIndex]; + final question = (widget.quizMode == CustomQuizMode.englishToJapanese) + ? currentItem.meaning + : (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); + + 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'), + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/custom_srs_screen.dart b/lib/src/screens/custom_srs_screen.dart new file mode 100644 index 0000000..8727e28 --- /dev/null +++ b/lib/src/screens/custom_srs_screen.dart @@ -0,0 +1,144 @@ +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 = []; + List _reviewDeck = []; + bool _useKanji = false; + 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(); + } + setState(() {}); + }); + _loadDeck(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + 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 [ + Tab(text: 'Jpn→Eng'), + Tab(text: 'Eng→Jpn'), + Tab(text: 'Listening'), + ], + ), + ), + body: _deck.isEmpty + ? 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: _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( + 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..61876c5 100644 --- a/lib/src/screens/start_screen.dart +++ b/lib/src/screens/start_screen.dart @@ -1,133 +1,128 @@ 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( + appBar: AppBar( + title: const Text('Hirameki SRS'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + ), + ], + ), + 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(32), + padding: const EdgeInsets.all(16.0), 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, - ), + Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary), 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]), + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), 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)), - ), - 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: 8), + Expanded( + child: Text( + description, + style: const TextStyle(fontSize: 12, color: Colors.grey), + textAlign: TextAlign.center, + softWrap: true, ), ), ], @@ -136,4 +131,4 @@ class _StartScreenState extends State { ), ); } -} +} \ 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..0e21da4 --- /dev/null +++ b/lib/src/services/custom_deck_repository.dart @@ -0,0 +1,56 @@ + +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 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 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); + 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 e649ab0..58b4324 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: @@ -294,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: @@ -315,6 +331,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 +424,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..ad2c70d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,12 +8,15 @@ 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 + flutter_dotenv: ^5.1.0 dev_dependencies: flutter_test: @@ -32,3 +35,4 @@ flutter: uses-material-design: true assets: - assets/sfx/confirm.mp3 + - .env