add a shit ton of feature for the custom srs

This commit is contained in:
Rene Kievits
2025-10-30 03:44:04 +01:00
parent b58a4020e1
commit ee4fd7ffc1
13 changed files with 787 additions and 311 deletions

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ app.*.map.json
*.jks *.jks
gradle.properties gradle.properties
# Environment variables
.env

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'src/services/deck_repository.dart'; import 'src/services/deck_repository.dart';
import 'src/screens/start_screen.dart'; import 'src/screens/start_screen.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env");
runApp( runApp(
Provider<DeckRepository>( Provider<DeckRepository>(

View File

@@ -3,11 +3,17 @@ class CustomKanjiItem {
final String characters; final String characters;
final String meaning; final String meaning;
final String? kanji; final String? kanji;
final bool useInterval;
int srsLevel;
DateTime? nextReview;
CustomKanjiItem({ CustomKanjiItem({
required this.characters, required this.characters,
required this.meaning, required this.meaning,
this.kanji, this.kanji,
this.useInterval = false,
this.srsLevel = 0,
this.nextReview,
}); });
factory CustomKanjiItem.fromJson(Map<String, dynamic> json) { factory CustomKanjiItem.fromJson(Map<String, dynamic> json) {
@@ -15,6 +21,11 @@ class CustomKanjiItem {
characters: json['characters'] as String, characters: json['characters'] as String,
meaning: json['meaning'] as String, meaning: json['meaning'] as String,
kanji: json['kanji'] as String?, kanji: json['kanji'] as String?,
useInterval: json['useInterval'] as bool? ?? false,
srsLevel: json['srsLevel'] as int? ?? 0,
nextReview: json['nextReview'] != null
? DateTime.parse(json['nextReview'] as String)
: null,
); );
} }
@@ -23,6 +34,9 @@ class CustomKanjiItem {
'characters': characters, 'characters': characters,
'meaning': meaning, 'meaning': meaning,
'kanji': kanji, 'kanji': kanji,
'useInterval': useInterval,
'srsLevel': srsLevel,
'nextReview': nextReview?.toIso8601String(),
}; };
} }
} }

View File

@@ -18,6 +18,7 @@ class _AddCardScreenState extends State<AddCardScreen> {
final _kanjiController = TextEditingController(); final _kanjiController = TextEditingController();
final _kanaKit = const KanaKit(); final _kanaKit = const KanaKit();
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
bool _useInterval = false;
@override @override
void initState() { void initState() {
@@ -53,6 +54,8 @@ class _AddCardScreenState extends State<AddCardScreen> {
characters: _japaneseController.text, characters: _japaneseController.text,
meaning: _englishController.text, meaning: _englishController.text,
kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null, kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null,
useInterval: _useInterval,
nextReview: _useInterval ? DateTime.now() : null,
); );
_deckRepository.addCard(newItem); _deckRepository.addCard(newItem);
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -106,6 +109,16 @@ class _AddCardScreenState extends State<AddCardScreen> {
return null; return null;
}, },
), ),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Use Interval-based SRS'),
value: _useInterval,
onChanged: (value) {
setState(() {
_useInterval = value;
});
},
),
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton( ElevatedButton(
onPressed: _saveCard, onPressed: _saveCard,

View File

@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../services/deck_repository.dart'; import '../services/deck_repository.dart';
import '../services/custom_deck_repository.dart';
import '../models/custom_kanji_item.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'custom_card_details_screen.dart';
class BrowseScreen extends StatefulWidget { class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key}); const BrowseScreen({super.key});
@@ -18,11 +21,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
List<KanjiItem> _kanjiDeck = []; List<KanjiItem> _kanjiDeck = [];
List<VocabularyItem> _vocabDeck = []; List<VocabularyItem> _vocabDeck = [];
List<CustomKanjiItem> _customDeck = [];
Map<int, List<KanjiItem>> _kanjiByLevel = {}; Map<int, List<KanjiItem>> _kanjiByLevel = {};
Map<int, List<VocabularyItem>> _vocabByLevel = {}; Map<int, List<VocabularyItem>> _vocabByLevel = {};
List<int> _kanjiSortedLevels = []; List<int> _kanjiSortedLevels = [];
List<int> _vocabSortedLevels = []; List<int> _vocabSortedLevels = [];
final _customDeckRepository = CustomDeckRepository();
bool _isSelectionMode = false;
List<CustomKanjiItem> _selectedItems = [];
bool _loading = true; bool _loading = true;
String _status = 'Loading...'; String _status = 'Loading...';
int _currentKanjiPage = 0; int _currentKanjiPage = 0;
@@ -32,7 +41,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 3, vsync: this);
_kanjiPageController = PageController(); _kanjiPageController = PageController();
_vocabPageController = PageController(); _vocabPageController = PageController();
@@ -57,6 +66,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}); });
_loadDecks(); _loadDecks();
_loadCustomDeck();
} }
@override @override
@@ -67,75 +77,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
super.dispose(); super.dispose();
} }
Future<void> _loadDecks() async { Future<void> _loadCustomDeck() async {
setState(() => _loading = true); final customDeck = await _customDeckRepository.getCustomDeck();
try {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
setState(() { setState(() {
_apiKeyMissing = true; _customDeck = customDeck;
_loading = false;
});
return;
}
var kanji = await repo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey);
}
var vocab = await repo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
}
_kanjiDeck = kanji;
_vocabDeck = vocab;
_groupItemsByLevel();
setState(() {
_loading = false;
_status =
'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
_apiKeyMissing = false;
});
} catch (e) {
setState(() {
_status = 'Error: $e';
_loading = false;
}); });
} }
}
void _groupItemsByLevel() { Widget _buildWaniKaniTab(Widget child) {
_kanjiByLevel = {};
for (final item in _kanjiDeck) {
if (item.level > 0) {
(_kanjiByLevel[item.level] ??= []).add(item);
}
}
_kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort();
_vocabByLevel = {};
for (final item in _vocabDeck) {
if (item.level > 0) {
(_vocabByLevel[item.level] ??= []).add(item);
}
}
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
}
@override
Widget build(BuildContext context) {
if (_apiKeyMissing) { if (_apiKeyMissing) {
return Scaffold( return Center(
appBar: AppBar(title: const Text('Browse')),
body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -152,24 +103,11 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
), ),
], ],
), ),
),
); );
} }
return Scaffold( if (_loading) {
appBar: AppBar( return Center(
title: const Text('Browse'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Kanji'),
Tab(text: 'Vocabulary'),
],
),
),
backgroundColor: const Color(0xFF121212),
body: _loading
? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -178,35 +116,21 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Text(_status, style: const TextStyle(color: Colors.white)), Text(_status, style: const TextStyle(color: Colors.white)),
], ],
), ),
)
: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaginatedView(
_kanjiByLevel,
_kanjiSortedLevels,
_kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>())),
_buildPaginatedView(
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
],
),
),
SafeArea(
top: false,
child: _buildLevelSelector(),
),
],
),
); );
} }
return child;
}
Widget _buildCustomSrsTab() {
if (_customDeck.isEmpty) {
return const Center(
child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)),
);
}
return _buildCustomGridView(_customDeck);
}
Widget _buildPaginatedView( Widget _buildPaginatedView(
Map<int, List<dynamic>> groupedItems, Map<int, List<dynamic>> groupedItems,
List<int> sortedLevels, List<int> sortedLevels,
@@ -452,7 +376,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty) if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text( const Text(
'No readings available.', 'No readings available.',
style: TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
], ],
), ),
@@ -467,4 +391,290 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}, },
); );
} }
Future<void> _loadDecks() async {
setState(() => _loading = true);
try {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
setState(() {
_apiKeyMissing = true;
_loading = false;
});
return;
}
var kanji = await repo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey);
}
var vocab = await repo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
}
_kanjiDeck = kanji;
_vocabDeck = vocab;
_groupItemsByLevel();
setState(() {
_loading = false;
_status =
'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
_apiKeyMissing = false;
});
} catch (e) {
setState(() {
_status = 'Error: $e';
_loading = false;
});
}
}
void _groupItemsByLevel() {
_kanjiByLevel = {};
for (final item in _kanjiDeck) {
if (item.level > 0) {
(_kanjiByLevel[item.level] ??= []).add(item);
}
}
_kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort();
_vocabByLevel = {};
for (final item in _vocabDeck) {
if (item.level > 0) {
(_vocabByLevel[item.level] ??= []).add(item);
}
}
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
backgroundColor: const Color(0xFF121212),
body: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildWaniKaniTab(
_buildPaginatedView(
_kanjiByLevel,
_kanjiSortedLevels,
_kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>())),
),
_buildWaniKaniTab(
_buildPaginatedView(
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
),
_buildCustomSrsTab(),
],
),
),
if (!_isSelectionMode)
SafeArea(
top: false,
child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(),
),
],
),
);
}
AppBar _buildDefaultAppBar() {
return AppBar(
title: const Text('Browse'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Kanji'),
Tab(text: 'Vocabulary'),
Tab(text: 'Custom SRS'),
],
),
);
}
AppBar _buildSelectionAppBar() {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
_isSelectionMode = false;
_selectedItems.clear();
});
},
),
title: Text('${_selectedItems.length} selected'),
actions: [
IconButton(
icon: const Icon(Icons.select_all),
onPressed: _selectAll,
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _deleteSelected,
),
IconButton(
icon: const Icon(Icons.timer_off),
onPressed: _toggleIntervalForSelected,
),
],
);
}
void _selectAll() {
setState(() {
if (_selectedItems.length == _customDeck.length) {
_selectedItems.clear();
} else {
_selectedItems = List.from(_customDeck);
}
});
}
void _deleteSelected() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
for (final item in _selectedItems) {
await _customDeckRepository.deleteCard(item);
}
setState(() {
_isSelectionMode = false;
_selectedItems.clear();
});
_loadCustomDeck();
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
),
);
}
void _toggleIntervalForSelected() {
for (final item in _selectedItems) {
final updatedItem = CustomKanjiItem(
characters: item.characters,
meaning: item.meaning,
kanji: item.kanji,
useInterval: !item.useInterval,
srsLevel: item.srsLevel,
nextReview: item.nextReview,
);
_customDeckRepository.updateCard(updatedItem);
}
setState(() {
_isSelectionMode = false;
_selectedItems.clear();
});
_loadCustomDeck();
}
Widget _buildCustomGridView(List<CustomKanjiItem> items) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 1.2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
final isSelected = _selectedItems.contains(item);
return GestureDetector(
onLongPress: () {
setState(() {
_isSelectionMode = true;
_selectedItems.add(item);
});
},
onTap: () {
if (_isSelectionMode) {
setState(() {
if (isSelected) {
_selectedItems.remove(item);
if (_selectedItems.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedItems.add(item);
}
});
} else {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CustomCardDetailsScreen(
item: item,
repository: _customDeckRepository,
),
),
).then((_) => _loadCustomDeck());
}
},
child: Card(
color: isSelected
? Colors.blue.withOpacity(0.5)
: item.useInterval
? Color.lerp(const Color(0xFF1E1E1E), Colors.blue, 0.1)
: const Color(0xFF1E1E1E),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item.kanji ?? item.characters,
style: const TextStyle(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
Text(
item.meaning,
style: const TextStyle(color: Colors.grey, fontSize: 16),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildSrsIndicator(item.srsLevel),
],
),
),
),
);
},
padding: const EdgeInsets.all(8),
);
}
} }

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import '../models/custom_kanji_item.dart';
import '../services/custom_deck_repository.dart';
class CustomCardDetailsScreen extends StatefulWidget {
final CustomKanjiItem item;
final CustomDeckRepository repository;
const CustomCardDetailsScreen(
{super.key, required this.item, required this.repository});
@override
State<CustomCardDetailsScreen> createState() =>
_CustomCardDetailsScreenState();
}
class _CustomCardDetailsScreenState extends State<CustomCardDetailsScreen> {
late TextEditingController _japaneseController;
late TextEditingController _englishController;
late TextEditingController _kanjiController;
late bool _useInterval;
late int _srsLevel;
@override
void initState() {
super.initState();
_japaneseController = TextEditingController(text: widget.item.characters);
_englishController = TextEditingController(text: widget.item.meaning);
_kanjiController = TextEditingController(text: widget.item.kanji);
_useInterval = widget.item.useInterval;
_srsLevel = widget.item.srsLevel;
}
@override
void dispose() {
_japaneseController.dispose();
_englishController.dispose();
_kanjiController.dispose();
super.dispose();
}
void _saveChanges() {
final updatedItem = CustomKanjiItem(
characters: _japaneseController.text,
meaning: _englishController.text,
kanji: _kanjiController.text,
useInterval: _useInterval,
srsLevel: _srsLevel,
nextReview: widget.item.nextReview,
);
widget.repository.updateCard(updatedItem);
Navigator.of(context).pop(true);
}
void _deleteCard() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Card'),
content: const Text('Are you sure you want to delete this card?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
widget.repository.deleteCard(widget.item);
Navigator.of(context).pop();
Navigator.of(context).pop(true);
},
child: const Text('Delete'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Card'),
actions: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: _deleteCard,
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextFormField(
controller: _japaneseController,
decoration: const InputDecoration(labelText: 'Japanese (Kana)'),
),
TextFormField(
controller: _kanjiController,
decoration: const InputDecoration(labelText: 'Japanese (Kanji)'),
),
TextFormField(
controller: _englishController,
decoration: const InputDecoration(labelText: 'English'),
),
SwitchListTile(
title: const Text('Use Interval SRS'),
value: _useInterval,
onChanged: (value) {
setState(() {
_useInterval = value;
});
},
),
Text('SRS Level: $_srsLevel'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _saveChanges,
child: const Text('Save Changes'),
),
],
),
),
);
}
}

View File

@@ -9,16 +9,23 @@ enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehensi
class CustomQuizScreen extends StatefulWidget { class CustomQuizScreen extends StatefulWidget {
final List<CustomKanjiItem> deck; final List<CustomKanjiItem> deck;
final CustomQuizMode quizMode; final CustomQuizMode quizMode;
final Function(CustomKanjiItem) onCardReviewed;
final bool useKanji;
const CustomQuizScreen( const CustomQuizScreen({
{super.key, required this.deck, required this.quizMode}); super.key,
required this.deck,
required this.quizMode,
required this.onCardReviewed,
required this.useKanji,
});
@override @override
State<CustomQuizScreen> createState() => CustomQuizScreenState(); State<CustomQuizScreen> createState() => CustomQuizScreenState();
} }
class CustomQuizScreenState extends State<CustomQuizScreen> class CustomQuizScreenState extends State<CustomQuizScreen>
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { with TickerProviderStateMixin {
int _currentIndex = 0; int _currentIndex = 0;
List<CustomKanjiItem> _shuffledDeck = []; List<CustomKanjiItem> _shuffledDeck = [];
List<String> _options = []; List<String> _options = [];
@@ -27,17 +34,15 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
late FlutterTts _flutterTts; late FlutterTts _flutterTts;
late AnimationController _shakeController; late AnimationController _shakeController;
late Animation<double> _shakeAnimation; late Animation<double> _shakeAnimation;
bool _useKanji = false;
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_shuffledDeck = widget.deck.toList()..shuffle(); _shuffledDeck = widget.deck.toList()..shuffle();
_initTts(); _initTts();
if (_shuffledDeck.isNotEmpty) {
_generateOptions(); _generateOptions();
}
_shakeController = AnimationController( _shakeController = AnimationController(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
@@ -51,6 +56,16 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
); );
} }
@override
void didUpdateWidget(CustomQuizScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.useKanji != oldWidget.useKanji) {
setState(() {
_generateOptions();
});
}
}
void playAudio() { void playAudio() {
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters); _speak(_shuffledDeck[_currentIndex].characters);
@@ -60,7 +75,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void _initTts() async { void _initTts() async {
_flutterTts = FlutterTts(); _flutterTts = FlutterTts();
await _flutterTts.setLanguage("ja-JP"); await _flutterTts.setLanguage("ja-JP");
if (widget.quizMode == CustomQuizMode.listeningComprehension) { if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) {
_speak(_shuffledDeck[_currentIndex].characters); _speak(_shuffledDeck[_currentIndex].characters);
} }
} }
@@ -77,7 +92,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
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 = [_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)
@@ -87,7 +102,7 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
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(_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();
@@ -96,10 +111,22 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
void _checkAnswer(String answer) { void _checkAnswer(String answer) {
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = _shuffledDeck[_currentIndex];
final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese) final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
? (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters) ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
: currentItem.meaning; : currentItem.meaning;
final isCorrect = answer == correctAnswer; final isCorrect = answer == correctAnswer;
if (currentItem.useInterval) {
if (isCorrect) {
currentItem.srsLevel++;
final interval = pow(2, currentItem.srsLevel).toInt();
currentItem.nextReview = DateTime.now().add(Duration(hours: interval));
} else {
currentItem.srsLevel = max(0, currentItem.srsLevel - 1);
currentItem.nextReview = DateTime.now().add(const Duration(hours: 1));
}
widget.onCardReviewed(currentItem);
}
setState(() { setState(() {
_answered = true; _answered = true;
_correct = isCorrect; _correct = isCorrect;
@@ -139,43 +166,18 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
if (_shuffledDeck.isEmpty) { if (_shuffledDeck.isEmpty) {
return Scaffold( return const Center(
appBar: AppBar(), child: Text('Review session complete!'),
body: const Center(
child: Text('No cards in the deck!'),
),
); );
} }
final currentItem = _shuffledDeck[_currentIndex]; final currentItem = _shuffledDeck[_currentIndex];
final question = (widget.quizMode == CustomQuizMode.englishToJapanese) final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
? currentItem.meaning ? currentItem.meaning
: (_useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters); : (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
return Scaffold( return Center(
appBar: AppBar(
title: const Text('Quiz'),
actions: [
if (widget.quizMode != CustomQuizMode.listeningComprehension)
Row(
children: [
const Text('Kanji'),
Switch(
value: _useKanji,
onChanged: (value) {
setState(() {
_useKanji = value;
_generateOptions();
});
},
),
],
),
],
),
body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@@ -223,7 +225,6 @@ class CustomQuizScreenState extends State<CustomQuizScreen>
), ),
], ],
), ),
),
); );
} }
} }

View File

@@ -15,6 +15,8 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
late TabController _tabController; late TabController _tabController;
final _deckRepository = CustomDeckRepository(); final _deckRepository = CustomDeckRepository();
List<CustomKanjiItem> _deck = []; List<CustomKanjiItem> _deck = [];
List<CustomKanjiItem> _reviewDeck = [];
bool _useKanji = false;
final _quizScreenKeys = [ final _quizScreenKeys = [
GlobalKey<CustomQuizScreenState>(), GlobalKey<CustomQuizScreenState>(),
GlobalKey<CustomQuizScreenState>(), GlobalKey<CustomQuizScreenState>(),
@@ -30,6 +32,7 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
final key = _quizScreenKeys[_tabController.index]; final key = _quizScreenKeys[_tabController.index];
key.currentState?.playAudio(); key.currentState?.playAudio();
} }
setState(() {});
}); });
_loadDeck(); _loadDeck();
} }
@@ -42,16 +45,52 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
Future<void> _loadDeck() async { Future<void> _loadDeck() async {
final deck = await _deckRepository.getCustomDeck(); final deck = await _deckRepository.getCustomDeck();
final now = DateTime.now();
final reviewDeck = deck.where((item) {
if (!item.useInterval) {
return true;
}
return item.nextReview == null || item.nextReview!.isBefore(now);
}).toList();
setState(() { setState(() {
_deck = deck; _deck = deck;
_reviewDeck = reviewDeck;
}); });
} }
Future<void> _updateCard(CustomKanjiItem item) async {
final index = _deck.indexWhere((element) => element.characters == item.characters);
if (index != -1) {
setState(() {
_deck[index] = item;
_reviewDeck.removeWhere((element) => element.characters == item.characters);
});
await _deckRepository.saveDeck(_deck);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Custom SRS'), title: const Text('Custom SRS'),
actions: [
if (_tabController.index != 2)
Row(
children: [
const Text('Kanji'),
Switch(
value: _useKanji,
onChanged: (value) {
setState(() {
_useKanji = value;
});
},
),
],
),
],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
tabs: const [ tabs: const [
@@ -62,13 +101,33 @@ class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProv
), ),
), ),
body: _deck.isEmpty body: _deck.isEmpty
? const Center(child: CircularProgressIndicator()) ? const Center(child: Text('Add cards to start quizzing!'))
: _reviewDeck.isEmpty
? const Center(child: Text('No cards due for review.'))
: TabBarView( : TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
CustomQuizScreen(key: _quizScreenKeys[0], deck: _deck, quizMode: CustomQuizMode.japaneseToEnglish), CustomQuizScreen(
CustomQuizScreen(key: _quizScreenKeys[1], deck: _deck, quizMode: CustomQuizMode.englishToJapanese), key: _quizScreenKeys[0],
CustomQuizScreen(key: _quizScreenKeys[2], deck: _deck, quizMode: CustomQuizMode.listeningComprehension), deck: _reviewDeck,
quizMode: CustomQuizMode.japaneseToEnglish,
onCardReviewed: _updateCard,
useKanji: _useKanji,
),
CustomQuizScreen(
key: _quizScreenKeys[1],
deck: _reviewDeck,
quizMode: CustomQuizMode.englishToJapanese,
onCardReviewed: _updateCard,
useKanji: _useKanji,
),
CustomQuizScreen(
key: _quizScreenKeys[2],
deck: _reviewDeck,
quizMode: CustomQuizMode.listeningComprehension,
onCardReviewed: _updateCard,
useKanji: _useKanji,
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(

View File

@@ -24,97 +24,111 @@ class StartScreen extends StatelessWidget {
), ),
], ],
), ),
body: SafeArea( body: Container(
child: Center( decoration: BoxDecoration(
child: Padding( gradient: LinearGradient(
padding: const EdgeInsets.all(32), colors: [
child: Column( const Color(0xFF121212),
mainAxisAlignment: MainAxisAlignment.center, Colors.grey[900]!,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(16),
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.8,
children: [ children: [
ElevatedButton( _buildModeCard(
onPressed: () { context,
title: 'Kanji Quiz',
icon: Icons.extension,
description: 'Test your knowledge of kanji characters.',
onTap: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const HomeScreen()), MaterialPageRoute(builder: (_) => const HomeScreen()),
); );
}, },
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
), ),
child: const Text( _buildModeCard(
'Kanji Quiz', context,
style: TextStyle(fontSize: 18), title: 'Vocabulary Quiz',
), icon: Icons.school,
), description: 'Practice vocabulary from your WaniKani deck.',
const SizedBox(height: 16), onTap: () {
ElevatedButton(
onPressed: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const VocabScreen()), MaterialPageRoute(builder: (_) => const VocabScreen()),
); );
}, },
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
), ),
child: const Text( _buildModeCard(
'Vocabulary Quiz', context,
style: TextStyle(fontSize: 18), title: 'Browse Items',
), icon: Icons.grid_view,
), description: 'Look through your kanji and vocabulary decks.',
const SizedBox(height: 16), onTap: () {
ElevatedButton(
onPressed: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const BrowseScreen()), MaterialPageRoute(builder: (_) => const BrowseScreen()),
); );
}, },
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurpleAccent,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
), ),
child: const Text( _buildModeCard(
'Browse Items', context,
style: TextStyle(fontSize: 18), title: 'Custom SRS',
), icon: Icons.create,
), description: 'Create and study your own custom flashcards.',
const SizedBox(height: 16), onTap: () {
ElevatedButton(
onPressed: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CustomSrsScreen()), MaterialPageRoute(builder: (_) => const CustomSrsScreen()),
); );
}, },
style: ElevatedButton.styleFrom(
backgroundColor: Colors.greenAccent,
foregroundColor: Colors.black,
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
), ),
child: const Text( ],
'Custom SRS', ),
style: TextStyle(fontSize: 18), ),
);
}
Widget _buildModeCard(BuildContext context, {
required String title,
required IconData icon,
required String description,
required VoidCallback onTap,
}) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Expanded(
child: Text(
description,
style: const TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
softWrap: true,
), ),
), ),
], ],
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -19,10 +19,25 @@ class CustomDeckRepository {
Future<void> addCard(CustomKanjiItem item) async { Future<void> addCard(CustomKanjiItem item) async {
final deck = await getCustomDeck(); final deck = await getCustomDeck();
deck.add(item); deck.add(item);
await _saveDeck(deck); await saveDeck(deck);
} }
Future<void> _saveDeck(List<CustomKanjiItem> deck) async { Future<void> updateCard(CustomKanjiItem item) async {
final deck = await getCustomDeck();
final index = deck.indexWhere((element) => element.characters == item.characters);
if (index != -1) {
deck[index] = item;
await saveDeck(deck);
}
}
Future<void> deleteCard(CustomKanjiItem item) async {
final deck = await getCustomDeck();
deck.removeWhere((element) => element.characters == item.characters);
await saveDeck(deck);
}
Future<void> saveDeck(List<CustomKanjiItem> deck) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonList = deck.map((item) => item.toJson()).toList(); final jsonList = deck.map((item) => item.toJson()).toList();
await prefs.setString(_key, json.encode(jsonList)); await prefs.setString(_key, json.encode(jsonList));

View File

@@ -6,6 +6,8 @@ import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart'; import '../models/kanji_item.dart';
import '../api/wk_client.dart'; import '../api/wk_client.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
class DeckRepository { class DeckRepository {
Database? _db; Database? _db;
String? _apiKey; String? _apiKey;
@@ -98,6 +100,12 @@ class DeckRepository {
} }
Future<String?> loadApiKey() async { Future<String?> loadApiKey() async {
final envApiKey = dotenv.env['WANIKANI_API_KEY'];
if (envApiKey != null && envApiKey.isNotEmpty) {
_apiKey = envApiKey;
return _apiKey;
}
final db = await _openDb(); final db = await _openDb();
final rows = await db.query( final rows = await db.query(
'settings', 'settings',

View File

@@ -302,6 +302,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
url: "https://pub.dev"
source: hosted
version: "5.2.1"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:

View File

@@ -16,6 +16,7 @@ dependencies:
http: ^1.5.0 http: ^1.5.0
kana_kit: ^2.1.1 kana_kit: ^2.1.1
flutter_tts: ^3.8.5 flutter_tts: ^3.8.5
flutter_dotenv: ^5.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -34,3 +35,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/sfx/confirm.mp3 - assets/sfx/confirm.mp3
- .env