diff --git a/lib/main.dart b/lib/main.dart index 0fe6d68..a8252b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:hirameki_srs/src/models/theme_model.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -9,16 +10,15 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); try { await dotenv.load(fileName: ".env"); - } catch (e) { - // It's okay if the .env file is not found. - // This is expected in release builds. - } + // No need to catch because the file is only needed for dev + } catch (_) {} runApp( MultiProvider( providers: [ Provider(create: (_) => DeckRepository()), Provider(create: (_) => VocabDeckRepository()), + ChangeNotifierProvider(create: (_) => ThemeModel()), ], child: const WkApp(), ), @@ -30,11 +30,15 @@ class WkApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Hirameki SRS', - debugShowCheckedModeBanner: false, - theme: ThemeData.dark(useMaterial3: true), - home: const StartScreen(), + return Consumer( + builder: (context, themeModel, child) { + return MaterialApp( + title: 'Hirameki SRS', + debugShowCheckedModeBanner: false, + theme: themeModel.currentTheme, + home: const StartScreen(), + ); + }, ); } -} \ No newline at end of file +} diff --git a/lib/src/api/wk_client.dart b/lib/src/api/wk_client.dart index 39adcd3..2b21854 100644 --- a/lib/src/api/wk_client.dart +++ b/lib/src/api/wk_client.dart @@ -1,14 +1,24 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../models/subject.dart'; +import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; class WkClient { final String apiKey; final Map headers; final String base = 'https://api.wanikani.com/v2'; - WkClient(this.apiKey) : headers = {'Authorization': 'Bearer $apiKey', 'Wanikani-Revision': '20170710', 'Accept': 'application/json'}; + WkClient(this.apiKey) + : headers = { + 'Authorization': 'Bearer $apiKey', + 'Wanikani-Revision': '20170710', + 'Accept': 'application/json', + }; - Future>> fetchAllAssignments({List? subjectTypes}) async { + Future>> fetchAllAssignments({ + List? subjectTypes, + }) async { final out = >[]; String url = '$base/assignments?page=1'; if (subjectTypes != null && subjectTypes.isNotEmpty) { @@ -30,13 +40,15 @@ class WkClient { return out; } - Future>> fetchAllSubjects({List? types}) async { + Future>> fetchAllSubjects({ + List? types, + }) async { final out = >[]; String url = '$base/subjects'; if (types != null && types.isNotEmpty) { url += '?types=${types.join(',')}'; } - + while (url.isNotEmpty) { final resp = await http.get(Uri.parse(url), headers: headers); if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}'); @@ -56,7 +68,10 @@ class WkClient { final out = >[]; const batch = 100; for (var i = 0; i < ids.length; i += batch) { - final chunk = ids.sublist(i, i + batch > ids.length ? ids.length : i + batch); + final chunk = ids.sublist( + i, + i + batch > ids.length ? ids.length : i + batch, + ); String url = '$base/subjects?ids=${chunk.join(',')}&page=1'; while (true) { final resp = await http.get(Uri.parse(url), headers: headers); @@ -73,4 +88,13 @@ class WkClient { } return out; } + static Subject createSubjectFromMap(Map map) { + final String object = map['object']; + if (object == 'kanji') { + return KanjiItem.fromSubject(map); + } else if (object == 'vocabulary') { + return VocabularyItem.fromSubject(map); + } + throw Exception('Unknown subject type: $object'); + } } diff --git a/lib/src/models/custom_kanji_item.dart b/lib/src/models/custom_kanji_item.dart index c045761..16df01a 100644 --- a/lib/src/models/custom_kanji_item.dart +++ b/lib/src/models/custom_kanji_item.dart @@ -1,4 +1,3 @@ - class CustomKanjiItem { final String characters; final String meaning; @@ -25,7 +24,9 @@ class CustomKanjiItem { srsData.listeningComprehensionNextReview ??= oldNextReview; } } else { - DateTime? nextReview = json['nextReview'] != null ? DateTime.parse(json['nextReview'] as String) : null; + DateTime? nextReview = json['nextReview'] != null + ? DateTime.parse(json['nextReview'] as String) + : null; srsData = SrsData( japaneseToEnglish: json['srsLevel'] as int? ?? 0, japaneseToEnglishNextReview: nextReview, @@ -76,22 +77,32 @@ class SrsData { factory SrsData.fromJson(Map json) { return SrsData( japaneseToEnglish: json['japaneseToEnglish'] as int? ?? 0, - japaneseToEnglishNextReview: json['japaneseToEnglishNextReview'] != null ? DateTime.parse(json['japaneseToEnglishNextReview'] as String) : null, + japaneseToEnglishNextReview: json['japaneseToEnglishNextReview'] != null + ? DateTime.parse(json['japaneseToEnglishNextReview'] as String) + : null, englishToJapanese: json['englishToJapanese'] as int? ?? 0, - englishToJapaneseNextReview: json['englishToJapaneseNextReview'] != null ? DateTime.parse(json['englishToJapaneseNextReview'] as String) : null, + englishToJapaneseNextReview: json['englishToJapaneseNextReview'] != null + ? DateTime.parse(json['englishToJapaneseNextReview'] as String) + : null, listeningComprehension: json['listeningComprehension'] as int? ?? 0, - listeningComprehensionNextReview: json['listeningComprehensionNextReview'] != null ? DateTime.parse(json['listeningComprehensionNextReview'] as String) : null, + listeningComprehensionNextReview: + json['listeningComprehensionNextReview'] != null + ? DateTime.parse(json['listeningComprehensionNextReview'] as String) + : null, ); } Map toJson() { return { 'japaneseToEnglish': japaneseToEnglish, - 'japaneseToEnglishNextReview': japaneseToEnglishNextReview?.toIso8601String(), + 'japaneseToEnglishNextReview': japaneseToEnglishNextReview + ?.toIso8601String(), 'englishToJapanese': englishToJapanese, - 'englishToJapaneseNextReview': englishToJapaneseNextReview?.toIso8601String(), + 'englishToJapaneseNextReview': englishToJapaneseNextReview + ?.toIso8601String(), 'listeningComprehension': listeningComprehension, - 'listeningComprehensionNextReview': listeningComprehensionNextReview?.toIso8601String(), + 'listeningComprehensionNextReview': listeningComprehensionNextReview + ?.toIso8601String(), }; } } diff --git a/lib/src/models/kanji_item.dart b/lib/src/models/kanji_item.dart index dcbbbb9..0d696c8 100644 --- a/lib/src/models/kanji_item.dart +++ b/lib/src/models/kanji_item.dart @@ -1,54 +1,24 @@ -enum QuizMode { kanjiToEnglish, englishToKanji, reading } +import 'subject.dart'; -class SrsItem { - final int kanjiId; - final QuizMode quizMode; - final String? readingType; // 'onyomi' or 'kunyomi' - int srsStage; - DateTime lastAsked; - - SrsItem({ - required this.kanjiId, - required this.quizMode, - this.readingType, - this.srsStage = 0, - DateTime? lastAsked, - }) : lastAsked = lastAsked ?? DateTime.now(); -} - -class KanjiItem { - final int id; - final int level; - final String characters; - final List meanings; +class KanjiItem extends Subject { final List onyomi; final List kunyomi; - final Map srsItems = {}; KanjiItem({ - required this.id, - required this.level, - required this.characters, - required this.meanings, + required super.id, + required super.level, + required super.characters, + required super.meanings, required this.onyomi, required this.kunyomi, }); factory KanjiItem.fromSubject(Map subj) { - final int id = subj['id'] as int; - final data = subj['data'] as Map; - final int level = data['level'] as int; - final String characters = (data['characters'] ?? '') as String; - final List meanings = []; + final commonFields = Subject.parseCommonFields(subj); + final data = commonFields['data'] as Map; final List onyomi = []; final List kunyomi = []; - if (data['meanings'] != null) { - for (final m in data['meanings'] as List) { - meanings.add((m['meaning'] as String).toLowerCase()); - } - } - if (data['readings'] != null) { for (final r in data['readings'] as List) { final typ = r['type'] as String? ?? ''; @@ -62,10 +32,10 @@ class KanjiItem { } return KanjiItem( - id: id, - level: level, - characters: characters, - meanings: meanings, + id: commonFields['id'] as int, + level: commonFields['level'] as int, + characters: commonFields['characters'] as String, + meanings: commonFields['meanings'] as List, onyomi: onyomi, kunyomi: kunyomi, ); @@ -83,89 +53,3 @@ String _katakanaToHiragana(String input) { } return buf.toString(); } - -enum VocabQuizMode { vocabToEnglish, englishToVocab, audioToEnglish } - -class VocabSrsItem { - final int vocabId; - final VocabQuizMode quizMode; - int srsStage; - DateTime lastAsked; - - VocabSrsItem({ - required this.vocabId, - required this.quizMode, - this.srsStage = 0, - DateTime? lastAsked, - }) : lastAsked = lastAsked ?? DateTime.now(); -} - -class PronunciationAudio { - final String url; - final String gender; - - PronunciationAudio({required this.url, required this.gender}); -} - -class VocabularyItem { - final int id; - final int level; - final String characters; - final List meanings; - final List readings; - final List pronunciationAudios; - final Map srsItems = {}; - - VocabularyItem( - {required this.id, - required this.level, - required this.characters, - required this.meanings, - required this.readings, - required this.pronunciationAudios}); - - factory VocabularyItem.fromSubject(Map subj) { - final int id = subj['id'] as int; - final data = subj['data'] as Map; - final int level = data['level'] as int; - final String characters = (data['characters'] ?? '') as String; - final List meanings = []; - final List readings = []; - final List pronunciationAudios = []; - - if (data['meanings'] != null) { - for (final m in data['meanings'] as List) { - meanings.add((m['meaning'] as String).toLowerCase()); - } - } - - if (data['readings'] != null) { - for (final r in data['readings'] as List) { - readings.add(r['reading'] as String); - } - } - - if (data['pronunciation_audios'] != null) { - for (final audio in data['pronunciation_audios'] as List) { - final url = audio['url'] as String?; - final metadata = audio['metadata'] as Map?; - final gender = metadata?['gender'] as String?; - - if (url != null && gender != null) { - pronunciationAudios.add(PronunciationAudio( - url: url, - gender: gender, - )); - } - } - } - - return VocabularyItem( - id: id, - level: level, - characters: characters, - meanings: meanings, - readings: readings, - pronunciationAudios: pronunciationAudios); - } -} diff --git a/lib/src/models/srs_item.dart b/lib/src/models/srs_item.dart new file mode 100644 index 0000000..913234d --- /dev/null +++ b/lib/src/models/srs_item.dart @@ -0,0 +1,17 @@ +enum QuizMode { kanjiToEnglish, englishToKanji, reading, vocabToEnglish, englishToVocab, audioToEnglish } + +class SrsItem { + final int subjectId; + final QuizMode quizMode; + final String? readingType; + int srsStage; + DateTime lastAsked; + + SrsItem({ + required this.subjectId, + required this.quizMode, + this.readingType, + this.srsStage = 0, + DateTime? lastAsked, + }) : lastAsked = lastAsked ?? DateTime.now(); +} diff --git a/lib/src/models/subject.dart b/lib/src/models/subject.dart new file mode 100644 index 0000000..36aac3b --- /dev/null +++ b/lib/src/models/subject.dart @@ -0,0 +1,38 @@ +import 'srs_item.dart'; + +abstract class Subject { + final int id; + final int level; + final String characters; + final List meanings; + final Map srsItems = {}; + + Subject({ + required this.id, + required this.level, + required this.characters, + required this.meanings, + }); + + static Map parseCommonFields(Map subj) { + final int id = subj['id'] as int; + final data = subj['data'] as Map; + final int level = data['level'] as int; + final String characters = (data['characters'] ?? '') as String; + final List meanings = []; + + if (data['meanings'] != null) { + for (final m in data['meanings'] as List) { + meanings.add((m['meaning'] as String).toLowerCase()); + } + } + + return { + 'id': id, + 'level': level, + 'characters': characters, + 'meanings': meanings, + 'data': data, + }; + } +} \ No newline at end of file diff --git a/lib/src/models/subject_factory.dart b/lib/src/models/subject_factory.dart new file mode 100644 index 0000000..888863c --- /dev/null +++ b/lib/src/models/subject_factory.dart @@ -0,0 +1,15 @@ +import 'kanji_item.dart'; +import 'vocabulary_item.dart'; +import 'subject.dart'; + +class SubjectFactory { + static Subject fromMap(Map map) { + final String object = map['object']; + if (object == 'kanji') { + return KanjiItem.fromSubject(map); + } else if (object == 'vocabulary') { + return VocabularyItem.fromSubject(map); + } + throw Exception('Unknown subject type: $object'); + } +} diff --git a/lib/src/models/theme_model.dart b/lib/src/models/theme_model.dart new file mode 100644 index 0000000..a017945 --- /dev/null +++ b/lib/src/models/theme_model.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:hirameki_srs/src/themes.dart'; + +class ThemeModel extends ChangeNotifier { + ThemeData _currentTheme = Themes.dark; + + ThemeData get currentTheme => _currentTheme; + + void setTheme(ThemeData theme) { + _currentTheme = theme; + notifyListeners(); + } +} diff --git a/lib/src/models/vocabulary_item.dart b/lib/src/models/vocabulary_item.dart new file mode 100644 index 0000000..fa333cb --- /dev/null +++ b/lib/src/models/vocabulary_item.dart @@ -0,0 +1,56 @@ +import 'subject.dart'; + +class PronunciationAudio { + final String url; + final String gender; + + PronunciationAudio({required this.url, required this.gender}); +} + +class VocabularyItem extends Subject { + final List readings; + final List pronunciationAudios; + + VocabularyItem({ + required super.id, + required super.level, + required super.characters, + required super.meanings, + required this.readings, + required this.pronunciationAudios, + }); + + factory VocabularyItem.fromSubject(Map subj) { + final commonFields = Subject.parseCommonFields(subj); + final data = commonFields['data'] as Map; + final List readings = []; + final List pronunciationAudios = []; + + if (data['readings'] != null) { + for (final r in data['readings'] as List) { + readings.add(r['reading'] as String); + } + } + + if (data['pronunciation_audios'] != null) { + for (final audio in data['pronunciation_audios'] as List) { + final url = audio['url'] as String?; + final metadata = audio['metadata'] as Map?; + final gender = metadata?['gender'] as String?; + + if (url != null && gender != null) { + pronunciationAudios.add(PronunciationAudio(url: url, gender: gender)); + } + } + } + + return VocabularyItem( + id: commonFields['id'] as int, + level: commonFields['level'] as int, + characters: commonFields['characters'] as String, + meanings: commonFields['meanings'] as List, + readings: readings, + pronunciationAudios: pronunciationAudios, + ); + } +} diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart index 1969a79..ca74757 100644 --- a/lib/src/screens/add_card_screen.dart +++ b/lib/src/screens/add_card_screen.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:kana_kit/kana_kit.dart'; import '../models/custom_kanji_item.dart'; @@ -61,7 +60,9 @@ class _AddCardScreenState extends State { final newItem = CustomKanjiItem( characters: _japaneseController.text, meaning: _englishController.text, - kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, + kanji: _kanjiController.text.trim().isNotEmpty + ? _kanjiController.text.trim() + : null, useInterval: _useInterval, srsData: srsData, ); @@ -73,9 +74,7 @@ class _AddCardScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Add New Card'), - ), + appBar: AppBar(title: const Text('Add New Card')), body: Padding( padding: const EdgeInsets.all(16.0), child: Form( diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index 823de0f..ea3a3a7 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -1,8 +1,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:hirameki_srs/src/themes.dart'; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import '../services/deck_repository.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/custom_deck_repository.dart'; @@ -18,7 +21,8 @@ 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; @@ -50,7 +54,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt _vocabPageController = PageController(); _tabController.addListener(() { - setState(() {}); // Rebuild to update the level selector + setState(() {}); }); _kanjiPageController.addListener(() { @@ -94,13 +98,17 @@ class _BrowseScreenState extends State with SingleTickerProviderSt child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)), + Text( + 'WaniKani API key is not set.', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), const SizedBox(height: 16), ElevatedButton( onPressed: () async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ); + if (!mounted) return; _loadDecks(); }, child: const Text('Go to Settings'), @@ -115,9 +123,9 @@ class _BrowseScreenState extends State with SingleTickerProviderSt child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const CircularProgressIndicator(color: Colors.blueAccent), + CircularProgressIndicator(color: Theme.of(context).colorScheme.primary), const SizedBox(height: 16), - Text(_status, style: const TextStyle(color: Colors.white)), + Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), ], ), ); @@ -128,22 +136,28 @@ class _BrowseScreenState extends State with SingleTickerProviderSt Widget _buildCustomSrsTab() { if (_customDeck.isEmpty) { - return const Center( - child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)), + return Center( + child: Text( + 'No custom cards yet.', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), ); } return _buildCustomGridView(_customDeck); } Widget _buildPaginatedView( - Map> groupedItems, - List sortedLevels, - PageController pageController, - Widget Function(List) buildPageContent) { + Map> groupedItems, + List sortedLevels, + PageController pageController, + Widget Function(List) buildPageContent, + ) { if (sortedLevels.isEmpty) { - return const Center( - child: - Text('No items to display.', style: TextStyle(color: Colors.white)), + return Center( + child: Text( + 'No items to display.', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), ); } @@ -160,10 +174,11 @@ class _BrowseScreenState extends State with SingleTickerProviderSt padding: const EdgeInsets.all(16.0), child: Text( 'Level $level', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold), + style: TextStyle( + fontSize: 24, + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), ), ), Expanded(child: buildPageContent(levelItems)), @@ -183,7 +198,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt return Container( padding: const EdgeInsets.symmetric(vertical: 8.0), - color: const Color(0xFF1F1F1F), + color: Theme.of(context).colorScheme.surfaceContainer, height: 60, child: SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -196,11 +211,17 @@ class _BrowseScreenState extends State with SingleTickerProviderSt padding: const EdgeInsets.symmetric(horizontal: 4.0), child: ElevatedButton( onPressed: () { - controller.animateToPage(index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); + controller.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, style: ElevatedButton.styleFrom( - backgroundColor: isSelected ? Colors.blueAccent : const Color(0xFF333333), - foregroundColor: Colors.white, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: Theme.of(context).colorScheme.onPrimary, shape: const CircleBorder(), padding: const EdgeInsets.all(12), ), @@ -245,9 +266,9 @@ class _BrowseScreenState extends State with SingleTickerProviderSt Widget _buildVocabListTile(VocabularyItem item) { final requiredModes = [ - VocabQuizMode.vocabToEnglish.toString(), - VocabQuizMode.englishToVocab.toString(), - VocabQuizMode.audioToEnglish.toString(), + QuizMode.vocabToEnglish.toString(), + QuizMode.englishToVocab.toString(), + QuizMode.audioToEnglish.toString(), ]; int minSrsStage = 9; @@ -266,7 +287,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt return GestureDetector( onTap: () => _showVocabDetailsDialog(context, item), child: Card( - color: const Color(0xFF1E1E1E), + color: Theme.of(context).colorScheme.surfaceContainer, margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Padding( padding: const EdgeInsets.all(12.0), @@ -275,7 +296,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt Expanded( child: Text( item.characters, - style: const TextStyle(fontSize: 24, color: Colors.white), + style: TextStyle(fontSize: 24, color: Theme.of(context).colorScheme.onSurface), ), ), const SizedBox(width: 16), @@ -286,7 +307,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt children: [ Text( item.meanings.join(', '), - style: const TextStyle(color: Colors.grey), + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), @@ -327,7 +348,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } return Card( - color: const Color(0xFF1E1E1E), + color: Theme.of(context).colorScheme.surfaceContainer, child: Padding( padding: const EdgeInsets.all(8.0), child: Column( @@ -335,7 +356,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt children: [ Text( item.characters, - style: const TextStyle(fontSize: 32, color: Colors.white), + style: TextStyle(fontSize: 32, color: Theme.of(context).colorScheme.onSurface), textAlign: TextAlign.center, ), const SizedBox(height: 8), @@ -354,8 +375,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt child: SizedBox( height: 10, child: LinearProgressIndicator( - value: level / 9.0, // Max SRS level is 9 - backgroundColor: Colors.grey[800], + value: level / 9.0, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation( _getColorForSrsLevel(level), ), @@ -366,13 +387,17 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } Color _getColorForSrsLevel(int level) { - if (level >= 9) return Colors.purple; - if (level >= 8) return Colors.blue; - if (level >= 7) return Colors.lightBlue; - if (level >= 5) return Colors.green; - if (level >= 3) return Colors.yellow; - if (level >= 1) return Colors.orange; - return Colors.red; + final srsColors = Theme.of(context).srsColors; + if (level >= 9) return srsColors.level9; + if (level >= 8) return srsColors.level8; + if (level >= 7) return srsColors.level7; + if (level >= 6) return srsColors.level6; + if (level >= 5) return srsColors.level5; + if (level >= 4) return srsColors.level4; + if (level >= 3) return srsColors.level3; + if (level >= 2) return srsColors.level2; + if (level >= 1) return srsColors.level1; + return Colors.grey; } void _showReadingsDialog(KanjiItem kanji) { @@ -399,17 +424,19 @@ class _BrowseScreenState extends State with SingleTickerProviderSt srsScores['Reading (kunyomi)'] = srsItem.srsStage; } break; + default: + break; } } showDialog( context: context, - builder: (context) { + builder: (dialogContext) { return AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, title: Text( 'Details for ${kanji.characters}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), content: SingleChildScrollView( child: Column( @@ -418,50 +445,56 @@ class _BrowseScreenState extends State with SingleTickerProviderSt children: [ Text( 'Level: ${kanji.level}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), if (kanji.meanings.isNotEmpty) Text( 'Meanings: ${kanji.meanings.join(', ')}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), if (kanji.onyomi.isNotEmpty) Text( 'On\'yomi: ${kanji.onyomi.join(', ')}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), if (kanji.kunyomi.isNotEmpty) Text( 'Kun\'yomi: ${kanji.kunyomi.join(', ')}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) - const Text( + Text( 'No readings available.', - style: TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), - const Divider(color: Colors.grey), + Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: 16), - const Text( + Text( 'SRS Scores:', style: TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ...srsScores.entries.map( + (entry) => Text( + ' ${entry.key}: ${entry.value}', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), ), - ...srsScores.entries.map((entry) => Text( - ' ${entry.key}: ${entry.value}', - style: const TextStyle(color: Colors.white), - )), ], ), ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close', - style: TextStyle(color: Colors.blueAccent)), + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + 'Close', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), ), ], ); @@ -473,8 +506,10 @@ class _BrowseScreenState extends State with SingleTickerProviderSt setState(() => _loading = true); try { final kanjiRepo = Provider.of(context, listen: false); - final vocabRepo = - Provider.of(context, listen: false); + final vocabRepo = Provider.of( + context, + listen: false, + ); await kanjiRepo.loadApiKey(); final apiKey = kanjiRepo.apiKey; @@ -537,9 +572,9 @@ class _BrowseScreenState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { return Scaffold( - appBar: - _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), - backgroundColor: const Color(0xFF121212), + appBar: _isSelectionMode + ? _buildSelectionAppBar() + : _buildDefaultAppBar(), body: Column( children: [ Expanded( @@ -548,18 +583,19 @@ class _BrowseScreenState extends State with SingleTickerProviderSt children: [ _buildWaniKaniTab( _buildPaginatedView( - _kanjiByLevel, - _kanjiSortedLevels, - _kanjiPageController, - (items) => _buildGridView(items.cast())), + _kanjiByLevel, + _kanjiSortedLevels, + _kanjiPageController, + (items) => _buildGridView(items.cast()), + ), ), _buildWaniKaniTab( _buildPaginatedView( - _vocabByLevel, - _vocabSortedLevels, - _vocabPageController, - (items) => - _buildListView(items.cast())), + _vocabByLevel, + _vocabSortedLevels, + _vocabPageController, + (items) => _buildListView(items.cast()), + ), ), _buildCustomSrsTab(), ], @@ -577,9 +613,10 @@ class _BrowseScreenState extends State with SingleTickerProviderSt floatingActionButton: _tabController.index == 2 ? FloatingActionButton( onPressed: () async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => AddCardScreen()), - ); + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => AddCardScreen())); + if (!mounted) return; _loadCustomDeck(); }, child: const Icon(Icons.add), @@ -615,14 +652,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt ), 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.select_all), onPressed: _selectAll), + IconButton(icon: const Icon(Icons.delete), onPressed: _deleteSelected), IconButton( icon: Icon(_toggleIntervalIcon), onPressed: _toggleIntervalForSelected, @@ -643,7 +674,8 @@ class _BrowseScreenState extends State with SingleTickerProviderSt setState(() { if (_selectedItems.length == _customDeck.length) { _selectedItems.clear(); - } else { + } + else { _selectedItems = List.from(_customDeck); } }); @@ -652,28 +684,30 @@ class _BrowseScreenState extends State with SingleTickerProviderSt void _deleteSelected() { showDialog( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Delete Selected'), - content: - Text('Are you sure you want to delete ${_selectedItems.length} cards?'), + content: Text( + 'Are you sure you want to delete ${_selectedItems.length} cards?', + ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), TextButton( - onPressed: () async { - final navigator = Navigator.of(context); - for (final item in _selectedItems) { - await _customDeckRepository.deleteCard(item); - } - setState(() { - _isSelectionMode = false; - _selectedItems.clear(); - }); - await _loadCustomDeck(); - if (!mounted) return; - navigator.pop(); + onPressed: () { + Navigator.of(dialogContext).pop(); + () async { + for (final item in _selectedItems) { + await _customDeckRepository.deleteCard(item); + } + if (!mounted) return; + setState(() { + _isSelectionMode = false; + _selectedItems.clear(); + }); + _loadCustomDeck(); + }(); }, child: const Text('Delete'), ), @@ -688,8 +722,9 @@ class _BrowseScreenState extends State with SingleTickerProviderSt } final bool targetState = _selectedItems.any((item) => !item.useInterval); - final selectedCharacters = - _selectedItems.map((item) => item.characters).toSet(); + final selectedCharacters = _selectedItems + .map((item) => item.characters) + .toSet(); final List updatedItems = []; for (final item in _selectedItems) { @@ -765,13 +800,13 @@ class _BrowseScreenState extends State with SingleTickerProviderSt child: Card( shape: RoundedRectangleBorder( side: isSelected - ? const BorderSide(color: Colors.blue, width: 2.0) + ? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0) : BorderSide.none, borderRadius: BorderRadius.circular(12.0), ), color: isSelected - ? Colors.blue.withAlpha((255 * 0.5).round()) - : const Color(0xFF1E1E1E), + ? Theme.of(context).colorScheme.primary.withAlpha((255 * 0.5).round()) + : Theme.of(context).colorScheme.surfaceContainer, child: Stack( children: [ Padding( @@ -785,27 +820,34 @@ class _BrowseScreenState extends State with SingleTickerProviderSt item.kanji?.isNotEmpty == true ? item.kanji! : item.characters, - style: - const TextStyle(fontSize: 32, color: Colors.white), + style: TextStyle( + fontSize: 32, + color: Theme.of(context).colorScheme.onSurface, + ), textAlign: TextAlign.center, ), ), const SizedBox(height: 8), Text( item.meaning, - style: - const TextStyle(color: Colors.grey, fontSize: 16), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 16, + ), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), - Builder(builder: (context) { - final avgSrs = (item.srsData.japaneseToEnglish + - item.srsData.englishToJapanese + - item.srsData.listeningComprehension) / - 3; - return _buildSrsIndicator(avgSrs.round()); - }), + Builder( + builder: (context) { + final avgSrs = + (item.srsData.japaneseToEnglish + + item.srsData.englishToJapanese + + item.srsData.listeningComprehension) / + 3; + return _buildSrsIndicator(avgSrs.round()); + }, + ), ], ), ), @@ -813,11 +855,7 @@ class _BrowseScreenState extends State with SingleTickerProviderSt Positioned( top: 4, right: 4, - child: Icon( - Icons.timer, - color: Colors.green, - size: 16, - ), + child: Icon(Icons.timer, color: Theme.of(context).colorScheme.tertiary, size: 16), ), ], ), @@ -848,9 +886,11 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { } Future _fetchExampleSentences() async { + final theme = Theme.of(context); try { final uri = Uri.parse( - 'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}'); + 'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}', + ); final response = await http.get(uri); if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); @@ -861,15 +901,24 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { (result['japanese'] as List).isNotEmpty && result['senses'] != null && (result['senses'] as List).isNotEmpty) { - final japaneseWord = result['japanese'][0]['word'] ?? result['japanese'][0]['reading']; - final englishDefinition = result['senses'][0]['english_definitions'].join(', '); + final japaneseWord = + result['japanese'][0]['word'] ?? + result['japanese'][0]['reading']; + final englishDefinition = + result['senses'][0]['english_definitions'].join(', '); if (japaneseWord != null && englishDefinition != null) { sentences.add( Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(japaneseWord, style: const TextStyle(color: Colors.white)), - Text(englishDefinition, style: const TextStyle(color: Colors.grey)), + Text( + japaneseWord, + style: TextStyle(color: theme.colorScheme.onSurface), + ), + Text( + englishDefinition, + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), const SizedBox(height: 8), ], ), @@ -879,7 +928,12 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { } } if (sentences.isEmpty) { - sentences.add(const Text('No example sentences found.', style: TextStyle(color: Colors.white))); + sentences.add( + Text( + 'No example sentences found.', + style: TextStyle(color: theme.colorScheme.onSurface), + ), + ); } if (mounted) { setState(() { @@ -890,7 +944,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { if (mounted) { setState(() { _exampleSentences = [ - const Text('Failed to load example sentences.', style: TextStyle(color: Colors.red)) + Text( + 'Failed to load example sentences.', + style: TextStyle(color: theme.colorScheme.error), + ), ]; }); } @@ -899,7 +956,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { if (mounted) { setState(() { _exampleSentences = [ - const Text('Error loading example sentences.', style: TextStyle(color: Colors.red)) + Text( + 'Error loading example sentences.', + style: TextStyle(color: theme.colorScheme.error), + ), ]; }); } @@ -908,24 +968,22 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { @override Widget build(BuildContext context) { - final srsScores = { - 'JP -> EN': 0, - 'EN -> JP': 0, - 'Audio': 0, - }; + final srsScores = {'JP -> EN': 0, 'EN -> JP': 0, 'Audio': 0}; for (final entry in widget.vocab.srsItems.entries) { final srsItem = entry.value; switch (srsItem.quizMode) { - case VocabQuizMode.vocabToEnglish: + case QuizMode.vocabToEnglish: srsScores['JP -> EN'] = srsItem.srsStage; break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: srsScores['EN -> JP'] = srsItem.srsStage; break; - case VocabQuizMode.audioToEnglish: + case QuizMode.audioToEnglish: srsScores['Audio'] = srsItem.srsStage; break; + default: + break; } } @@ -936,37 +994,39 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { children: [ Text( 'Level: ${widget.vocab.level}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), if (widget.vocab.meanings.isNotEmpty) Text( 'Meanings: ${widget.vocab.meanings.join(', ')}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), if (widget.vocab.readings.isNotEmpty) Text( 'Readings: ${widget.vocab.readings.join(', ')}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), const SizedBox(height: 16), - const Divider(color: Colors.grey), + Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: 16), - const Text( + Text( 'SRS Scores:', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), + ), + ...srsScores.entries.map( + (entry) => Text( + ' ${entry.key}: ${entry.value}', + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), + ), ), - ...srsScores.entries.map((entry) => Text( - ' ${entry.key}: ${entry.value}', - style: const TextStyle(color: Colors.white), - )), const SizedBox(height: 16), - const Divider(color: Colors.grey), + Divider(color: Theme.of(context).colorScheme.onSurfaceVariant), const SizedBox(height: 16), - const Text( + Text( 'Example Sentences:', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold), ), ..._exampleSentences, ], @@ -978,21 +1038,25 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> { void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) { showDialog( context: context, - builder: (context) { + builder: (dialogContext) { return AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, title: Text( 'Details for ${vocab.characters}', - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), ), content: _VocabDetailsDialog(vocab: vocab), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Close', style: TextStyle(color: Colors.blueAccent)), + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text( + 'Close', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), ), ], ); }, ); -} \ 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 index 6c3f2f3..cf98ff7 100644 --- a/lib/src/screens/custom_card_details_screen.dart +++ b/lib/src/screens/custom_card_details_screen.dart @@ -6,8 +6,11 @@ class CustomCardDetailsScreen extends StatefulWidget { final CustomKanjiItem item; final CustomDeckRepository repository; - const CustomCardDetailsScreen( - {super.key, required this.item, required this.repository}); + const CustomCardDetailsScreen({ + super.key, + required this.item, + required this.repository, + }); @override State createState() => @@ -41,7 +44,9 @@ class _CustomCardDetailsScreenState extends State { final updatedItem = CustomKanjiItem( characters: _japaneseController.text, meaning: _englishController.text, - kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, + kanji: _kanjiController.text.trim().isNotEmpty + ? _kanjiController.text.trim() + : null, useInterval: _useInterval, srsData: widget.item.srsData, ); @@ -79,10 +84,7 @@ class _CustomCardDetailsScreenState extends State { appBar: AppBar( title: const Text('Edit Card'), actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: _deleteCard, - ), + IconButton(icon: const Icon(Icons.delete), onPressed: _deleteCard), ], ), body: Padding( @@ -111,10 +113,19 @@ class _CustomCardDetailsScreenState extends State { }, ), const SizedBox(height: 20), - const Text('SRS Levels', style: TextStyle(fontWeight: FontWeight.bold)), - Text('Jpn→Eng: ${widget.item.srsData.japaneseToEnglish} (Next review: ${widget.item.srsData.japaneseToEnglishNextReview?.toString() ?? 'N/A'})'), - Text('Eng→Jpn: ${widget.item.srsData.englishToJapanese} (Next review: ${widget.item.srsData.englishToJapaneseNextReview?.toString() ?? 'N/A'})'), - Text('Listening: ${widget.item.srsData.listeningComprehension} (Next review: ${widget.item.srsData.listeningComprehensionNextReview?.toString() ?? 'N/A'})'), + const Text( + 'SRS Levels', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + 'Jpn→Eng: ${widget.item.srsData.japaneseToEnglish} (Next review: ${widget.item.srsData.japaneseToEnglishNextReview?.toString() ?? 'N/A'})', + ), + Text( + 'Eng→Jpn: ${widget.item.srsData.englishToJapanese} (Next review: ${widget.item.srsData.englishToJapaneseNextReview?.toString() ?? 'N/A'})', + ), + Text( + 'Listening: ${widget.item.srsData.listeningComprehension} (Next review: ${widget.item.srsData.listeningComprehensionNextReview?.toString() ?? 'N/A'})', + ), const SizedBox(height: 20), ElevatedButton( onPressed: _saveChanges, diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart index aaf8040..2dcc1ab 100644 --- a/lib/src/screens/custom_quiz_screen.dart +++ b/lib/src/screens/custom_quiz_screen.dart @@ -5,7 +5,11 @@ import '../models/custom_kanji_item.dart'; import '../widgets/options_grid.dart'; import '../widgets/kanji_card.dart'; -enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension } +enum CustomQuizMode { + japaneseToEnglish, + englishToJapanese, + listeningComprehension, +} class CustomQuizScreen extends StatefulWidget { final List deck; @@ -53,10 +57,7 @@ class CustomQuizScreenState extends State vsync: this, ); _shakeAnimation = Tween(begin: 0, end: 1).animate( - CurvedAnimation( - parent: _shakeController, - curve: Curves.elasticIn, - ), + CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn), ); } @@ -82,7 +83,8 @@ class CustomQuizScreenState extends State } void playAudio() { - if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) { + if (widget.quizMode == CustomQuizMode.listeningComprehension && + _currentIndex < _shuffledDeck.length) { _speak(_shuffledDeck[_currentIndex].characters); } } @@ -101,20 +103,30 @@ class CustomQuizScreenState extends State void _generateOptions() { final currentItem = _shuffledDeck[_currentIndex]; - if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) { + if (widget.quizMode == CustomQuizMode.listeningComprehension || + widget.quizMode == CustomQuizMode.japaneseToEnglish) { _options = [currentItem.meaning]; } else { - _options = [widget.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) .toList(); otherItems.shuffle(); for (var i = 0; i < min(3, otherItems.length); i++) { - if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) { + 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.add( + widget.useKanji && otherItems[i].kanji != null + ? otherItems[i].kanji! + : otherItems[i].characters, + ); } } _options.shuffle(); @@ -123,7 +135,9 @@ class CustomQuizScreenState extends State void _checkAnswer(String answer) async { final currentItem = _shuffledDeck[_currentIndex]; final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) - ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) + ? (widget.useKanji && currentItem.kanji != null + ? currentItem.kanji! + : currentItem.characters) : currentItem.meaning; final isCorrect = answer == correctAnswer; @@ -157,7 +171,8 @@ class CustomQuizScreenState extends State currentItem.srsData.englishToJapaneseNextReview = newNextReview; break; case CustomQuizMode.listeningComprehension: - currentItem.srsData.listeningComprehensionNextReview = newNextReview; + currentItem.srsData.listeningComprehensionNextReview = + newNextReview; break; } } else { @@ -174,7 +189,8 @@ class CustomQuizScreenState extends State currentItem.srsData.englishToJapaneseNextReview = newNextReview; break; case CustomQuizMode.listeningComprehension: - currentItem.srsData.listeningComprehensionNextReview = newNextReview; + currentItem.srsData.listeningComprehensionNextReview = + newNextReview; break; } } @@ -194,35 +210,35 @@ class CustomQuizScreenState extends State widget.onCardReviewed(currentItem); } - // --- SnackBar Logic (new) --- final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese) - ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) + ? (widget.useKanji && currentItem.kanji != null + ? currentItem.kanji! + : currentItem.characters) : currentItem.meaning; final snack = SnackBar( content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( - color: isCorrect ? Colors.greenAccent : Colors.redAccent, + color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error, fontWeight: FontWeight.bold, ), ), - backgroundColor: const Color(0xFF222222), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 900), ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(snack); } - // --- End SnackBar Logic --- if (isCorrect) { if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { await _speak(currentItem.characters); } - await Future.delayed(const Duration(milliseconds: 500)); // Small delay after correct answer + await Future.delayed(const Duration(milliseconds: 500)); } else { _shakeController.forward(from: 0); - await Future.delayed(const Duration(milliseconds: 900)); // Delay for shake animation + await Future.delayed(const Duration(milliseconds: 900)); } _nextQuestion(); @@ -255,15 +271,15 @@ class CustomQuizScreenState extends State @override Widget build(BuildContext context) { if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) { - return const Center( - child: Text('Review session complete!'), - ); + return Center(child: Text('Review session complete!', style: TextStyle(color: Theme.of(context).colorScheme.onSurface))); } final currentItem = _shuffledDeck[_currentIndex]; final question = (widget.quizMode == CustomQuizMode.englishToJapanese) ? currentItem.meaning - : (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); + : (widget.useKanji && currentItem.kanji != null + ? currentItem.kanji! + : currentItem.characters); Widget promptWidget; if (widget.quizMode == CustomQuizMode.listeningComprehension) { @@ -276,7 +292,7 @@ class CustomQuizScreenState extends State onTap: () => _speak(question), child: Text( question, - style: const TextStyle(fontSize: 48), + style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface), textAlign: TextAlign.center, ), ); @@ -296,10 +312,7 @@ class CustomQuizScreenState extends State maxWidth: 500, minHeight: 150, ), - child: KanjiCard( - characterWidget: promptWidget, - subtitle: '', - ), + child: KanjiCard(characterWidget: promptWidget, subtitle: ''), ), ), ), @@ -331,4 +344,4 @@ class CustomQuizScreenState extends State ), ); } -} \ No newline at end of file +} diff --git a/lib/src/screens/custom_srs_screen.dart b/lib/src/screens/custom_srs_screen.dart index 8a4861c..ad3c432 100644 --- a/lib/src/screens/custom_srs_screen.dart +++ b/lib/src/screens/custom_srs_screen.dart @@ -10,7 +10,8 @@ class CustomSrsScreen extends StatefulWidget { State createState() => _CustomSrsScreenState(); } -class _CustomSrsScreenState extends State with SingleTickerProviderStateMixin { +class _CustomSrsScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; final _deckRepository = CustomDeckRepository(); List _deck = []; @@ -49,7 +50,9 @@ class _CustomSrsScreenState extends State with SingleTickerProv } Future _updateCard(CustomKanjiItem item) async { - final index = _deck.indexWhere((element) => element.characters == item.characters); + final index = _deck.indexWhere( + (element) => element.characters == item.characters, + ); if (index != -1) { setState(() { _deck[index] = item; @@ -79,7 +82,8 @@ class _CustomSrsScreenState extends State with SingleTickerProv item.srsData.listeningComprehensionNextReview!.isBefore(now); }).toList(); - final allDecksEmpty = jpnToEngReviewDeck.isEmpty && + final allDecksEmpty = + jpnToEngReviewDeck.isEmpty && engToJpnReviewDeck.isEmpty && listeningReviewDeck.isEmpty; @@ -114,8 +118,8 @@ class _CustomSrsScreenState extends State with SingleTickerProv body: _deck.isEmpty ? const Center(child: Text('Add cards to start quizzing!')) : allDecksEmpty - ? const Center(child: Text('No cards due for review.')) - : TabBarView( + ? const Center(child: Text('No cards due for review.')) + : TabBarView( controller: _tabController, children: [ CustomQuizScreen( diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index 7f292ad..4725dea 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/kanji_item.dart'; +import '../models/srs_item.dart'; import '../services/deck_repository.dart'; import '../services/distractor_generator.dart'; import '../widgets/kanji_card.dart'; @@ -39,7 +40,8 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _HomeScreenState extends State with SingleTickerProviderStateMixin { +class _HomeScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; List _deck = []; bool _loading = false; @@ -60,9 +62,6 @@ class _HomeScreenState extends State with SingleTickerProviderStateM super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { - if (_tabController.indexIsChanging) { - _nextQuestion(); - } setState(() {}); }); _dg = widget.distractorGenerator ?? DistractorGenerator(); @@ -171,8 +170,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM _deck.sort((a, b) { int getSrsStage(KanjiItem item) { if (mode == QuizMode.reading) { - final onyomiStage = item.srsItems['${QuizMode.reading}onyomi']?.srsStage; - final kunyomiStage = item.srsItems['${QuizMode.reading}kunyomi']?.srsStage; + final onyomiStage = + item.srsItems['${QuizMode.reading}onyomi']?.srsStage; + final kunyomiStage = + item.srsItems['${QuizMode.reading}kunyomi']?.srsStage; if (onyomiStage != null && kunyomiStage != null) { return min(onyomiStage, kunyomiStage); @@ -184,8 +185,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM DateTime getLastAsked(KanjiItem item) { if (mode == QuizMode.reading) { - final onyomiLastAsked = item.srsItems['${QuizMode.reading}onyomi']?.lastAsked; - final kunyomiLastAsked = item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked; + final onyomiLastAsked = + item.srsItems['${QuizMode.reading}onyomi']?.lastAsked; + final kunyomiLastAsked = + item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked; if (onyomiLastAsked != null && kunyomiLastAsked != null) { return onyomiLastAsked.isBefore(kunyomiLastAsked) @@ -227,16 +230,15 @@ class _HomeScreenState extends State with SingleTickerProviderStateM quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.options = [ quizState.correctAnswers.first, - ..._dg.generateMeanings(quizState.current!, _deck, 3) - ].map(_toTitleCase).toList() - ..shuffle(); + ..._dg.generateMeanings(quizState.current!, _deck, 3), + ].map(_toTitleCase).toList()..shuffle(); break; case QuizMode.englishToKanji: quizState.correctAnswers = [quizState.current!.characters]; quizState.options = [ quizState.correctAnswers.first, - ..._dg.generateKanji(quizState.current!, _deck, 3) + ..._dg.generateKanji(quizState.current!, _deck, 3), ]..shuffle(); break; @@ -249,16 +251,22 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ? _deck.expand((k) => k.onyomi) : _deck.expand((k) => k.kunyomi); - final distractors = readingsSource - .where((r) => !quizState.correctAnswers.contains(r)) - .toSet() - .toList() + final distractors = + readingsSource + .where((r) => !quizState.correctAnswers.contains(r)) + .toSet() + .toList() ..shuffle(); quizState.options = ([ - quizState.correctAnswers[_random.nextInt(quizState.correctAnswers.length)], - ...distractors.take(3) - ]) - ..shuffle(); + quizState.correctAnswers[_random.nextInt( + quizState.correctAnswers.length, + )], + ...distractors.take(3), + ])..shuffle(); + break; + default: + // Handle other QuizMode cases if necessary, or throw an error + // if these modes are not expected in this context. break; } @@ -279,26 +287,29 @@ class _HomeScreenState extends State with SingleTickerProviderStateM String readingType = ''; if (mode == QuizMode.reading) { - readingType = quizState.readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi'; + readingType = quizState.readingHint.contains("on'yomi") + ? 'onyomi' + : 'kunyomi'; } final srsKey = mode.toString() + readingType; var srsItem = current.srsItems[srsKey]; final isNew = srsItem == null; - final srsItemForUpdate = srsItem ??= - SrsItem(kanjiId: current.id, quizMode: mode, readingType: readingType); - - quizState.asked += 1; - - quizState.selectedOption = option; - - quizState.showResult = true; - - setState(() {}); // Trigger UI rebuild to show selected/correct colors - - - - if (isCorrect) { + final srsItemForUpdate = srsItem ??= SrsItem( + subjectId: current.id, + quizMode: mode, + readingType: readingType, + ); + + quizState.asked += 1; + + quizState.selectedOption = option; + + quizState.showResult = true; + + setState(() {}); + + if (isCorrect) { quizState.score += 1; srsItemForUpdate.srsStage += 1; if (_playCorrectSound) { @@ -310,6 +321,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM srsItemForUpdate.lastAsked = DateTime.now(); current.srsItems[srsKey] = srsItemForUpdate; + final scaffoldMessenger = ScaffoldMessenger.of(context); + final theme = Theme.of(context); + if (isNew) { await repo.insertSrsItem(srsItemForUpdate); } else { @@ -319,29 +333,35 @@ class _HomeScreenState extends State with SingleTickerProviderStateM final correctDisplay = (mode == QuizMode.kanjiToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : (mode == QuizMode.reading - ? quizState.correctAnswers.join(', ') - : quizState.correctAnswers.first); + ? quizState.correctAnswers.join(', ') + : quizState.correctAnswers.first); final snack = SnackBar( content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( - color: isCorrect ? Colors.greenAccent : Colors.redAccent, + color: isCorrect + ? theme.colorScheme.primary + : theme.colorScheme.error, fontWeight: FontWeight.bold, ), ), - backgroundColor: const Color(0xFF222222), + backgroundColor: theme.colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 900), ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(snack); + scaffoldMessenger.showSnackBar(snack); } setState(() { - _isAnswering = true; // Disable input after showing result + _isAnswering = true; }); - Future.delayed(const Duration(milliseconds: 900), () => _nextQuestion()); + Future.delayed(const Duration(milliseconds: 900), () { + if (mounted) { + _nextQuestion(); + } + }); } @override @@ -353,7 +373,12 @@ class _HomeScreenState extends State with SingleTickerProviderStateM child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)), + Text( + 'WaniKani API key is not set.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), const SizedBox(height: 16), ElevatedButton( onPressed: () async { @@ -382,14 +407,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ], ), ), - backgroundColor: const Color(0xFF121212), + backgroundColor: Theme.of(context).colorScheme.surface, body: TabBarView( controller: _tabController, - children: [ - _buildQuizPage(0), - _buildQuizPage(1), - _buildQuizPage(2), - ], + children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)], ), ); } @@ -413,6 +434,10 @@ class _HomeScreenState extends State with SingleTickerProviderStateM prompt = quizState.current!.characters; subtitle = quizState.readingHint; break; + default: + // Handle other QuizMode cases if necessary, or throw an error + // if these modes are not expected in this context. + break; } } @@ -426,11 +451,15 @@ class _HomeScreenState extends State with SingleTickerProviderStateM Expanded( child: Text( _status, - style: const TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ), if (_loading) - const CircularProgressIndicator(color: Colors.blueAccent), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), ], ), const SizedBox(height: 18), @@ -446,8 +475,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM child: KanjiCard( characters: prompt, subtitle: subtitle, - backgroundColor: const Color(0xFF1E1E1E), - textColor: Colors.white, + backgroundColor: Theme.of(context).colorScheme.surface, + textColor: Theme.of(context).colorScheme.onSurface, ), ), ), @@ -468,7 +497,9 @@ class _HomeScreenState extends State with SingleTickerProviderStateM const SizedBox(height: 8), Text( 'Score: ${quizState.score} / ${quizState.asked}', - style: const TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ], ), @@ -477,4 +508,4 @@ class _HomeScreenState extends State with SingleTickerProviderStateM ), ); } -} \ No newline at end of file +} diff --git a/lib/src/screens/settings_screen.dart b/lib/src/screens/settings_screen.dart index 2d01a11..25b85e7 100644 --- a/lib/src/screens/settings_screen.dart +++ b/lib/src/screens/settings_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:hirameki_srs/src/models/theme_model.dart'; +import 'package:hirameki_srs/src/themes.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/deck_repository.dart'; @@ -50,24 +52,26 @@ class _SettingsScreenState extends State { await repo.setApiKey(apiKey); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('API key saved!')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('API key saved!'))); - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const HomeScreen()), - ); + Navigator.of( + context, + ).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen())); } } @override Widget build(BuildContext context) { + final themeModel = Provider.of(context); + return Scaffold( - backgroundColor: const Color(0xFF121212), + backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( title: const Text('Settings'), - backgroundColor: const Color(0xFF1F1F1F), - foregroundColor: Colors.white, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + foregroundColor: Theme.of(context).colorScheme.onSurface, ), body: Padding( padding: const EdgeInsets.all(16.0), @@ -76,15 +80,19 @@ class _SettingsScreenState extends State { TextField( controller: _apiKeyController, obscureText: true, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface), decoration: InputDecoration( labelText: 'WaniKani API Key', - labelStyle: const TextStyle(color: Colors.grey), + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), filled: true, - fillColor: const Color(0xFF1E1E1E), + fillColor: Theme.of(context).colorScheme.surfaceContainer, border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), - borderSide: const BorderSide(color: Colors.grey), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ), @@ -92,16 +100,18 @@ class _SettingsScreenState extends State { ElevatedButton( onPressed: _saveApiKey, style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueAccent, - foregroundColor: Colors.white, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, ), child: const Text('Save & Start Quiz'), ), const SizedBox(height: 24), SwitchListTile( - title: const Text( + title: Text( 'Play audio for vocabulary', - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), value: _playAudio, onChanged: (value) async { @@ -111,18 +121,22 @@ class _SettingsScreenState extends State { _playAudio = value; }); }, - activeThumbColor: Colors.blueAccent, - inactiveThumbColor: Colors.grey, - tileColor: const Color(0xFF1E1E1E), + activeThumbColor: Theme.of(context).colorScheme.primary, + inactiveThumbColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + tileColor: Theme.of(context).colorScheme.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), ), const SizedBox(height: 12), SwitchListTile( - title: const Text( + title: Text( 'Play sound on correct answer', - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), value: _playCorrectSound, onChanged: (value) async { @@ -132,9 +146,50 @@ class _SettingsScreenState extends State { _playCorrectSound = value; }); }, - activeThumbColor: Colors.blueAccent, - inactiveThumbColor: Colors.grey, - tileColor: const Color(0xFF1E1E1E), + activeThumbColor: Theme.of(context).colorScheme.primary, + inactiveThumbColor: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + tileColor: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 12), + ListTile( + title: Text( + 'Theme', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + trailing: DropdownButton( + value: themeModel.currentTheme, + dropdownColor: Theme.of(context).colorScheme.surfaceContainer, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + items: [ + DropdownMenuItem( + value: Themes.dark, + child: const Text('Dark'), + ), + DropdownMenuItem( + value: Themes.light, + child: const Text('Light'), + ), + DropdownMenuItem( + value: Themes.nier, + child: const Text('Nier'), + ), + ], + onChanged: (theme) { + if (theme != null) { + themeModel.setTheme(theme); + } + }, + ), + tileColor: Theme.of(context).colorScheme.surfaceContainer, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart index 61876c5..adb2ad9 100644 --- a/lib/src/screens/start_screen.dart +++ b/lib/src/screens/start_screen.dart @@ -28,8 +28,8 @@ class StartScreen extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - const Color(0xFF121212), - Colors.grey[900]!, + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surface, ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -113,14 +113,18 @@ class StartScreen extends StatelessWidget { const SizedBox(height: 16), Text( title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Expanded( child: Text( description, - style: const TextStyle(fontSize: 12, color: Colors.grey), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, softWrap: true, ), diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart index cc7773e..14a1cea 100644 --- a/lib/src/screens/vocab_screen.dart +++ b/lib/src/screens/vocab_screen.dart @@ -3,7 +3,8 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import '../services/distractor_generator.dart'; import '../widgets/kanji_card.dart'; @@ -31,7 +32,8 @@ class VocabScreen extends StatefulWidget { State createState() => _VocabScreenState(); } -class _VocabScreenState extends State with SingleTickerProviderStateMixin { +class _VocabScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; List _deck = []; bool _loading = false; @@ -52,9 +54,6 @@ class _VocabScreenState extends State with SingleTickerProviderStat super.initState(); _tabController = TabController(length: 3, vsync: this); _tabController.addListener(() { - if (_tabController.indexIsChanging) { - _nextQuestion(); - } setState(() {}); }); _loadSettings(); @@ -129,16 +128,16 @@ class _VocabScreenState extends State with SingleTickerProviderStat .join(' '); } - VocabQuizMode _modeForIndex(int index) { + QuizMode _modeForIndex(int index) { switch (index) { case 0: - return VocabQuizMode.vocabToEnglish; + return QuizMode.vocabToEnglish; case 1: - return VocabQuizMode.englishToVocab; + return QuizMode.englishToVocab; case 2: - return VocabQuizMode.audioToEnglish; + return QuizMode.audioToEnglish; default: - return VocabQuizMode.vocabToEnglish; + return QuizMode.vocabToEnglish; } } @@ -149,8 +148,10 @@ class _VocabScreenState extends State with SingleTickerProviderStat final mode = _modeForIndex(index ?? _tabController.index); List currentDeckForMode = _deck; - if (mode == VocabQuizMode.audioToEnglish) { - currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList(); + if (mode == QuizMode.audioToEnglish) { + currentDeckForMode = _deck + .where((item) => item.pronunciationAudios.isNotEmpty) + .toList(); if (currentDeckForMode.isEmpty) { setState(() { _status = 'No vocabulary with audio found.'; @@ -160,13 +161,16 @@ class _VocabScreenState extends State with SingleTickerProviderStat } } - // If it's a new session or we've gone through all shuffled items, re-shuffle - if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) { - quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy - // Apply sorting based on SRS stages here, but only once per shuffle + if (quizState.shuffledDeck.isEmpty || + quizState.currentIndex >= quizState.shuffledDeck.length) { + quizState.shuffledDeck = currentDeckForMode.toList(); quizState.shuffledDeck.sort((a, b) { - final aSrsItem = a.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: a.id, quizMode: mode); - final bSrsItem = b.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: b.id, quizMode: mode); + final aSrsItem = + a.srsItems[mode.toString()] ?? + SrsItem(subjectId: a.id, quizMode: mode); + final bSrsItem = + b.srsItems[mode.toString()] ?? + SrsItem(subjectId: b.id, quizMode: mode); final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage); if (stageComparison != 0) { return stageComparison; @@ -176,37 +180,34 @@ class _VocabScreenState extends State with SingleTickerProviderStat quizState.currentIndex = 0; } - quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck - quizState.currentIndex++; // Advance index + quizState.current = quizState.shuffledDeck[quizState.currentIndex]; + quizState.currentIndex++; quizState.key = UniqueKey(); - if (mode == VocabQuizMode.audioToEnglish) { - _playCurrentAudio(); - } - quizState.correctAnswers = []; quizState.options = []; quizState.selectedOption = null; quizState.showResult = false; switch (mode) { - case VocabQuizMode.vocabToEnglish: - case VocabQuizMode.audioToEnglish: + case QuizMode.vocabToEnglish: + case QuizMode.audioToEnglish: quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.options = [ quizState.correctAnswers.first, - ..._dg.generateVocabMeanings(quizState.current!, _deck, 3) - ].map(_toTitleCase).toList() - ..shuffle(); + ..._dg.generateVocabMeanings(quizState.current!, _deck, 3), + ].map(_toTitleCase).toList()..shuffle(); break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: quizState.correctAnswers = [quizState.current!.characters]; quizState.options = [ quizState.correctAnswers.first, - ..._dg.generateVocab(quizState.current!, _deck, 3) + ..._dg.generateVocab(quizState.current!, _deck, 3), ]..shuffle(); break; + default: + break; } setState(() { @@ -214,18 +215,22 @@ class _VocabScreenState extends State with SingleTickerProviderStat }); } - Future _playCurrentAudio() async { + Future _playCurrentAudio({bool playOnLoad = false}) async { final current = _currentQuizState.current; if (current == null || current.pronunciationAudios.isEmpty) return; - final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male'); - final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url); + if (playOnLoad && !_playAudio) return; + + final maleAudios = current.pronunciationAudios.where( + (a) => a.gender == 'male', + ); + final audioUrl = (maleAudios.isNotEmpty + ? maleAudios.first.url + : current.pronunciationAudios.first.url); try { await _audioPlayer.play(UrlSource(audioUrl)); - } catch (e) { - // Ignore player errors - } + } finally {} } void _answer(String option) async { @@ -243,12 +248,12 @@ class _VocabScreenState extends State with SingleTickerProviderStat var srsItemNullable = current.srsItems[srsKey]; final isNew = srsItemNullable == null; final srsItem = - srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode); + srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode); quizState.asked += 1; quizState.selectedOption = option; quizState.showResult = true; - setState(() {}); // Trigger UI rebuild to show selected/correct colors + setState(() {}); if (isCorrect) { quizState.score += 1; @@ -265,32 +270,34 @@ class _VocabScreenState extends State with SingleTickerProviderStat await repo.updateVocabSrsItem(srsItem); } - final correctDisplay = (mode == VocabQuizMode.vocabToEnglish) + final correctDisplay = (mode == QuizMode.vocabToEnglish) ? _toTitleCase(quizState.correctAnswers.first) : quizState.correctAnswers.first; + if (!mounted) return; final snack = SnackBar( content: Text( isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', style: TextStyle( - color: isCorrect ? Colors.greenAccent : Colors.redAccent, + color: isCorrect + ? Theme.of(context).colorScheme.tertiary + : Theme.of(context).colorScheme.error, fontWeight: FontWeight.bold, ), ), - backgroundColor: const Color(0xFF222222), + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, duration: const Duration(milliseconds: 900), ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(snack); - } + ScaffoldMessenger.of(context).showSnackBar(snack); if (isCorrect) { if (_playCorrectSound) { await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); } - if (_playAudio && mode != VocabQuizMode.audioToEnglish) { - final maleAudios = - current.pronunciationAudios.where((a) => a.gender == 'male'); + if (_playAudio) { + final maleAudios = current.pronunciationAudios.where( + (a) => a.gender == 'male', + ); if (maleAudios.isNotEmpty) { final completer = Completer(); final sub = _audioPlayer.onPlayerComplete.listen((event) { @@ -300,19 +307,15 @@ class _VocabScreenState extends State with SingleTickerProviderStat try { await _audioPlayer.play(UrlSource(maleAudios.first.url)); await completer.future.timeout(const Duration(seconds: 5)); - } catch (e) { - // Ignore player errors } finally { await sub.cancel(); } } } - } else { - // No fixed delay for incorrect answers } setState(() { - _isAnswering = true; // Disable input after showing result + _isAnswering = true; }); _nextQuestion(); @@ -327,13 +330,19 @@ class _VocabScreenState extends State with SingleTickerProviderStat child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('WaniKani API key is not set.'), + Text( + 'WaniKani API key is not set.', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + ), const SizedBox(height: 16), ElevatedButton( onPressed: () async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const SettingsScreen()), ); + if (!mounted) return; _loadDeck(); }, child: const Text('Go to Settings'), @@ -358,11 +367,7 @@ class _VocabScreenState extends State with SingleTickerProviderStat ), body: TabBarView( controller: _tabController, - children: [ - _buildQuizPage(0), - _buildQuizPage(1), - _buildQuizPage(2), - ], + children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)], ), ); } @@ -375,25 +380,36 @@ class _VocabScreenState extends State with SingleTickerProviderStat if (quizState.current == null) { promptWidget = const SizedBox.shrink(); - } else if (mode == VocabQuizMode.audioToEnglish) { + } else if (mode == QuizMode.audioToEnglish) { promptWidget = IconButton( - icon: const Icon(Icons.volume_up, color: Colors.white, size: 64), + icon: Icon( + Icons.volume_up, + color: Theme.of(context).colorScheme.onSurface, + size: 64, + ), onPressed: _playCurrentAudio, ); } else { String promptText = ''; switch (mode) { - case VocabQuizMode.vocabToEnglish: + case QuizMode.vocabToEnglish: promptText = quizState.current!.characters; break; - case VocabQuizMode.englishToVocab: + case QuizMode.englishToVocab: promptText = _toTitleCase(quizState.current!.meanings.first); break; - case VocabQuizMode.audioToEnglish: - // Handled above + case QuizMode.audioToEnglish: + break; + default: break; } - promptWidget = Text(promptText, style: const TextStyle(fontSize: 48, color: Colors.white)); + promptWidget = Text( + promptText, + style: TextStyle( + fontSize: 48, + color: Theme.of(context).colorScheme.onSurface, + ), + ); } return Padding( @@ -406,10 +422,15 @@ class _VocabScreenState extends State with SingleTickerProviderStat Expanded( child: Text( _status, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ), if (_loading) - const CircularProgressIndicator(color: Colors.blueAccent), + CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), ], ), const SizedBox(height: 18), @@ -422,10 +443,7 @@ class _VocabScreenState extends State with SingleTickerProviderStat maxWidth: 500, minHeight: 150, ), - child: KanjiCard( - characterWidget: promptWidget, - subtitle: '', - ), + child: KanjiCard(characterWidget: promptWidget, subtitle: ''), ), ), ), @@ -445,7 +463,9 @@ class _VocabScreenState extends State with SingleTickerProviderStat const SizedBox(height: 8), Text( 'Score: ${quizState.score} / ${quizState.asked}', - style: const TextStyle(color: Colors.white), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), ), ], ), @@ -454,4 +474,4 @@ class _VocabScreenState extends State with SingleTickerProviderStat ), ); } -} \ 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 0e21da4..b61790f 100644 --- a/lib/src/services/custom_deck_repository.dart +++ b/lib/src/services/custom_deck_repository.dart @@ -23,12 +23,7 @@ class CustomDeckRepository { } 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); - } + await updateCards([item]); } Future updateCards(List itemsToUpdate) async { diff --git a/lib/src/services/database_constants.dart b/lib/src/services/database_constants.dart new file mode 100644 index 0000000..85f4132 --- /dev/null +++ b/lib/src/services/database_constants.dart @@ -0,0 +1,27 @@ + +class DbConstants { + static const String settingsTable = 'settings'; + static const String kanjiTable = 'kanji'; + static const String srsItemsTable = 'srs_items'; + static const String vocabularyTable = 'vocabulary'; + static const String srsVocabItemsTable = 'srs_vocab_items'; + + static const String keyColumn = 'key'; + static const String valueColumn = 'value'; + + static const String idColumn = 'id'; + static const String levelColumn = 'level'; + static const String charactersColumn = 'characters'; + static const String meaningsColumn = 'meanings'; + static const String onyomiColumn = 'onyomi'; + static const String kunyomiColumn = 'kunyomi'; + static const String readingsColumn = 'readings'; + static const String pronunciationAudiosColumn = 'pronunciation_audios'; + + static const String kanjiIdColumn = 'kanjiId'; + static const String vocabIdColumn = 'vocabId'; + static const String quizModeColumn = 'quizMode'; + static const String readingTypeColumn = 'readingType'; + static const String srsStageColumn = 'srsStage'; + static const String lastAskedColumn = 'lastAsked'; +} diff --git a/lib/src/services/database_helper.dart b/lib/src/services/database_helper.dart new file mode 100644 index 0000000..88da2f5 --- /dev/null +++ b/lib/src/services/database_helper.dart @@ -0,0 +1,92 @@ +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'database_constants.dart'; + +class DatabaseHelper { + static final DatabaseHelper _instance = DatabaseHelper._internal(); + static Database? _db; + + factory DatabaseHelper() { + return _instance; + } + + DatabaseHelper._internal(); + + Future get db async { + if (_db != null) return _db!; + _db = await _openDb(); + return _db!; + } + + Future close() async { + if (_db != null) { + await _db!.close(); + _db = null; + } + } + + Future _openDb() async { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, 'wanikani_srs.db'); + + return openDatabase( + path, + version: 7, + onCreate: (db, version) async { + await db.execute( + '''CREATE TABLE ${DbConstants.kanjiTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.onyomiColumn} TEXT, ${DbConstants.kunyomiColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.levelColumn} INTEGER, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT, ${DbConstants.pronunciationAudiosColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', + ); + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.settingsTable} (${DbConstants.keyColumn} TEXT PRIMARY KEY, ${DbConstants.valueColumn} TEXT)''', + ); + } + if (oldVersion < 4) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsItemsTable} (${DbConstants.kanjiIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.readingTypeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.kanjiIdColumn}, ${DbConstants.quizModeColumn}, ${DbConstants.readingTypeColumn}))''', + ); + } + if (oldVersion < 5) { + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.vocabularyTable} (${DbConstants.idColumn} INTEGER PRIMARY KEY, ${DbConstants.charactersColumn} TEXT, ${DbConstants.meaningsColumn} TEXT, ${DbConstants.readingsColumn} TEXT)''', + ); + await db.execute( + '''CREATE TABLE IF NOT EXISTS ${DbConstants.srsVocabItemsTable} (${DbConstants.vocabIdColumn} INTEGER, ${DbConstants.quizModeColumn} TEXT, ${DbConstants.srsStageColumn} INTEGER, ${DbConstants.lastAskedColumn} TEXT, PRIMARY KEY (${DbConstants.vocabIdColumn}, ${DbConstants.quizModeColumn}))''', + ); + } + if (oldVersion < 6) { + try { + await db.execute( + 'ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.pronunciationAudiosColumn} TEXT', + ); + } catch (_) { + // Ignore error, column might already exist + } + } + if (oldVersion < 7) { + try { + await db.execute('ALTER TABLE ${DbConstants.kanjiTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); + await db.execute('ALTER TABLE ${DbConstants.vocabularyTable} ADD COLUMN ${DbConstants.levelColumn} INTEGER'); + } catch (_) { + // Ignore error, column might already exist + } + } + }, + ); + } +} diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index aeb6b99..2a2db35 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -1,14 +1,14 @@ import 'dart:async'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; import '../models/kanji_item.dart'; +import '../models/srs_item.dart'; import '../api/wk_client.dart'; +import 'database_constants.dart'; +import 'database_helper.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; class DeckRepository { - Database? _db; String? _apiKey; Future setApiKey(String apiKey) async { @@ -18,148 +18,75 @@ class DeckRepository { String? get apiKey => _apiKey; - Future _openDb() async { - if (_db != null) return _db!; - final dir = await getApplicationDocumentsDirectory(); - final path = join(dir.path, 'wanikani_srs.db'); - - _db = await openDatabase( - path, - version: 7, - onCreate: (db, version) async { - await db.execute( - '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''', - ); - await db.execute( - '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', - ); - await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', - ); - }, - onUpgrade: (db, oldVersion, newVersion) async { - if (oldVersion < 2) { - await db.execute( - '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''', - ); - } - if (oldVersion < 3) { - // Migration from version 2 to 3 was flawed, so we just drop the columns if they exist - } - if (oldVersion < 4) { - await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', - ); - // We are not migrating the old srs data, as it was not mode-specific. - // Old columns will be dropped. - } - if (oldVersion < 5) { - await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', - ); - } - if (oldVersion < 6) { - try { - await db.execute( - 'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT', - ); - } catch (_) { - // Ignore error, column might already exist - } - } - if (oldVersion < 7) { - try { - await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER'); - await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER'); - } catch (_) { - // Ignore error, column might already exist - } - } - }, - ); - - return _db!; - } - Future saveApiKey(String apiKey) async { - final db = await _openDb(); - await db.insert('settings', { - 'key': 'apiKey', - 'value': apiKey, + final db = await DatabaseHelper().db; + await db.insert(DbConstants.settingsTable, { + DbConstants.keyColumn: 'apiKey', + DbConstants.valueColumn: apiKey, }, conflictAlgorithm: ConflictAlgorithm.replace); } Future loadApiKey() async { - String? envApiKey; - try { - envApiKey = dotenv.env['WANIKANI_API_KEY']; - } catch (e) { - // dotenv is not initialized, so we can't get the key. - // This is expected in release builds. - envApiKey = null; - } - - if (envApiKey != null && envApiKey.isNotEmpty) { - _apiKey = envApiKey; - return _apiKey; - } - - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query( - 'settings', - where: 'key = ?', + DbConstants.settingsTable, + where: '${DbConstants.keyColumn} = ?', whereArgs: ['apiKey'], ); + if (rows.isNotEmpty) { - _apiKey = rows.first['value'] as String; + _apiKey = rows.first[DbConstants.valueColumn] as String; return _apiKey; } + + try { + final envApiKey = dotenv.env['WANIKANI_API_KEY']; + if (envApiKey != null && envApiKey.isNotEmpty) { + await saveApiKey(envApiKey); + _apiKey = envApiKey; + return _apiKey; + } + } catch (e) { + // dotenv is not initialized + } + return null; } Future saveKanji(List items) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; final batch = db.batch(); for (final it in items) { - batch.insert('kanji', { - 'id': it.id, - 'level': it.level, - 'characters': it.characters, - 'meanings': it.meanings.join('|'), - 'onyomi': it.onyomi.join('|'), - 'kunyomi': it.kunyomi.join('|'), + batch.insert(DbConstants.kanjiTable, { + DbConstants.idColumn: it.id, + DbConstants.levelColumn: it.level, + DbConstants.charactersColumn: it.characters, + DbConstants.meaningsColumn: it.meanings.join('|'), + DbConstants.onyomiColumn: it.onyomi.join('|'), + DbConstants.kunyomiColumn: it.kunyomi.join('|'), }, conflictAlgorithm: ConflictAlgorithm.replace); } await batch.commit(noResult: true); } Future> loadKanji() async { - final db = await _openDb(); - final rows = await db.query('kanji'); + final db = await DatabaseHelper().db; + final rows = await db.query(DbConstants.kanjiTable); final kanjiItems = rows .map( (r) => KanjiItem( - id: r['id'] as int, - level: r['level'] as int? ?? 0, - characters: r['characters'] as String, - meanings: (r['meanings'] as String) + id: r[DbConstants.idColumn] as int, + level: r[DbConstants.levelColumn] as int? ?? 0, + characters: r[DbConstants.charactersColumn] as String, + meanings: (r[DbConstants.meaningsColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), - onyomi: (r['onyomi'] as String) + onyomi: (r[DbConstants.onyomiColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), - kunyomi: (r['kunyomi'] as String) + kunyomi: (r[DbConstants.kunyomiColumn] as String) .split('|') .where((s) => s.isNotEmpty) .toList(), @@ -167,8 +94,23 @@ class DeckRepository { ) .toList(); + final srsRows = await db.query(DbConstants.srsItemsTable); + final srsItemsByKanjiId = >{}; + for (final r in srsRows) { + final srsItem = SrsItem( + subjectId: r[DbConstants.kanjiIdColumn] as int, + quizMode: QuizMode.values.firstWhere( + (e) => e.toString() == r[DbConstants.quizModeColumn] as String, + ), + readingType: r[DbConstants.readingTypeColumn] as String?, + srsStage: r[DbConstants.srsStageColumn] as int, + lastAsked: DateTime.parse(r[DbConstants.lastAskedColumn] as String), + ); + srsItemsByKanjiId.putIfAbsent(srsItem.subjectId, () => []).add(srsItem); + } + for (final item in kanjiItems) { - final srsItems = await getSrsItems(item.id); + final srsItems = srsItemsByKanjiId[item.id] ?? []; for (final srsItem in srsItems) { final key = srsItem.quizMode.toString() + (srsItem.readingType ?? ''); item.srsItems[key] = srsItem; @@ -178,47 +120,27 @@ class DeckRepository { return kanjiItems; } - Future> getSrsItems(int kanjiId) async { - final db = await _openDb(); - final rows = await db.query( - 'srs_items', - where: 'kanjiId = ?', - whereArgs: [kanjiId], - ); - return rows.map((r) { - return SrsItem( - kanjiId: r['kanjiId'] as int, - quizMode: QuizMode.values.firstWhere( - (e) => e.toString() == r['quizMode'] as String, - ), - readingType: r['readingType'] as String?, - srsStage: r['srsStage'] as int, - lastAsked: DateTime.parse(r['lastAsked'] as String), - ); - }).toList(); - } - Future updateSrsItem(SrsItem item) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; await db.update( - 'srs_items', + DbConstants.srsItemsTable, { - 'srsStage': item.srsStage, - 'lastAsked': item.lastAsked.toIso8601String(), + DbConstants.srsStageColumn: item.srsStage, + DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), }, - where: 'kanjiId = ? AND quizMode = ? AND readingType = ?', - whereArgs: [item.kanjiId, item.quizMode.toString(), item.readingType], + where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?', + whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType], ); } Future insertSrsItem(SrsItem item) async { - final db = await _openDb(); - await db.insert('srs_items', { - 'kanjiId': item.kanjiId, - 'quizMode': item.quizMode.toString(), - 'readingType': item.readingType, - 'srsStage': item.srsStage, - 'lastAsked': item.lastAsked.toIso8601String(), + final db = await DatabaseHelper().db; + await db.insert(DbConstants.srsItemsTable, { + DbConstants.kanjiIdColumn: item.subjectId, + DbConstants.quizModeColumn: item.quizMode.toString(), + DbConstants.readingTypeColumn: item.readingType, + DbConstants.srsStageColumn: item.srsStage, + DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(), }, conflictAlgorithm: ConflictAlgorithm.replace); } @@ -261,6 +183,4 @@ class DeckRepository { await saveKanji(items); return items; } - - -} \ No newline at end of file +} diff --git a/lib/src/services/distractor_generator.dart b/lib/src/services/distractor_generator.dart index 5c9f03a..984c9ed 100644 --- a/lib/src/services/distractor_generator.dart +++ b/lib/src/services/distractor_generator.dart @@ -1,4 +1,5 @@ import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; import 'dart:math'; class DistractorGenerator { diff --git a/lib/src/services/vocab_deck_repository.dart b/lib/src/services/vocab_deck_repository.dart index cc25efa..7766532 100644 --- a/lib/src/services/vocab_deck_repository.dart +++ b/lib/src/services/vocab_deck_repository.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'dart:convert'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:sqflite/sqflite.dart'; -import '../models/kanji_item.dart'; +import '../models/vocabulary_item.dart'; +import '../models/srs_item.dart'; import '../api/wk_client.dart'; +import 'database_helper.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; class VocabDeckRepository { - Database? _db; String? _apiKey; Future setApiKey(String apiKey) async { @@ -19,93 +18,20 @@ class VocabDeckRepository { String? get apiKey => _apiKey; - Future _openDb() async { - if (_db != null) return _db!; - final dir = await getApplicationDocumentsDirectory(); - final path = join(dir.path, 'wanikani_srs.db'); - - _db = await openDatabase( - path, - version: 7, - onCreate: (db, version) async { - await db.execute( - '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''', - ); - await db.execute( - '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', - ); - await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', - ); - }, - onUpgrade: (db, oldVersion, newVersion) async { - if (oldVersion < 2) { - await db.execute( - '''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''', - ); - } - if (oldVersion < 3) { - // Migration from version 2 to 3 was flawed, so we just drop the columns if they exist - } - if (oldVersion < 4) { - await db.execute( - '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''', - ); - // We are not migrating the old srs data, as it was not mode-specific. - // Old columns will be dropped. - } - if (oldVersion < 5) { - await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''', - ); - await db.execute( - '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''', - ); - } - if (oldVersion < 6) { - try { - await db.execute( - 'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT', - ); - } catch (_) { - // Ignore error, column might already exist - } - } - if (oldVersion < 7) { - try { - await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER'); - await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER'); - } catch (_) { - // Ignore error, column might already exist - } - } - }, - ); - - return _db!; - } - Future saveApiKey(String apiKey) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; await db.insert('settings', { 'key': 'apiKey', 'value': apiKey, }, conflictAlgorithm: ConflictAlgorithm.replace); } + Future loadApiKey() async { String? envApiKey; try { envApiKey = dotenv.env['WANIKANI_API_KEY']; } catch (e) { - // dotenv is not initialized, so we can't get the key. - // This is expected in release builds. envApiKey = null; } @@ -114,7 +40,7 @@ class VocabDeckRepository { return _apiKey; } - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query( 'settings', where: 'key = ?', @@ -127,17 +53,17 @@ class VocabDeckRepository { return null; } - Future> getVocabSrsItems(int vocabId) async { - final db = await _openDb(); + Future> getVocabSrsItems(int vocabId) async { + final db = await DatabaseHelper().db; final rows = await db.query( 'srs_vocab_items', where: 'vocabId = ?', whereArgs: [vocabId], ); return rows.map((r) { - return VocabSrsItem( - vocabId: r['vocabId'] as int, - quizMode: VocabQuizMode.values.firstWhere( + return SrsItem( + subjectId: r['vocabId'] as int, + quizMode: QuizMode.values.firstWhere( (e) => e.toString() == r['quizMode'] as String, ), srsStage: r['srsStage'] as int, @@ -146,8 +72,8 @@ class VocabDeckRepository { }).toList(); } - Future updateVocabSrsItem(VocabSrsItem item) async { - final db = await _openDb(); + Future updateVocabSrsItem(SrsItem item) async { + final db = await DatabaseHelper().db; await db.update( 'srs_vocab_items', { @@ -155,14 +81,14 @@ class VocabDeckRepository { 'lastAsked': item.lastAsked.toIso8601String(), }, where: 'vocabId = ? AND quizMode = ?', - whereArgs: [item.vocabId, item.quizMode.toString()], + whereArgs: [item.subjectId, item.quizMode.toString()], ); } - Future insertVocabSrsItem(VocabSrsItem item) async { - final db = await _openDb(); + Future insertVocabSrsItem(SrsItem item) async { + final db = await DatabaseHelper().db; await db.insert('srs_vocab_items', { - 'vocabId': item.vocabId, + 'vocabId': item.subjectId, 'quizMode': item.quizMode.toString(), 'srsStage': item.srsStage, 'lastAsked': item.lastAsked.toIso8601String(), @@ -170,7 +96,7 @@ class VocabDeckRepository { } Future saveVocabulary(List items) async { - final db = await _openDb(); + final db = await DatabaseHelper().db; final batch = db.batch(); for (final it in items) { final audios = it.pronunciationAudios @@ -189,7 +115,7 @@ class VocabDeckRepository { } Future> loadVocabulary() async { - final db = await _openDb(); + final db = await DatabaseHelper().db; final rows = await db.query('vocabulary'); final vocabItems = rows.map((r) { final audiosRaw = r['pronunciation_audios'] as String?; @@ -205,9 +131,7 @@ class VocabDeckRepository { ), ); } - } catch (e) { - // Error decoding, so we'll just have no audio for this item - } + } finally {} } return VocabularyItem( id: r['id'] as int, diff --git a/lib/src/themes.dart b/lib/src/themes.dart new file mode 100644 index 0000000..7092f68 --- /dev/null +++ b/lib/src/themes.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +class SrsColors { + final Color level1; + final Color level2; + final Color level3; + final Color level4; + final Color level5; + final Color level6; + final Color level7; + final Color level8; + final Color level9; + + const SrsColors({ + required this.level1, + required this.level2, + required this.level3, + required this.level4, + required this.level5, + required this.level6, + required this.level7, + required this.level8, + required this.level9, + }); +} + +extension CustomTheme on ThemeData { + SrsColors get srsColors { + if (brightness == Brightness.dark) { + return const SrsColors( + level1: Color(0xFFE57373), // red + level2: Color(0xFFFFB74D), // orange + level3: Color(0xFFFFD54F), // yellow + level4: Color(0xFFDCE775), // lime + level5: Color(0xFFAED581), // light green + level6: Color(0xFF81C784), // green + level7: Color(0xFF4DB6AC), // teal + level8: Color(0xFF4FC3F7), // light blue + level9: Color(0xFF7986CB), // indigo + ); + } else if (colorScheme.primary == const Color(0xFF7B6D53)) { // Nier theme + return const SrsColors( + level1: Color(0xFFB71C1C), // dark red + level2: Color(0xFFD84315), // deep orange + level3: Color(0xFFF57F17), // yellow + level4: Color(0xFF9E9D24), // lime + level5: Color(0xFF558B2F), // light green + level6: Color(0xFF2E7D32), // green + level7: Color(0xFF00695C), // teal + level8: Color(0xFF0277BD), // light blue + level9: Color(0xFF283593), // indigo + ); + } else { // Light theme + return const SrsColors( + level1: Colors.red, + level2: Colors.orange, + level3: Colors.yellow, + level4: Colors.lightGreen, + level5: Colors.green, + level6: Colors.teal, + level7: Colors.cyan, + level8: Colors.blue, + level9: Colors.purple, + ); + } + } +} + +class Themes { + static final dark = ThemeData( + colorScheme: const ColorScheme( + brightness: Brightness.dark, + primary: Color(0xFF90CAF9), + onPrimary: Colors.black, + secondary: Color(0xFFBBDEFB), + onSecondary: Colors.black, + tertiary: Color(0xFFA5D6A7), + onTertiary: Colors.black, + error: Color(0xFFEF9A9A), + onError: Colors.black, + surface: Color(0xFF121212), + onSurface: Colors.white, + surfaceContainer: Color(0xFF1E1E1E), + surfaceContainerHighest: Color(0xFF424242), + onSurfaceVariant: Colors.white70, + ), + useMaterial3: true, + ); + + static final light = ThemeData( + colorScheme: const ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF1976D2), + onPrimary: Colors.white, + secondary: Color(0xFF42A5F5), + onSecondary: Colors.white, + tertiary: Color(0xFF66BB6A), + onTertiary: Colors.white, + error: Color(0xFFE57373), + onError: Colors.white, + surface: Color(0xFFFFFFFF), + onSurface: Colors.black, + surfaceContainer: Color(0xFFF5F5F5), + surfaceContainerHighest: Color(0xFFE0E0E0), + onSurfaceVariant: Colors.black54, + ), + useMaterial3: true, + ); + + static final nier = ThemeData( + colorScheme: const ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF7B6D53), + onPrimary: Colors.white, + secondary: Color(0xFFA99A7E), + onSecondary: Colors.white, + tertiary: Color(0xFFA99A7E), + onTertiary: Colors.white, + error: Color(0xFFD32F2F), + onError: Colors.white, + surface: Color(0xFFCFCBAA), + onSurface: Color(0xFF333333), + surfaceContainer: Color(0xFFBDB898), + surfaceContainerHighest: Color(0xFFA8A388), + onSurfaceVariant: Color(0xFF545454), + ), + useMaterial3: true, + ); +} diff --git a/lib/src/widgets/options_grid.dart b/lib/src/widgets/options_grid.dart index d1043cd..ee61642 100644 --- a/lib/src/widgets/options_grid.dart +++ b/lib/src/widgets/options_grid.dart @@ -40,9 +40,9 @@ class OptionsGrid extends StatelessWidget { if (showResult) { if (correctAnswers != null && correctAnswers!.contains(o)) { - currentButtonColor = Colors.green; + currentButtonColor = theme.colorScheme.tertiary; } else if (o == selectedOption) { - currentButtonColor = Colors.red; + currentButtonColor = theme.colorScheme.error; } } @@ -60,7 +60,7 @@ class OptionsGrid extends StatelessWidget { ), child: Text( o, - style: TextStyle(fontSize: 20, color: currentTextColor), + style: theme.textTheme.titleMedium?.copyWith(color: currentTextColor), textAlign: TextAlign.center, ), ),