Merge pull request 'themeing' (#7) from themeing into master

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2025-10-31 17:19:39 +01:00
26 changed files with 1121 additions and 770 deletions

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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:hirameki_srs/src/services/vocab_deck_repository.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -9,16 +10,15 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
try { try {
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");
} catch (e) { // No need to catch because the file is only needed for dev
// It's okay if the .env file is not found. } catch (_) {}
// This is expected in release builds.
}
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
Provider<DeckRepository>(create: (_) => DeckRepository()), Provider<DeckRepository>(create: (_) => DeckRepository()),
Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()), Provider<VocabDeckRepository>(create: (_) => VocabDeckRepository()),
ChangeNotifierProvider<ThemeModel>(create: (_) => ThemeModel()),
], ],
child: const WkApp(), child: const WkApp(),
), ),
@@ -30,11 +30,15 @@ class WkApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return Consumer<ThemeModel>(
title: 'Hirameki SRS', builder: (context, themeModel, child) {
debugShowCheckedModeBanner: false, return MaterialApp(
theme: ThemeData.dark(useMaterial3: true), title: 'Hirameki SRS',
home: const StartScreen(), debugShowCheckedModeBanner: false,
theme: themeModel.currentTheme,
home: const StartScreen(),
);
},
); );
} }
} }

View File

@@ -1,14 +1,24 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/subject.dart';
import '../models/kanji_item.dart';
import '../models/vocabulary_item.dart';
class WkClient { class WkClient {
final String apiKey; final String apiKey;
final Map<String, String> headers; final Map<String, String> headers;
final String base = 'https://api.wanikani.com/v2'; 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<List<Map<String, dynamic>>> fetchAllAssignments({List<String>? subjectTypes}) async { Future<List<Map<String, dynamic>>> fetchAllAssignments({
List<String>? subjectTypes,
}) async {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
String url = '$base/assignments?page=1'; String url = '$base/assignments?page=1';
if (subjectTypes != null && subjectTypes.isNotEmpty) { if (subjectTypes != null && subjectTypes.isNotEmpty) {
@@ -30,13 +40,15 @@ class WkClient {
return out; return out;
} }
Future<List<Map<String, dynamic>>> fetchAllSubjects({List<String>? types}) async { Future<List<Map<String, dynamic>>> fetchAllSubjects({
List<String>? types,
}) async {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
String url = '$base/subjects'; String url = '$base/subjects';
if (types != null && types.isNotEmpty) { if (types != null && types.isNotEmpty) {
url += '?types=${types.join(',')}'; url += '?types=${types.join(',')}';
} }
while (url.isNotEmpty) { while (url.isNotEmpty) {
final resp = await http.get(Uri.parse(url), headers: headers); final resp = await http.get(Uri.parse(url), headers: headers);
if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}'); if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}');
@@ -56,7 +68,10 @@ class WkClient {
final out = <Map<String, dynamic>>[]; final out = <Map<String, dynamic>>[];
const batch = 100; const batch = 100;
for (var i = 0; i < ids.length; i += batch) { 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'; String url = '$base/subjects?ids=${chunk.join(',')}&page=1';
while (true) { while (true) {
final resp = await http.get(Uri.parse(url), headers: headers); final resp = await http.get(Uri.parse(url), headers: headers);
@@ -73,4 +88,13 @@ class WkClient {
} }
return out; return out;
} }
static Subject createSubjectFromMap(Map<String, dynamic> 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');
}
} }

View File

@@ -1,4 +1,3 @@
class CustomKanjiItem { class CustomKanjiItem {
final String characters; final String characters;
final String meaning; final String meaning;
@@ -25,7 +24,9 @@ class CustomKanjiItem {
srsData.listeningComprehensionNextReview ??= oldNextReview; srsData.listeningComprehensionNextReview ??= oldNextReview;
} }
} else { } 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( srsData = SrsData(
japaneseToEnglish: json['srsLevel'] as int? ?? 0, japaneseToEnglish: json['srsLevel'] as int? ?? 0,
japaneseToEnglishNextReview: nextReview, japaneseToEnglishNextReview: nextReview,
@@ -76,22 +77,32 @@ class SrsData {
factory SrsData.fromJson(Map<String, dynamic> json) { factory SrsData.fromJson(Map<String, dynamic> json) {
return SrsData( return SrsData(
japaneseToEnglish: json['japaneseToEnglish'] as int? ?? 0, 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, 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, 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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'japaneseToEnglish': japaneseToEnglish, 'japaneseToEnglish': japaneseToEnglish,
'japaneseToEnglishNextReview': japaneseToEnglishNextReview?.toIso8601String(), 'japaneseToEnglishNextReview': japaneseToEnglishNextReview
?.toIso8601String(),
'englishToJapanese': englishToJapanese, 'englishToJapanese': englishToJapanese,
'englishToJapaneseNextReview': englishToJapaneseNextReview?.toIso8601String(), 'englishToJapaneseNextReview': englishToJapaneseNextReview
?.toIso8601String(),
'listeningComprehension': listeningComprehension, 'listeningComprehension': listeningComprehension,
'listeningComprehensionNextReview': listeningComprehensionNextReview?.toIso8601String(), 'listeningComprehensionNextReview': listeningComprehensionNextReview
?.toIso8601String(),
}; };
} }
} }

View File

@@ -1,54 +1,24 @@
enum QuizMode { kanjiToEnglish, englishToKanji, reading } import 'subject.dart';
class SrsItem { class KanjiItem extends Subject {
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<String> meanings;
final List<String> onyomi; final List<String> onyomi;
final List<String> kunyomi; final List<String> kunyomi;
final Map<String, SrsItem> srsItems = {};
KanjiItem({ KanjiItem({
required this.id, required super.id,
required this.level, required super.level,
required this.characters, required super.characters,
required this.meanings, required super.meanings,
required this.onyomi, required this.onyomi,
required this.kunyomi, required this.kunyomi,
}); });
factory KanjiItem.fromSubject(Map<String, dynamic> subj) { factory KanjiItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int; final commonFields = Subject.parseCommonFields(subj);
final data = subj['data'] as Map<String, dynamic>; final data = commonFields['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> onyomi = <String>[]; final List<String> onyomi = <String>[];
final List<String> kunyomi = <String>[]; final List<String> kunyomi = <String>[];
if (data['meanings'] != null) {
for (final m in data['meanings'] as List) {
meanings.add((m['meaning'] as String).toLowerCase());
}
}
if (data['readings'] != null) { if (data['readings'] != null) {
for (final r in data['readings'] as List) { for (final r in data['readings'] as List) {
final typ = r['type'] as String? ?? ''; final typ = r['type'] as String? ?? '';
@@ -62,10 +32,10 @@ class KanjiItem {
} }
return KanjiItem( return KanjiItem(
id: id, id: commonFields['id'] as int,
level: level, level: commonFields['level'] as int,
characters: characters, characters: commonFields['characters'] as String,
meanings: meanings, meanings: commonFields['meanings'] as List<String>,
onyomi: onyomi, onyomi: onyomi,
kunyomi: kunyomi, kunyomi: kunyomi,
); );
@@ -83,89 +53,3 @@ String _katakanaToHiragana(String input) {
} }
return buf.toString(); 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<String> meanings;
final List<String> readings;
final List<PronunciationAudio> pronunciationAudios;
final Map<String, VocabSrsItem> srsItems = {};
VocabularyItem(
{required this.id,
required this.level,
required this.characters,
required this.meanings,
required this.readings,
required this.pronunciationAudios});
factory VocabularyItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
final data = subj['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> readings = <String>[];
final List<PronunciationAudio> pronunciationAudios = <PronunciationAudio>[];
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<String, dynamic>?;
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);
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,38 @@
import 'srs_item.dart';
abstract class Subject {
final int id;
final int level;
final String characters;
final List<String> meanings;
final Map<String, SrsItem> srsItems = {};
Subject({
required this.id,
required this.level,
required this.characters,
required this.meanings,
});
static Map<String, dynamic> parseCommonFields(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
final data = subj['data'] as Map<String, dynamic>;
final int level = data['level'] as int;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
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,
};
}
}

View File

@@ -0,0 +1,15 @@
import 'kanji_item.dart';
import 'vocabulary_item.dart';
import 'subject.dart';
class SubjectFactory {
static Subject fromMap(Map<String, dynamic> 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');
}
}

View File

@@ -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();
}
}

View File

@@ -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<String> readings;
final List<PronunciationAudio> pronunciationAudios;
VocabularyItem({
required super.id,
required super.level,
required super.characters,
required super.meanings,
required this.readings,
required this.pronunciationAudios,
});
factory VocabularyItem.fromSubject(Map<String, dynamic> subj) {
final commonFields = Subject.parseCommonFields(subj);
final data = commonFields['data'] as Map<String, dynamic>;
final List<String> readings = <String>[];
final List<PronunciationAudio> pronunciationAudios = <PronunciationAudio>[];
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<String, dynamic>?;
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<String>,
readings: readings,
pronunciationAudios: pronunciationAudios,
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:kana_kit/kana_kit.dart'; import 'package:kana_kit/kana_kit.dart';
import '../models/custom_kanji_item.dart'; import '../models/custom_kanji_item.dart';
@@ -61,7 +60,9 @@ class _AddCardScreenState extends State<AddCardScreen> {
final newItem = CustomKanjiItem( final newItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, kanji: _kanjiController.text.trim().isNotEmpty
? _kanjiController.text.trim()
: null,
useInterval: _useInterval, useInterval: _useInterval,
srsData: srsData, srsData: srsData,
); );
@@ -73,9 +74,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Add New Card')),
title: const Text('Add New Card'),
),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Form( child: Form(

View File

@@ -1,8 +1,11 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hirameki_srs/src/themes.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/vocabulary_item.dart';
import '../models/srs_item.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart'; import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/custom_deck_repository.dart'; import '../services/custom_deck_repository.dart';
@@ -18,7 +21,8 @@ class BrowseScreen extends StatefulWidget {
State<BrowseScreen> createState() => _BrowseScreenState(); State<BrowseScreen> createState() => _BrowseScreenState();
} }
class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderStateMixin { class _BrowseScreenState extends State<BrowseScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late PageController _kanjiPageController; late PageController _kanjiPageController;
late PageController _vocabPageController; late PageController _vocabPageController;
@@ -50,7 +54,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
_vocabPageController = PageController(); _vocabPageController = PageController();
_tabController.addListener(() { _tabController.addListener(() {
setState(() {}); // Rebuild to update the level selector setState(() {});
}); });
_kanjiPageController.addListener(() { _kanjiPageController.addListener(() {
@@ -94,13 +98,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
); );
if (!mounted) return;
_loadDecks(); _loadDecks();
}, },
child: const Text('Go to Settings'), child: const Text('Go to Settings'),
@@ -115,9 +123,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const CircularProgressIndicator(color: Colors.blueAccent), CircularProgressIndicator(color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16), 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<BrowseScreen> with SingleTickerProviderSt
Widget _buildCustomSrsTab() { Widget _buildCustomSrsTab() {
if (_customDeck.isEmpty) { if (_customDeck.isEmpty) {
return const Center( return Center(
child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)), child: Text(
'No custom cards yet.',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
); );
} }
return _buildCustomGridView(_customDeck); return _buildCustomGridView(_customDeck);
} }
Widget _buildPaginatedView( Widget _buildPaginatedView(
Map<int, List<dynamic>> groupedItems, Map<int, List<dynamic>> groupedItems,
List<int> sortedLevels, List<int> sortedLevels,
PageController pageController, PageController pageController,
Widget Function(List<dynamic>) buildPageContent) { Widget Function(List<dynamic>) buildPageContent,
) {
if (sortedLevels.isEmpty) { if (sortedLevels.isEmpty) {
return const Center( return Center(
child: child: Text(
Text('No items to display.', style: TextStyle(color: Colors.white)), 'No items to display.',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
); );
} }
@@ -160,10 +174,11 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Text(
'Level $level', 'Level $level',
style: const TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
color: Colors.white, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold,
),
), ),
), ),
Expanded(child: buildPageContent(levelItems)), Expanded(child: buildPageContent(levelItems)),
@@ -183,7 +198,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
color: const Color(0xFF1F1F1F), color: Theme.of(context).colorScheme.surfaceContainer,
height: 60, height: 60,
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -196,11 +211,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton( child: ElevatedButton(
onPressed: () { 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( style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? Colors.blueAccent : const Color(0xFF333333), backgroundColor: isSelected
foregroundColor: Colors.white, ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
shape: const CircleBorder(), shape: const CircleBorder(),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
), ),
@@ -245,9 +266,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Widget _buildVocabListTile(VocabularyItem item) { Widget _buildVocabListTile(VocabularyItem item) {
final requiredModes = <String>[ final requiredModes = <String>[
VocabQuizMode.vocabToEnglish.toString(), QuizMode.vocabToEnglish.toString(),
VocabQuizMode.englishToVocab.toString(), QuizMode.englishToVocab.toString(),
VocabQuizMode.audioToEnglish.toString(), QuizMode.audioToEnglish.toString(),
]; ];
int minSrsStage = 9; int minSrsStage = 9;
@@ -266,7 +287,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
return GestureDetector( return GestureDetector(
onTap: () => _showVocabDetailsDialog(context, item), onTap: () => _showVocabDetailsDialog(context, item),
child: Card( child: Card(
color: const Color(0xFF1E1E1E), color: Theme.of(context).colorScheme.surfaceContainer,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -275,7 +296,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Expanded( Expanded(
child: Text( child: Text(
item.characters, item.characters,
style: const TextStyle(fontSize: 24, color: Colors.white), style: TextStyle(fontSize: 24, color: Theme.of(context).colorScheme.onSurface),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -286,7 +307,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
children: [ children: [
Text( Text(
item.meanings.join(', '), item.meanings.join(', '),
style: const TextStyle(color: Colors.grey), style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -327,7 +348,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
return Card( return Card(
color: const Color(0xFF1E1E1E), color: Theme.of(context).colorScheme.surfaceContainer,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
@@ -335,7 +356,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
children: [ children: [
Text( Text(
item.characters, item.characters,
style: const TextStyle(fontSize: 32, color: Colors.white), style: TextStyle(fontSize: 32, color: Theme.of(context).colorScheme.onSurface),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -354,8 +375,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
child: SizedBox( child: SizedBox(
height: 10, height: 10,
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: level / 9.0, // Max SRS level is 9 value: level / 9.0,
backgroundColor: Colors.grey[800], backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
_getColorForSrsLevel(level), _getColorForSrsLevel(level),
), ),
@@ -366,13 +387,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
Color _getColorForSrsLevel(int level) { Color _getColorForSrsLevel(int level) {
if (level >= 9) return Colors.purple; final srsColors = Theme.of(context).srsColors;
if (level >= 8) return Colors.blue; if (level >= 9) return srsColors.level9;
if (level >= 7) return Colors.lightBlue; if (level >= 8) return srsColors.level8;
if (level >= 5) return Colors.green; if (level >= 7) return srsColors.level7;
if (level >= 3) return Colors.yellow; if (level >= 6) return srsColors.level6;
if (level >= 1) return Colors.orange; if (level >= 5) return srsColors.level5;
return Colors.red; 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) { void _showReadingsDialog(KanjiItem kanji) {
@@ -399,17 +424,19 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
srsScores['Reading (kunyomi)'] = srsItem.srsStage; srsScores['Reading (kunyomi)'] = srsItem.srsStage;
} }
break; break;
default:
break;
} }
} }
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text( title: Text(
'Details for ${kanji.characters}', 'Details for ${kanji.characters}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
@@ -418,50 +445,56 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
children: [ children: [
Text( Text(
'Level: ${kanji.level}', 'Level: ${kanji.level}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (kanji.meanings.isNotEmpty) if (kanji.meanings.isNotEmpty)
Text( Text(
'Meanings: ${kanji.meanings.join(', ')}', 'Meanings: ${kanji.meanings.join(', ')}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (kanji.onyomi.isNotEmpty) if (kanji.onyomi.isNotEmpty)
Text( Text(
'On\'yomi: ${kanji.onyomi.join(', ')}', 'On\'yomi: ${kanji.onyomi.join(', ')}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
if (kanji.kunyomi.isNotEmpty) if (kanji.kunyomi.isNotEmpty)
Text( Text(
'Kun\'yomi: ${kanji.kunyomi.join(', ')}', '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) if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text( Text(
'No readings available.', 'No readings available.',
style: TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(color: Colors.grey), Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'SRS Scores:', 'SRS Scores:',
style: TextStyle( 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: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close', child: Text(
style: TextStyle(color: Colors.blueAccent)), 'Close',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
), ),
], ],
); );
@@ -473,8 +506,10 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
setState(() => _loading = true); setState(() => _loading = true);
try { try {
final kanjiRepo = Provider.of<DeckRepository>(context, listen: false); final kanjiRepo = Provider.of<DeckRepository>(context, listen: false);
final vocabRepo = final vocabRepo = Provider.of<VocabDeckRepository>(
Provider.of<VocabDeckRepository>(context, listen: false); context,
listen: false,
);
await kanjiRepo.loadApiKey(); await kanjiRepo.loadApiKey();
final apiKey = kanjiRepo.apiKey; final apiKey = kanjiRepo.apiKey;
@@ -537,9 +572,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: appBar: _isSelectionMode
_isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(), ? _buildSelectionAppBar()
backgroundColor: const Color(0xFF121212), : _buildDefaultAppBar(),
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
@@ -548,18 +583,19 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
children: [ children: [
_buildWaniKaniTab( _buildWaniKaniTab(
_buildPaginatedView( _buildPaginatedView(
_kanjiByLevel, _kanjiByLevel,
_kanjiSortedLevels, _kanjiSortedLevels,
_kanjiPageController, _kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>())), (items) => _buildGridView(items.cast<KanjiItem>()),
),
), ),
_buildWaniKaniTab( _buildWaniKaniTab(
_buildPaginatedView( _buildPaginatedView(
_vocabByLevel, _vocabByLevel,
_vocabSortedLevels, _vocabSortedLevels,
_vocabPageController, _vocabPageController,
(items) => (items) => _buildListView(items.cast<VocabularyItem>()),
_buildListView(items.cast<VocabularyItem>())), ),
), ),
_buildCustomSrsTab(), _buildCustomSrsTab(),
], ],
@@ -577,9 +613,10 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
floatingActionButton: _tabController.index == 2 floatingActionButton: _tabController.index == 2
? FloatingActionButton( ? FloatingActionButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(
MaterialPageRoute(builder: (_) => AddCardScreen()), context,
); ).push(MaterialPageRoute(builder: (_) => AddCardScreen()));
if (!mounted) return;
_loadCustomDeck(); _loadCustomDeck();
}, },
child: const Icon(Icons.add), child: const Icon(Icons.add),
@@ -615,14 +652,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
), ),
title: Text('${_selectedItems.length} selected'), title: Text('${_selectedItems.length} selected'),
actions: [ actions: [
IconButton( IconButton(icon: const Icon(Icons.select_all), onPressed: _selectAll),
icon: const Icon(Icons.select_all), IconButton(icon: const Icon(Icons.delete), onPressed: _deleteSelected),
onPressed: _selectAll,
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _deleteSelected,
),
IconButton( IconButton(
icon: Icon(_toggleIntervalIcon), icon: Icon(_toggleIntervalIcon),
onPressed: _toggleIntervalForSelected, onPressed: _toggleIntervalForSelected,
@@ -643,7 +674,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
setState(() { setState(() {
if (_selectedItems.length == _customDeck.length) { if (_selectedItems.length == _customDeck.length) {
_selectedItems.clear(); _selectedItems.clear();
} else { }
else {
_selectedItems = List.from(_customDeck); _selectedItems = List.from(_customDeck);
} }
}); });
@@ -652,28 +684,30 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
void _deleteSelected() { void _deleteSelected() {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Delete Selected'), title: const Text('Delete Selected'),
content: content: Text(
Text('Are you sure you want to delete ${_selectedItems.length} cards?'), 'Are you sure you want to delete ${_selectedItems.length} cards?',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () {
final navigator = Navigator.of(context); Navigator.of(dialogContext).pop();
for (final item in _selectedItems) { () async {
await _customDeckRepository.deleteCard(item); for (final item in _selectedItems) {
} await _customDeckRepository.deleteCard(item);
setState(() { }
_isSelectionMode = false; if (!mounted) return;
_selectedItems.clear(); setState(() {
}); _isSelectionMode = false;
await _loadCustomDeck(); _selectedItems.clear();
if (!mounted) return; });
navigator.pop(); _loadCustomDeck();
}();
}, },
child: const Text('Delete'), child: const Text('Delete'),
), ),
@@ -688,8 +722,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
} }
final bool targetState = _selectedItems.any((item) => !item.useInterval); final bool targetState = _selectedItems.any((item) => !item.useInterval);
final selectedCharacters = final selectedCharacters = _selectedItems
_selectedItems.map((item) => item.characters).toSet(); .map((item) => item.characters)
.toSet();
final List<CustomKanjiItem> updatedItems = []; final List<CustomKanjiItem> updatedItems = [];
for (final item in _selectedItems) { for (final item in _selectedItems) {
@@ -765,13 +800,13 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
child: Card( child: Card(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
side: isSelected side: isSelected
? const BorderSide(color: Colors.blue, width: 2.0) ? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2.0)
: BorderSide.none, : BorderSide.none,
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
), ),
color: isSelected color: isSelected
? Colors.blue.withAlpha((255 * 0.5).round()) ? Theme.of(context).colorScheme.primary.withAlpha((255 * 0.5).round())
: const Color(0xFF1E1E1E), : Theme.of(context).colorScheme.surfaceContainer,
child: Stack( child: Stack(
children: [ children: [
Padding( Padding(
@@ -785,27 +820,34 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
item.kanji?.isNotEmpty == true item.kanji?.isNotEmpty == true
? item.kanji! ? item.kanji!
: item.characters, : item.characters,
style: style: TextStyle(
const TextStyle(fontSize: 32, color: Colors.white), fontSize: 32,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item.meaning, item.meaning,
style: style: TextStyle(
const TextStyle(color: Colors.grey, fontSize: 16), color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Builder(builder: (context) { Builder(
final avgSrs = (item.srsData.japaneseToEnglish + builder: (context) {
item.srsData.englishToJapanese + final avgSrs =
item.srsData.listeningComprehension) / (item.srsData.japaneseToEnglish +
3; item.srsData.englishToJapanese +
return _buildSrsIndicator(avgSrs.round()); item.srsData.listeningComprehension) /
}), 3;
return _buildSrsIndicator(avgSrs.round());
},
),
], ],
), ),
), ),
@@ -813,11 +855,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Positioned( Positioned(
top: 4, top: 4,
right: 4, right: 4,
child: Icon( child: Icon(Icons.timer, color: Theme.of(context).colorScheme.tertiary, size: 16),
Icons.timer,
color: Colors.green,
size: 16,
),
), ),
], ],
), ),
@@ -848,9 +886,11 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
} }
Future<void> _fetchExampleSentences() async { Future<void> _fetchExampleSentences() async {
final theme = Theme.of(context);
try { try {
final uri = Uri.parse( 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); final response = await http.get(uri);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)); final data = jsonDecode(utf8.decode(response.bodyBytes));
@@ -861,15 +901,24 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
(result['japanese'] as List).isNotEmpty && (result['japanese'] as List).isNotEmpty &&
result['senses'] != null && result['senses'] != null &&
(result['senses'] as List).isNotEmpty) { (result['senses'] as List).isNotEmpty) {
final japaneseWord = result['japanese'][0]['word'] ?? result['japanese'][0]['reading']; final japaneseWord =
final englishDefinition = result['senses'][0]['english_definitions'].join(', '); result['japanese'][0]['word'] ??
result['japanese'][0]['reading'];
final englishDefinition =
result['senses'][0]['english_definitions'].join(', ');
if (japaneseWord != null && englishDefinition != null) { if (japaneseWord != null && englishDefinition != null) {
sentences.add( sentences.add(
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(japaneseWord, style: const TextStyle(color: Colors.white)), Text(
Text(englishDefinition, style: const TextStyle(color: Colors.grey)), japaneseWord,
style: TextStyle(color: theme.colorScheme.onSurface),
),
Text(
englishDefinition,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -879,7 +928,12 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
} }
} }
if (sentences.isEmpty) { 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) { if (mounted) {
setState(() { setState(() {
@@ -890,7 +944,10 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
if (mounted) { if (mounted) {
setState(() { setState(() {
_exampleSentences = [ _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) { if (mounted) {
setState(() { setState(() {
_exampleSentences = [ _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final srsScores = <String, int>{ final srsScores = <String, int>{'JP -> EN': 0, 'EN -> JP': 0, 'Audio': 0};
'JP -> EN': 0,
'EN -> JP': 0,
'Audio': 0,
};
for (final entry in widget.vocab.srsItems.entries) { for (final entry in widget.vocab.srsItems.entries) {
final srsItem = entry.value; final srsItem = entry.value;
switch (srsItem.quizMode) { switch (srsItem.quizMode) {
case VocabQuizMode.vocabToEnglish: case QuizMode.vocabToEnglish:
srsScores['JP -> EN'] = srsItem.srsStage; srsScores['JP -> EN'] = srsItem.srsStage;
break; break;
case VocabQuizMode.englishToVocab: case QuizMode.englishToVocab:
srsScores['EN -> JP'] = srsItem.srsStage; srsScores['EN -> JP'] = srsItem.srsStage;
break; break;
case VocabQuizMode.audioToEnglish: case QuizMode.audioToEnglish:
srsScores['Audio'] = srsItem.srsStage; srsScores['Audio'] = srsItem.srsStage;
break; break;
default:
break;
} }
} }
@@ -936,37 +994,39 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
children: [ children: [
Text( Text(
'Level: ${widget.vocab.level}', 'Level: ${widget.vocab.level}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (widget.vocab.meanings.isNotEmpty) if (widget.vocab.meanings.isNotEmpty)
Text( Text(
'Meanings: ${widget.vocab.meanings.join(', ')}', 'Meanings: ${widget.vocab.meanings.join(', ')}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (widget.vocab.readings.isNotEmpty) if (widget.vocab.readings.isNotEmpty)
Text( Text(
'Readings: ${widget.vocab.readings.join(', ')}', 'Readings: ${widget.vocab.readings.join(', ')}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Divider(color: Colors.grey), Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'SRS Scores:', '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 SizedBox(height: 16),
const Divider(color: Colors.grey), Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'Example Sentences:', 'Example Sentences:',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold),
), ),
..._exampleSentences, ..._exampleSentences,
], ],
@@ -978,21 +1038,25 @@ class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) { void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (dialogContext) {
return AlertDialog( return AlertDialog(
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text( title: Text(
'Details for ${vocab.characters}', 'Details for ${vocab.characters}',
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
), ),
content: _VocabDetailsDialog(vocab: vocab), content: _VocabDetailsDialog(vocab: vocab),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close', style: TextStyle(color: Colors.blueAccent)), child: Text(
'Close',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
), ),
], ],
); );
}, },
); );
} }

View File

@@ -6,8 +6,11 @@ class CustomCardDetailsScreen extends StatefulWidget {
final CustomKanjiItem item; final CustomKanjiItem item;
final CustomDeckRepository repository; final CustomDeckRepository repository;
const CustomCardDetailsScreen( const CustomCardDetailsScreen({
{super.key, required this.item, required this.repository}); super.key,
required this.item,
required this.repository,
});
@override @override
State<CustomCardDetailsScreen> createState() => State<CustomCardDetailsScreen> createState() =>
@@ -41,7 +44,9 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
final updatedItem = CustomKanjiItem( final updatedItem = CustomKanjiItem(
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text.trim().isNotEmpty ? _kanjiController.text.trim() : null, kanji: _kanjiController.text.trim().isNotEmpty
? _kanjiController.text.trim()
: null,
useInterval: _useInterval, useInterval: _useInterval,
srsData: widget.item.srsData, srsData: widget.item.srsData,
); );
@@ -79,10 +84,7 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Edit Card'), title: const Text('Edit Card'),
actions: [ actions: [
IconButton( IconButton(icon: const Icon(Icons.delete), onPressed: _deleteCard),
icon: const Icon(Icons.delete),
onPressed: _deleteCard,
),
], ],
), ),
body: Padding( body: Padding(
@@ -111,10 +113,19 @@ class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
}, },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text('SRS Levels', style: TextStyle(fontWeight: FontWeight.bold)), const Text(
Text('Jpn→Eng: ${widget.item.srsData.japaneseToEnglish} (Next review: ${widget.item.srsData.japaneseToEnglishNextReview?.toString() ?? 'N/A'})'), 'SRS Levels',
Text('Eng→Jpn: ${widget.item.srsData.englishToJapanese} (Next review: ${widget.item.srsData.englishToJapaneseNextReview?.toString() ?? 'N/A'})'), style: TextStyle(fontWeight: FontWeight.bold),
Text('Listening: ${widget.item.srsData.listeningComprehension} (Next review: ${widget.item.srsData.listeningComprehensionNextReview?.toString() ?? 'N/A'})'), ),
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), const SizedBox(height: 20),
ElevatedButton( ElevatedButton(
onPressed: _saveChanges, onPressed: _saveChanges,

View File

@@ -5,7 +5,11 @@ import '../models/custom_kanji_item.dart';
import '../widgets/options_grid.dart'; import '../widgets/options_grid.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension } enum CustomQuizMode {
japaneseToEnglish,
englishToJapanese,
listeningComprehension,
}
class CustomQuizScreen extends StatefulWidget { class CustomQuizScreen extends StatefulWidget {
final List<CustomKanjiItem> deck; final List<CustomKanjiItem> deck;
@@ -53,10 +57,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
vsync: this, vsync: this,
); );
_shakeAnimation = Tween<double>(begin: 0, end: 1).animate( _shakeAnimation = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation( CurvedAnimation(parent: _shakeController, curve: Curves.elasticIn),
parent: _shakeController,
curve: Curves.elasticIn,
),
); );
} }
@@ -82,7 +83,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
} }
void playAudio() { void playAudio() {
if (widget.quizMode == CustomQuizMode.listeningComprehension && _currentIndex < _shuffledDeck.length) { if (widget.quizMode == CustomQuizMode.listeningComprehension &&
_currentIndex < _shuffledDeck.length) {
_speak(_shuffledDeck[_currentIndex].characters); _speak(_shuffledDeck[_currentIndex].characters);
} }
} }
@@ -101,20 +103,30 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void _generateOptions() { void _generateOptions() {
final currentItem = _shuffledDeck[_currentIndex]; 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]; _options = [currentItem.meaning];
} else { } 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 final otherItems = widget.deck
.where((item) => item.characters != currentItem.characters) .where((item) => item.characters != currentItem.characters)
.toList(); .toList();
otherItems.shuffle(); otherItems.shuffle();
for (var i = 0; i < min(3, otherItems.length); i++) { 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); _options.add(otherItems[i].meaning);
} else { } 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(); _options.shuffle();
@@ -123,7 +135,9 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void _checkAnswer(String answer) async { void _checkAnswer(String answer) async {
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) 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; : currentItem.meaning;
final isCorrect = answer == correctAnswer; final isCorrect = answer == correctAnswer;
@@ -157,7 +171,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
currentItem.srsData.englishToJapaneseNextReview = newNextReview; currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break; break;
case CustomQuizMode.listeningComprehension: case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview = newNextReview; currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break; break;
} }
} else { } else {
@@ -174,7 +189,8 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
currentItem.srsData.englishToJapaneseNextReview = newNextReview; currentItem.srsData.englishToJapaneseNextReview = newNextReview;
break; break;
case CustomQuizMode.listeningComprehension: case CustomQuizMode.listeningComprehension:
currentItem.srsData.listeningComprehensionNextReview = newNextReview; currentItem.srsData.listeningComprehensionNextReview =
newNextReview;
break; break;
} }
} }
@@ -194,35 +210,35 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
widget.onCardReviewed(currentItem); widget.onCardReviewed(currentItem);
} }
// --- SnackBar Logic (new) ---
final correctDisplay = (widget.quizMode == CustomQuizMode.englishToJapanese) 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; : currentItem.meaning;
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent, color: isCorrect ? Theme.of(context).colorScheme.tertiary : Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
backgroundColor: const Color(0xFF222222), backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack); ScaffoldMessenger.of(context).showSnackBar(snack);
} }
// --- End SnackBar Logic ---
if (isCorrect) { if (isCorrect) {
if (widget.quizMode == CustomQuizMode.japaneseToEnglish) { if (widget.quizMode == CustomQuizMode.japaneseToEnglish) {
await _speak(currentItem.characters); await _speak(currentItem.characters);
} }
await Future.delayed(const Duration(milliseconds: 500)); // Small delay after correct answer await Future.delayed(const Duration(milliseconds: 500));
} else { } else {
_shakeController.forward(from: 0); _shakeController.forward(from: 0);
await Future.delayed(const Duration(milliseconds: 900)); // Delay for shake animation await Future.delayed(const Duration(milliseconds: 900));
} }
_nextQuestion(); _nextQuestion();
@@ -255,15 +271,15 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) { if (_shuffledDeck.isEmpty || _currentIndex >= _shuffledDeck.length) {
return const Center( return Center(child: Text('Review session complete!', style: TextStyle(color: Theme.of(context).colorScheme.onSurface)));
child: Text('Review session complete!'),
);
} }
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = _shuffledDeck[_currentIndex];
final question = (widget.quizMode == CustomQuizMode.englishToJapanese) final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning ? currentItem.meaning
: (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); : (widget.useKanji && currentItem.kanji != null
? currentItem.kanji!
: currentItem.characters);
Widget promptWidget; Widget promptWidget;
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (widget.quizMode == CustomQuizMode.listeningComprehension) {
@@ -276,7 +292,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
onTap: () => _speak(question), onTap: () => _speak(question),
child: Text( child: Text(
question, question,
style: const TextStyle(fontSize: 48), style: TextStyle(fontSize: 48, color: Theme.of(context).colorScheme.onSurface),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
); );
@@ -296,10 +312,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
maxWidth: 500, maxWidth: 500,
minHeight: 150, minHeight: 150,
), ),
child: KanjiCard( child: KanjiCard(characterWidget: promptWidget, subtitle: ''),
characterWidget: promptWidget,
subtitle: '',
),
), ),
), ),
), ),
@@ -331,4 +344,4 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
), ),
); );
} }
} }

View File

@@ -10,7 +10,8 @@ class CustomSrsScreen extends StatefulWidget {
State<CustomSrsScreen> createState() => _CustomSrsScreenState(); State<CustomSrsScreen> createState() => _CustomSrsScreenState();
} }
class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProviderStateMixin { class _CustomSrsScreenState extends State<CustomSrsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
List<CustomKanjiItem> _deck = []; List<CustomKanjiItem> _deck = [];
@@ -49,7 +50,9 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
} }
Future<void> _updateCard(CustomKanjiItem item) async { Future<void> _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) { if (index != -1) {
setState(() { setState(() {
_deck[index] = item; _deck[index] = item;
@@ -79,7 +82,8 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
item.srsData.listeningComprehensionNextReview!.isBefore(now); item.srsData.listeningComprehensionNextReview!.isBefore(now);
}).toList(); }).toList();
final allDecksEmpty = jpnToEngReviewDeck.isEmpty && final allDecksEmpty =
jpnToEngReviewDeck.isEmpty &&
engToJpnReviewDeck.isEmpty && engToJpnReviewDeck.isEmpty &&
listeningReviewDeck.isEmpty; listeningReviewDeck.isEmpty;
@@ -114,8 +118,8 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
body: _deck.isEmpty body: _deck.isEmpty
? const Center(child: Text('Add cards to start quizzing!')) ? const Center(child: Text('Add cards to start quizzing!'))
: allDecksEmpty : allDecksEmpty
? const Center(child: Text('No cards due for review.')) ? const Center(child: Text('No cards due for review.'))
: TabBarView( : TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
CustomQuizScreen( CustomQuizScreen(

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/srs_item.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import '../services/distractor_generator.dart'; import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
@@ -39,7 +40,8 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin { class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<KanjiItem> _deck = []; List<KanjiItem> _deck = [];
bool _loading = false; bool _loading = false;
@@ -60,9 +62,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.indexIsChanging) {
_nextQuestion();
}
setState(() {}); setState(() {});
}); });
_dg = widget.distractorGenerator ?? DistractorGenerator(); _dg = widget.distractorGenerator ?? DistractorGenerator();
@@ -171,8 +170,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_deck.sort((a, b) { _deck.sort((a, b) {
int getSrsStage(KanjiItem item) { int getSrsStage(KanjiItem item) {
if (mode == QuizMode.reading) { if (mode == QuizMode.reading) {
final onyomiStage = item.srsItems['${QuizMode.reading}onyomi']?.srsStage; final onyomiStage =
final kunyomiStage = item.srsItems['${QuizMode.reading}kunyomi']?.srsStage; item.srsItems['${QuizMode.reading}onyomi']?.srsStage;
final kunyomiStage =
item.srsItems['${QuizMode.reading}kunyomi']?.srsStage;
if (onyomiStage != null && kunyomiStage != null) { if (onyomiStage != null && kunyomiStage != null) {
return min(onyomiStage, kunyomiStage); return min(onyomiStage, kunyomiStage);
@@ -184,8 +185,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
DateTime getLastAsked(KanjiItem item) { DateTime getLastAsked(KanjiItem item) {
if (mode == QuizMode.reading) { if (mode == QuizMode.reading) {
final onyomiLastAsked = item.srsItems['${QuizMode.reading}onyomi']?.lastAsked; final onyomiLastAsked =
final kunyomiLastAsked = item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked; item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
final kunyomiLastAsked =
item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
if (onyomiLastAsked != null && kunyomiLastAsked != null) { if (onyomiLastAsked != null && kunyomiLastAsked != null) {
return onyomiLastAsked.isBefore(kunyomiLastAsked) return onyomiLastAsked.isBefore(kunyomiLastAsked)
@@ -227,16 +230,15 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.correctAnswers = [quizState.current!.meanings.first];
quizState.options = [ quizState.options = [
quizState.correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateMeanings(quizState.current!, _deck, 3) ..._dg.generateMeanings(quizState.current!, _deck, 3),
].map(_toTitleCase).toList() ].map(_toTitleCase).toList()..shuffle();
..shuffle();
break; break;
case QuizMode.englishToKanji: case QuizMode.englishToKanji:
quizState.correctAnswers = [quizState.current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
quizState.options = [ quizState.options = [
quizState.correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateKanji(quizState.current!, _deck, 3) ..._dg.generateKanji(quizState.current!, _deck, 3),
]..shuffle(); ]..shuffle();
break; break;
@@ -249,16 +251,22 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
? _deck.expand((k) => k.onyomi) ? _deck.expand((k) => k.onyomi)
: _deck.expand((k) => k.kunyomi); : _deck.expand((k) => k.kunyomi);
final distractors = readingsSource final distractors =
.where((r) => !quizState.correctAnswers.contains(r)) readingsSource
.toSet() .where((r) => !quizState.correctAnswers.contains(r))
.toList() .toSet()
.toList()
..shuffle(); ..shuffle();
quizState.options = ([ quizState.options = ([
quizState.correctAnswers[_random.nextInt(quizState.correctAnswers.length)], quizState.correctAnswers[_random.nextInt(
...distractors.take(3) quizState.correctAnswers.length,
]) )],
..shuffle(); ...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; break;
} }
@@ -279,26 +287,29 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
String readingType = ''; String readingType = '';
if (mode == QuizMode.reading) { 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; final srsKey = mode.toString() + readingType;
var srsItem = current.srsItems[srsKey]; var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null; final isNew = srsItem == null;
final srsItemForUpdate = srsItem ??= final srsItemForUpdate = srsItem ??= SrsItem(
SrsItem(kanjiId: current.id, quizMode: mode, readingType: readingType); subjectId: current.id,
quizMode: mode,
quizState.asked += 1; readingType: readingType,
);
quizState.selectedOption = option;
quizState.asked += 1;
quizState.showResult = true;
quizState.selectedOption = option;
setState(() {}); // Trigger UI rebuild to show selected/correct colors
quizState.showResult = true;
setState(() {});
if (isCorrect) {
if (isCorrect) {
quizState.score += 1; quizState.score += 1;
srsItemForUpdate.srsStage += 1; srsItemForUpdate.srsStage += 1;
if (_playCorrectSound) { if (_playCorrectSound) {
@@ -310,6 +321,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
srsItemForUpdate.lastAsked = DateTime.now(); srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate; current.srsItems[srsKey] = srsItemForUpdate;
final scaffoldMessenger = ScaffoldMessenger.of(context);
final theme = Theme.of(context);
if (isNew) { if (isNew) {
await repo.insertSrsItem(srsItemForUpdate); await repo.insertSrsItem(srsItemForUpdate);
} else { } else {
@@ -319,29 +333,35 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
final correctDisplay = (mode == QuizMode.kanjiToEnglish) final correctDisplay = (mode == QuizMode.kanjiToEnglish)
? _toTitleCase(quizState.correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: (mode == QuizMode.reading : (mode == QuizMode.reading
? quizState.correctAnswers.join(', ') ? quizState.correctAnswers.join(', ')
: quizState.correctAnswers.first); : quizState.correctAnswers.first);
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent, color: isCorrect
? theme.colorScheme.primary
: theme.colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
backgroundColor: const Color(0xFF222222), backgroundColor: theme.colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(snack); scaffoldMessenger.showSnackBar(snack);
} }
setState(() { 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 @override
@@ -353,7 +373,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
@@ -382,14 +407,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
], ],
), ),
), ),
backgroundColor: const Color(0xFF121212), backgroundColor: Theme.of(context).colorScheme.surface,
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
_buildQuizPage(0),
_buildQuizPage(1),
_buildQuizPage(2),
],
), ),
); );
} }
@@ -413,6 +434,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
prompt = quizState.current!.characters; prompt = quizState.current!.characters;
subtitle = quizState.readingHint; subtitle = quizState.readingHint;
break; 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<HomeScreen> with SingleTickerProviderStateM
Expanded( Expanded(
child: Text( child: Text(
_status, _status,
style: const TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
), ),
if (_loading) if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent), CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -446,8 +475,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: KanjiCard( child: KanjiCard(
characters: prompt, characters: prompt,
subtitle: subtitle, subtitle: subtitle,
backgroundColor: const Color(0xFF1E1E1E), backgroundColor: Theme.of(context).colorScheme.surface,
textColor: Colors.white, textColor: Theme.of(context).colorScheme.onSurface,
), ),
), ),
), ),
@@ -468,7 +497,9 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Score: ${quizState.score} / ${quizState.asked}', '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<HomeScreen> with SingleTickerProviderStateM
), ),
); );
} }
} }

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
@@ -50,24 +52,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
await repo.setApiKey(apiKey); await repo.setApiKey(apiKey);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
const SnackBar(content: Text('API key saved!')), context,
); ).showSnackBar(const SnackBar(content: Text('API key saved!')));
Navigator.of(context).pushReplacement( Navigator.of(
MaterialPageRoute(builder: (_) => const HomeScreen()), context,
); ).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final themeModel = Provider.of<ThemeModel>(context);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF121212), backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar( appBar: AppBar(
title: const Text('Settings'), title: const Text('Settings'),
backgroundColor: const Color(0xFF1F1F1F), backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
foregroundColor: Colors.white, foregroundColor: Theme.of(context).colorScheme.onSurface,
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -76,15 +80,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
TextField( TextField(
controller: _apiKeyController, controller: _apiKeyController,
obscureText: true, obscureText: true,
style: const TextStyle(color: Colors.white), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'WaniKani API Key', labelText: 'WaniKani API Key',
labelStyle: const TextStyle(color: Colors.grey), labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
filled: true, filled: true,
fillColor: const Color(0xFF1E1E1E), fillColor: Theme.of(context).colorScheme.surfaceContainer,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), 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<SettingsScreen> {
ElevatedButton( ElevatedButton(
onPressed: _saveApiKey, onPressed: _saveApiKey,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent, backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white, foregroundColor: Theme.of(context).colorScheme.onPrimary,
), ),
child: const Text('Save & Start Quiz'), child: const Text('Save & Start Quiz'),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
SwitchListTile( SwitchListTile(
title: const Text( title: Text(
'Play audio for vocabulary', 'Play audio for vocabulary',
style: TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
value: _playAudio, value: _playAudio,
onChanged: (value) async { onChanged: (value) async {
@@ -111,18 +121,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
_playAudio = value; _playAudio = value;
}); });
}, },
activeThumbColor: Colors.blueAccent, activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Colors.grey, inactiveThumbColor: Theme.of(
tileColor: const Color(0xFF1E1E1E), context,
).colorScheme.onSurfaceVariant,
tileColor: Theme.of(context).colorScheme.surfaceContainer,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SwitchListTile( SwitchListTile(
title: const Text( title: Text(
'Play sound on correct answer', 'Play sound on correct answer',
style: TextStyle(color: Colors.white), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
value: _playCorrectSound, value: _playCorrectSound,
onChanged: (value) async { onChanged: (value) async {
@@ -132,9 +146,50 @@ class _SettingsScreenState extends State<SettingsScreen> {
_playCorrectSound = value; _playCorrectSound = value;
}); });
}, },
activeThumbColor: Colors.blueAccent, activeThumbColor: Theme.of(context).colorScheme.primary,
inactiveThumbColor: Colors.grey, inactiveThumbColor: Theme.of(
tileColor: const Color(0xFF1E1E1E), 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<ThemeData>(
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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),

View File

@@ -28,8 +28,8 @@ class StartScreen extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
const Color(0xFF121212), Theme.of(context).colorScheme.surface,
Colors.grey[900]!, Theme.of(context).colorScheme.surface,
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
@@ -113,14 +113,18 @@ class StartScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
title, title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: Text( child: Text(
description, 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, textAlign: TextAlign.center,
softWrap: true, softWrap: true,
), ),

View File

@@ -3,7 +3,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/distractor_generator.dart'; import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart'; import '../widgets/kanji_card.dart';
@@ -31,7 +32,8 @@ class VocabScreen extends StatefulWidget {
State<VocabScreen> createState() => _VocabScreenState(); State<VocabScreen> createState() => _VocabScreenState();
} }
class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStateMixin { class _VocabScreenState extends State<VocabScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<VocabularyItem> _deck = []; List<VocabularyItem> _deck = [];
bool _loading = false; bool _loading = false;
@@ -52,9 +54,6 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (_tabController.indexIsChanging) {
_nextQuestion();
}
setState(() {}); setState(() {});
}); });
_loadSettings(); _loadSettings();
@@ -129,16 +128,16 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
.join(' '); .join(' ');
} }
VocabQuizMode _modeForIndex(int index) { QuizMode _modeForIndex(int index) {
switch (index) { switch (index) {
case 0: case 0:
return VocabQuizMode.vocabToEnglish; return QuizMode.vocabToEnglish;
case 1: case 1:
return VocabQuizMode.englishToVocab; return QuizMode.englishToVocab;
case 2: case 2:
return VocabQuizMode.audioToEnglish; return QuizMode.audioToEnglish;
default: default:
return VocabQuizMode.vocabToEnglish; return QuizMode.vocabToEnglish;
} }
} }
@@ -149,8 +148,10 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
final mode = _modeForIndex(index ?? _tabController.index); final mode = _modeForIndex(index ?? _tabController.index);
List<VocabularyItem> currentDeckForMode = _deck; List<VocabularyItem> currentDeckForMode = _deck;
if (mode == VocabQuizMode.audioToEnglish) { if (mode == QuizMode.audioToEnglish) {
currentDeckForMode = _deck.where((item) => item.pronunciationAudios.isNotEmpty).toList(); currentDeckForMode = _deck
.where((item) => item.pronunciationAudios.isNotEmpty)
.toList();
if (currentDeckForMode.isEmpty) { if (currentDeckForMode.isEmpty) {
setState(() { setState(() {
_status = 'No vocabulary with audio found.'; _status = 'No vocabulary with audio found.';
@@ -160,13 +161,16 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
} }
} }
// If it's a new session or we've gone through all shuffled items, re-shuffle if (quizState.shuffledDeck.isEmpty ||
if (quizState.shuffledDeck.isEmpty || quizState.currentIndex >= quizState.shuffledDeck.length) { quizState.currentIndex >= quizState.shuffledDeck.length) {
quizState.shuffledDeck = currentDeckForMode.toList(); // Start with a fresh copy quizState.shuffledDeck = currentDeckForMode.toList();
// Apply sorting based on SRS stages here, but only once per shuffle
quizState.shuffledDeck.sort((a, b) { quizState.shuffledDeck.sort((a, b) {
final aSrsItem = a.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: a.id, quizMode: mode); final aSrsItem =
final bSrsItem = b.srsItems[mode.toString()] ?? VocabSrsItem(vocabId: b.id, quizMode: mode); 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); final stageComparison = aSrsItem.srsStage.compareTo(bSrsItem.srsStage);
if (stageComparison != 0) { if (stageComparison != 0) {
return stageComparison; return stageComparison;
@@ -176,37 +180,34 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
quizState.currentIndex = 0; quizState.currentIndex = 0;
} }
quizState.current = quizState.shuffledDeck[quizState.currentIndex]; // Pick from shuffled deck quizState.current = quizState.shuffledDeck[quizState.currentIndex];
quizState.currentIndex++; // Advance index quizState.currentIndex++;
quizState.key = UniqueKey(); quizState.key = UniqueKey();
if (mode == VocabQuizMode.audioToEnglish) {
_playCurrentAudio();
}
quizState.correctAnswers = []; quizState.correctAnswers = [];
quizState.options = []; quizState.options = [];
quizState.selectedOption = null; quizState.selectedOption = null;
quizState.showResult = false; quizState.showResult = false;
switch (mode) { switch (mode) {
case VocabQuizMode.vocabToEnglish: case QuizMode.vocabToEnglish:
case VocabQuizMode.audioToEnglish: case QuizMode.audioToEnglish:
quizState.correctAnswers = [quizState.current!.meanings.first]; quizState.correctAnswers = [quizState.current!.meanings.first];
quizState.options = [ quizState.options = [
quizState.correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateVocabMeanings(quizState.current!, _deck, 3) ..._dg.generateVocabMeanings(quizState.current!, _deck, 3),
].map(_toTitleCase).toList() ].map(_toTitleCase).toList()..shuffle();
..shuffle();
break; break;
case VocabQuizMode.englishToVocab: case QuizMode.englishToVocab:
quizState.correctAnswers = [quizState.current!.characters]; quizState.correctAnswers = [quizState.current!.characters];
quizState.options = [ quizState.options = [
quizState.correctAnswers.first, quizState.correctAnswers.first,
..._dg.generateVocab(quizState.current!, _deck, 3) ..._dg.generateVocab(quizState.current!, _deck, 3),
]..shuffle(); ]..shuffle();
break; break;
default:
break;
} }
setState(() { setState(() {
@@ -214,18 +215,22 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
}); });
} }
Future<void> _playCurrentAudio() async { Future<void> _playCurrentAudio({bool playOnLoad = false}) async {
final current = _currentQuizState.current; final current = _currentQuizState.current;
if (current == null || current.pronunciationAudios.isEmpty) return; if (current == null || current.pronunciationAudios.isEmpty) return;
final maleAudios = current.pronunciationAudios.where((a) => a.gender == 'male'); if (playOnLoad && !_playAudio) return;
final audioUrl = (maleAudios.isNotEmpty ? maleAudios.first.url : current.pronunciationAudios.first.url);
final maleAudios = current.pronunciationAudios.where(
(a) => a.gender == 'male',
);
final audioUrl = (maleAudios.isNotEmpty
? maleAudios.first.url
: current.pronunciationAudios.first.url);
try { try {
await _audioPlayer.play(UrlSource(audioUrl)); await _audioPlayer.play(UrlSource(audioUrl));
} catch (e) { } finally {}
// Ignore player errors
}
} }
void _answer(String option) async { void _answer(String option) async {
@@ -243,12 +248,12 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
var srsItemNullable = current.srsItems[srsKey]; var srsItemNullable = current.srsItems[srsKey];
final isNew = srsItemNullable == null; final isNew = srsItemNullable == null;
final srsItem = final srsItem =
srsItemNullable ?? VocabSrsItem(vocabId: current.id, quizMode: mode); srsItemNullable ?? SrsItem(subjectId: current.id, quizMode: mode);
quizState.asked += 1; quizState.asked += 1;
quizState.selectedOption = option; quizState.selectedOption = option;
quizState.showResult = true; quizState.showResult = true;
setState(() {}); // Trigger UI rebuild to show selected/correct colors setState(() {});
if (isCorrect) { if (isCorrect) {
quizState.score += 1; quizState.score += 1;
@@ -265,32 +270,34 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
await repo.updateVocabSrsItem(srsItem); await repo.updateVocabSrsItem(srsItem);
} }
final correctDisplay = (mode == VocabQuizMode.vocabToEnglish) final correctDisplay = (mode == QuizMode.vocabToEnglish)
? _toTitleCase(quizState.correctAnswers.first) ? _toTitleCase(quizState.correctAnswers.first)
: quizState.correctAnswers.first; : quizState.correctAnswers.first;
if (!mounted) return;
final snack = SnackBar( final snack = SnackBar(
content: Text( content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay', isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle( style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent, color: isCorrect
? Theme.of(context).colorScheme.tertiary
: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
backgroundColor: const Color(0xFF222222), backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
duration: const Duration(milliseconds: 900), duration: const Duration(milliseconds: 900),
); );
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(snack);
ScaffoldMessenger.of(context).showSnackBar(snack);
}
if (isCorrect) { if (isCorrect) {
if (_playCorrectSound) { if (_playCorrectSound) {
await _audioPlayer.play(AssetSource('sfx/confirm.mp3')); await _audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} }
if (_playAudio && mode != VocabQuizMode.audioToEnglish) { if (_playAudio) {
final maleAudios = final maleAudios = current.pronunciationAudios.where(
current.pronunciationAudios.where((a) => a.gender == 'male'); (a) => a.gender == 'male',
);
if (maleAudios.isNotEmpty) { if (maleAudios.isNotEmpty) {
final completer = Completer<void>(); final completer = Completer<void>();
final sub = _audioPlayer.onPlayerComplete.listen((event) { final sub = _audioPlayer.onPlayerComplete.listen((event) {
@@ -300,19 +307,15 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
try { try {
await _audioPlayer.play(UrlSource(maleAudios.first.url)); await _audioPlayer.play(UrlSource(maleAudios.first.url));
await completer.future.timeout(const Duration(seconds: 5)); await completer.future.timeout(const Duration(seconds: 5));
} catch (e) {
// Ignore player errors
} finally { } finally {
await sub.cancel(); await sub.cancel();
} }
} }
} }
} else {
// No fixed delay for incorrect answers
} }
setState(() { setState(() {
_isAnswering = true; // Disable input after showing result _isAnswering = true;
}); });
_nextQuestion(); _nextQuestion();
@@ -327,13 +330,19 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()), MaterialPageRoute(builder: (_) => const SettingsScreen()),
); );
if (!mounted) return;
_loadDeck(); _loadDeck();
}, },
child: const Text('Go to Settings'), child: const Text('Go to Settings'),
@@ -358,11 +367,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
), ),
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
_buildQuizPage(0),
_buildQuizPage(1),
_buildQuizPage(2),
],
), ),
); );
} }
@@ -375,25 +380,36 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
if (quizState.current == null) { if (quizState.current == null) {
promptWidget = const SizedBox.shrink(); promptWidget = const SizedBox.shrink();
} else if (mode == VocabQuizMode.audioToEnglish) { } else if (mode == QuizMode.audioToEnglish) {
promptWidget = IconButton( 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, onPressed: _playCurrentAudio,
); );
} else { } else {
String promptText = ''; String promptText = '';
switch (mode) { switch (mode) {
case VocabQuizMode.vocabToEnglish: case QuizMode.vocabToEnglish:
promptText = quizState.current!.characters; promptText = quizState.current!.characters;
break; break;
case VocabQuizMode.englishToVocab: case QuizMode.englishToVocab:
promptText = _toTitleCase(quizState.current!.meanings.first); promptText = _toTitleCase(quizState.current!.meanings.first);
break; break;
case VocabQuizMode.audioToEnglish: case QuizMode.audioToEnglish:
// Handled above break;
default:
break; 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( return Padding(
@@ -406,10 +422,15 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
Expanded( Expanded(
child: Text( child: Text(
_status, _status,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
), ),
if (_loading) if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent), CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
@@ -422,10 +443,7 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
maxWidth: 500, maxWidth: 500,
minHeight: 150, minHeight: 150,
), ),
child: KanjiCard( child: KanjiCard(characterWidget: promptWidget, subtitle: ''),
characterWidget: promptWidget,
subtitle: '',
),
), ),
), ),
), ),
@@ -445,7 +463,9 @@ class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStat
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Score: ${quizState.score} / ${quizState.asked}', '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<VocabScreen> with SingleTickerProviderStat
), ),
); );
} }
} }

View File

@@ -23,12 +23,7 @@ class CustomDeckRepository {
} }
Future<void> updateCard(CustomKanjiItem item) async { Future<void> updateCard(CustomKanjiItem item) async {
final deck = await getCustomDeck(); await updateCards([item]);
final index = deck.indexWhere((element) => element.characters == item.characters);
if (index != -1) {
deck[index] = item;
await saveDeck(deck);
}
} }
Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async { Future<void> updateCards(List<CustomKanjiItem> itemsToUpdate) async {

View File

@@ -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';
}

View File

@@ -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<Database> get db async {
if (_db != null) return _db!;
_db = await _openDb();
return _db!;
}
Future<void> close() async {
if (_db != null) {
await _db!.close();
_db = null;
}
}
Future<Database> _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
}
}
},
);
}
}

View File

@@ -1,14 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/srs_item.dart';
import '../api/wk_client.dart'; import '../api/wk_client.dart';
import 'database_constants.dart';
import 'database_helper.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
class DeckRepository { class DeckRepository {
Database? _db;
String? _apiKey; String? _apiKey;
Future<void> setApiKey(String apiKey) async { Future<void> setApiKey(String apiKey) async {
@@ -18,148 +18,75 @@ class DeckRepository {
String? get apiKey => _apiKey; String? get apiKey => _apiKey;
Future<Database> _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<void> saveApiKey(String apiKey) async { Future<void> saveApiKey(String apiKey) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('settings', { await db.insert(DbConstants.settingsTable, {
'key': 'apiKey', DbConstants.keyColumn: 'apiKey',
'value': apiKey, DbConstants.valueColumn: apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
Future<String?> loadApiKey() async { Future<String?> loadApiKey() async {
String? envApiKey; final db = await DatabaseHelper().db;
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 rows = await db.query( final rows = await db.query(
'settings', DbConstants.settingsTable,
where: 'key = ?', where: '${DbConstants.keyColumn} = ?',
whereArgs: ['apiKey'], whereArgs: ['apiKey'],
); );
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
_apiKey = rows.first['value'] as String; _apiKey = rows.first[DbConstants.valueColumn] as String;
return _apiKey; 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; return null;
} }
Future<void> saveKanji(List<KanjiItem> items) async { Future<void> saveKanji(List<KanjiItem> items) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final batch = db.batch(); final batch = db.batch();
for (final it in items) { for (final it in items) {
batch.insert('kanji', { batch.insert(DbConstants.kanjiTable, {
'id': it.id, DbConstants.idColumn: it.id,
'level': it.level, DbConstants.levelColumn: it.level,
'characters': it.characters, DbConstants.charactersColumn: it.characters,
'meanings': it.meanings.join('|'), DbConstants.meaningsColumn: it.meanings.join('|'),
'onyomi': it.onyomi.join('|'), DbConstants.onyomiColumn: it.onyomi.join('|'),
'kunyomi': it.kunyomi.join('|'), DbConstants.kunyomiColumn: it.kunyomi.join('|'),
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
Future<List<KanjiItem>> loadKanji() async { Future<List<KanjiItem>> loadKanji() async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query('kanji'); final rows = await db.query(DbConstants.kanjiTable);
final kanjiItems = rows final kanjiItems = rows
.map( .map(
(r) => KanjiItem( (r) => KanjiItem(
id: r['id'] as int, id: r[DbConstants.idColumn] as int,
level: r['level'] as int? ?? 0, level: r[DbConstants.levelColumn] as int? ?? 0,
characters: r['characters'] as String, characters: r[DbConstants.charactersColumn] as String,
meanings: (r['meanings'] as String) meanings: (r[DbConstants.meaningsColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
onyomi: (r['onyomi'] as String) onyomi: (r[DbConstants.onyomiColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
kunyomi: (r['kunyomi'] as String) kunyomi: (r[DbConstants.kunyomiColumn] as String)
.split('|') .split('|')
.where((s) => s.isNotEmpty) .where((s) => s.isNotEmpty)
.toList(), .toList(),
@@ -167,8 +94,23 @@ class DeckRepository {
) )
.toList(); .toList();
final srsRows = await db.query(DbConstants.srsItemsTable);
final srsItemsByKanjiId = <int, List<SrsItem>>{};
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) { for (final item in kanjiItems) {
final srsItems = await getSrsItems(item.id); final srsItems = srsItemsByKanjiId[item.id] ?? [];
for (final srsItem in srsItems) { for (final srsItem in srsItems) {
final key = srsItem.quizMode.toString() + (srsItem.readingType ?? ''); final key = srsItem.quizMode.toString() + (srsItem.readingType ?? '');
item.srsItems[key] = srsItem; item.srsItems[key] = srsItem;
@@ -178,47 +120,27 @@ class DeckRepository {
return kanjiItems; return kanjiItems;
} }
Future<List<SrsItem>> 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<void> updateSrsItem(SrsItem item) async { Future<void> updateSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.update( await db.update(
'srs_items', DbConstants.srsItemsTable,
{ {
'srsStage': item.srsStage, DbConstants.srsStageColumn: item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
}, },
where: 'kanjiId = ? AND quizMode = ? AND readingType = ?', where: '${DbConstants.kanjiIdColumn} = ? AND ${DbConstants.quizModeColumn} = ? AND ${DbConstants.readingTypeColumn} = ?',
whereArgs: [item.kanjiId, item.quizMode.toString(), item.readingType], whereArgs: [item.subjectId, item.quizMode.toString(), item.readingType],
); );
} }
Future<void> insertSrsItem(SrsItem item) async { Future<void> insertSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('srs_items', { await db.insert(DbConstants.srsItemsTable, {
'kanjiId': item.kanjiId, DbConstants.kanjiIdColumn: item.subjectId,
'quizMode': item.quizMode.toString(), DbConstants.quizModeColumn: item.quizMode.toString(),
'readingType': item.readingType, DbConstants.readingTypeColumn: item.readingType,
'srsStage': item.srsStage, DbConstants.srsStageColumn: item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), DbConstants.lastAskedColumn: item.lastAsked.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
@@ -261,6 +183,4 @@ class DeckRepository {
await saveKanji(items); await saveKanji(items);
return items; return items;
} }
}
}

View File

@@ -1,4 +1,5 @@
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../models/vocabulary_item.dart';
import 'dart:math'; import 'dart:math';
class DistractorGenerator { class DistractorGenerator {

View File

@@ -1,15 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.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 '../api/wk_client.dart';
import 'database_helper.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
class VocabDeckRepository { class VocabDeckRepository {
Database? _db;
String? _apiKey; String? _apiKey;
Future<void> setApiKey(String apiKey) async { Future<void> setApiKey(String apiKey) async {
@@ -19,93 +18,20 @@ class VocabDeckRepository {
String? get apiKey => _apiKey; String? get apiKey => _apiKey;
Future<Database> _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<void> saveApiKey(String apiKey) async { Future<void> saveApiKey(String apiKey) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('settings', { await db.insert('settings', {
'key': 'apiKey', 'key': 'apiKey',
'value': apiKey, 'value': apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace); }, conflictAlgorithm: ConflictAlgorithm.replace);
} }
Future<String?> loadApiKey() async { Future<String?> loadApiKey() async {
String? envApiKey; String? envApiKey;
try { try {
envApiKey = dotenv.env['WANIKANI_API_KEY']; envApiKey = dotenv.env['WANIKANI_API_KEY'];
} catch (e) { } catch (e) {
// dotenv is not initialized, so we can't get the key.
// This is expected in release builds.
envApiKey = null; envApiKey = null;
} }
@@ -114,7 +40,7 @@ class VocabDeckRepository {
return _apiKey; return _apiKey;
} }
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query( final rows = await db.query(
'settings', 'settings',
where: 'key = ?', where: 'key = ?',
@@ -127,17 +53,17 @@ class VocabDeckRepository {
return null; return null;
} }
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async { Future<List<SrsItem>> getVocabSrsItems(int vocabId) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query( final rows = await db.query(
'srs_vocab_items', 'srs_vocab_items',
where: 'vocabId = ?', where: 'vocabId = ?',
whereArgs: [vocabId], whereArgs: [vocabId],
); );
return rows.map((r) { return rows.map((r) {
return VocabSrsItem( return SrsItem(
vocabId: r['vocabId'] as int, subjectId: r['vocabId'] as int,
quizMode: VocabQuizMode.values.firstWhere( quizMode: QuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String, (e) => e.toString() == r['quizMode'] as String,
), ),
srsStage: r['srsStage'] as int, srsStage: r['srsStage'] as int,
@@ -146,8 +72,8 @@ class VocabDeckRepository {
}).toList(); }).toList();
} }
Future<void> updateVocabSrsItem(VocabSrsItem item) async { Future<void> updateVocabSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.update( await db.update(
'srs_vocab_items', 'srs_vocab_items',
{ {
@@ -155,14 +81,14 @@ class VocabDeckRepository {
'lastAsked': item.lastAsked.toIso8601String(), 'lastAsked': item.lastAsked.toIso8601String(),
}, },
where: 'vocabId = ? AND quizMode = ?', where: 'vocabId = ? AND quizMode = ?',
whereArgs: [item.vocabId, item.quizMode.toString()], whereArgs: [item.subjectId, item.quizMode.toString()],
); );
} }
Future<void> insertVocabSrsItem(VocabSrsItem item) async { Future<void> insertVocabSrsItem(SrsItem item) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
await db.insert('srs_vocab_items', { await db.insert('srs_vocab_items', {
'vocabId': item.vocabId, 'vocabId': item.subjectId,
'quizMode': item.quizMode.toString(), 'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage, 'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(), 'lastAsked': item.lastAsked.toIso8601String(),
@@ -170,7 +96,7 @@ class VocabDeckRepository {
} }
Future<void> saveVocabulary(List<VocabularyItem> items) async { Future<void> saveVocabulary(List<VocabularyItem> items) async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final batch = db.batch(); final batch = db.batch();
for (final it in items) { for (final it in items) {
final audios = it.pronunciationAudios final audios = it.pronunciationAudios
@@ -189,7 +115,7 @@ class VocabDeckRepository {
} }
Future<List<VocabularyItem>> loadVocabulary() async { Future<List<VocabularyItem>> loadVocabulary() async {
final db = await _openDb(); final db = await DatabaseHelper().db;
final rows = await db.query('vocabulary'); final rows = await db.query('vocabulary');
final vocabItems = rows.map((r) { final vocabItems = rows.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?; final audiosRaw = r['pronunciation_audios'] as String?;
@@ -205,9 +131,7 @@ class VocabDeckRepository {
), ),
); );
} }
} catch (e) { } finally {}
// Error decoding, so we'll just have no audio for this item
}
} }
return VocabularyItem( return VocabularyItem(
id: r['id'] as int, id: r['id'] as int,

129
lib/src/themes.dart Normal file
View File

@@ -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,
);
}

View File

@@ -40,9 +40,9 @@ class OptionsGrid extends StatelessWidget {
if (showResult) { if (showResult) {
if (correctAnswers != null && correctAnswers!.contains(o)) { if (correctAnswers != null && correctAnswers!.contains(o)) {
currentButtonColor = Colors.green; currentButtonColor = theme.colorScheme.tertiary;
} else if (o == selectedOption) { } else if (o == selectedOption) {
currentButtonColor = Colors.red; currentButtonColor = theme.colorScheme.error;
} }
} }
@@ -60,7 +60,7 @@ class OptionsGrid extends StatelessWidget {
), ),
child: Text( child: Text(
o, o,
style: TextStyle(fontSize: 20, color: currentTextColor), style: theme.textTheme.titleMedium?.copyWith(color: currentTextColor),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),