diff --git a/.gitignore b/.gitignore
index d12fe35..0716726 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ app.*.map.json
*.jks
gradle.properties
+
+# Environment variables
+.env
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0f0132b..5afdc62 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,5 +42,8 @@
+
+
+
diff --git a/lib/main.dart b/lib/main.dart
index e7b4dd9..ba8558a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'src/services/deck_repository.dart';
import 'src/screens/start_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
+ await dotenv.load(fileName: ".env");
runApp(
Provider(
diff --git a/lib/src/models/custom_kanji_item.dart b/lib/src/models/custom_kanji_item.dart
new file mode 100644
index 0000000..2d8bd0e
--- /dev/null
+++ b/lib/src/models/custom_kanji_item.dart
@@ -0,0 +1,42 @@
+
+class CustomKanjiItem {
+ final String characters;
+ final String meaning;
+ final String? kanji;
+ final bool useInterval;
+ int srsLevel;
+ DateTime? nextReview;
+
+ CustomKanjiItem({
+ required this.characters,
+ required this.meaning,
+ this.kanji,
+ this.useInterval = false,
+ this.srsLevel = 0,
+ this.nextReview,
+ });
+
+ factory CustomKanjiItem.fromJson(Map json) {
+ return CustomKanjiItem(
+ characters: json['characters'] as String,
+ meaning: json['meaning'] as String,
+ kanji: json['kanji'] as String?,
+ useInterval: json['useInterval'] as bool? ?? false,
+ srsLevel: json['srsLevel'] as int? ?? 0,
+ nextReview: json['nextReview'] != null
+ ? DateTime.parse(json['nextReview'] as String)
+ : null,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'characters': characters,
+ 'meaning': meaning,
+ 'kanji': kanji,
+ 'useInterval': useInterval,
+ 'srsLevel': srsLevel,
+ 'nextReview': nextReview?.toIso8601String(),
+ };
+ }
+}
diff --git a/lib/src/screens/add_card_screen.dart b/lib/src/screens/add_card_screen.dart
new file mode 100644
index 0000000..c3cd3de
--- /dev/null
+++ b/lib/src/screens/add_card_screen.dart
@@ -0,0 +1,133 @@
+
+import 'package:flutter/material.dart';
+import 'package:kana_kit/kana_kit.dart';
+import '../models/custom_kanji_item.dart';
+import '../services/custom_deck_repository.dart';
+
+class AddCardScreen extends StatefulWidget {
+ const AddCardScreen({super.key});
+
+ @override
+ State createState() => _AddCardScreenState();
+}
+
+class _AddCardScreenState extends State {
+ final _formKey = GlobalKey();
+ final _japaneseController = TextEditingController();
+ final _englishController = TextEditingController();
+ final _kanjiController = TextEditingController();
+ final _kanaKit = const KanaKit();
+ final _deckRepository = CustomDeckRepository();
+ bool _useInterval = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _japaneseController.addListener(_convertToKana);
+ }
+
+ @override
+ void dispose() {
+ _japaneseController.removeListener(_convertToKana);
+ _japaneseController.dispose();
+ _englishController.dispose();
+ _kanjiController.dispose();
+ super.dispose();
+ }
+
+ void _convertToKana() {
+ final text = _japaneseController.text;
+ final converted = _kanaKit.toKana(text);
+ if (text != converted) {
+ _japaneseController.value = _japaneseController.value.copyWith(
+ text: converted,
+ selection: TextSelection.fromPosition(
+ TextPosition(offset: converted.length),
+ ),
+ );
+ }
+ }
+
+ void _saveCard() {
+ if (_formKey.currentState!.validate()) {
+ final newItem = CustomKanjiItem(
+ characters: _japaneseController.text,
+ meaning: _englishController.text,
+ kanji: _kanjiController.text.isNotEmpty ? _kanjiController.text : null,
+ useInterval: _useInterval,
+ nextReview: _useInterval ? DateTime.now() : null,
+ );
+ _deckRepository.addCard(newItem);
+ Navigator.of(context).pop();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Add New Card'),
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Form(
+ key: _formKey,
+ child: Column(
+ children: [
+ TextFormField(
+ controller: _japaneseController,
+ decoration: const InputDecoration(
+ labelText: 'Japanese (Kana)',
+ hintText: 'Enter Japanese vocabulary or kanji',
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter a Japanese term';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _kanjiController,
+ decoration: const InputDecoration(
+ labelText: 'Japanese (Kanji)',
+ hintText: 'Enter the kanji (optional)',
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextFormField(
+ controller: _englishController,
+ decoration: const InputDecoration(
+ labelText: 'English',
+ hintText: 'Enter the English meaning',
+ ),
+ validator: (value) {
+ if (value == null || value.isEmpty) {
+ return 'Please enter an English meaning';
+ }
+ return null;
+ },
+ ),
+ const SizedBox(height: 16),
+ SwitchListTile(
+ title: const Text('Use Interval-based SRS'),
+ value: _useInterval,
+ onChanged: (value) {
+ setState(() {
+ _useInterval = value;
+ });
+ },
+ ),
+ const SizedBox(height: 32),
+ ElevatedButton(
+ onPressed: _saveCard,
+ child: const Text('Save Card'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart
index e199b18..909bb2f 100644
--- a/lib/src/screens/browse_screen.dart
+++ b/lib/src/screens/browse_screen.dart
@@ -2,6 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/kanji_item.dart';
import '../services/deck_repository.dart';
+import '../services/custom_deck_repository.dart';
+import '../models/custom_kanji_item.dart';
+import 'settings_screen.dart';
+import 'custom_card_details_screen.dart';
class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key});
@@ -10,28 +14,34 @@ class BrowseScreen extends StatefulWidget {
State createState() => _BrowseScreenState();
}
-class _BrowseScreenState extends State
- with SingleTickerProviderStateMixin {
+class _BrowseScreenState extends State with SingleTickerProviderStateMixin {
late TabController _tabController;
late PageController _kanjiPageController;
late PageController _vocabPageController;
List _kanjiDeck = [];
List _vocabDeck = [];
+ List _customDeck = [];
Map> _kanjiByLevel = {};
Map> _vocabByLevel = {};
List _kanjiSortedLevels = [];
List _vocabSortedLevels = [];
+ final _customDeckRepository = CustomDeckRepository();
+
+ bool _isSelectionMode = false;
+ List _selectedItems = [];
+
bool _loading = true;
String _status = 'Loading...';
int _currentKanjiPage = 0;
int _currentVocabPage = 0;
+ bool _apiKeyMissing = false;
@override
void initState() {
super.initState();
- _tabController = TabController(length: 2, vsync: this);
+ _tabController = TabController(length: 3, vsync: this);
_kanjiPageController = PageController();
_vocabPageController = PageController();
@@ -56,126 +66,69 @@ class _BrowseScreenState extends State
});
_loadDecks();
- }
-
- Future _loadDecks() async {
- setState(() => _loading = true);
- try {
- final repo = Provider.of(context, listen: false);
- await repo.loadApiKey();
- final apiKey = repo.apiKey;
-
- if (apiKey == null || apiKey.isEmpty) {
- setState(() {
- _status = 'API key not set.';
- _loading = false;
- });
- return;
- }
-
- var kanji = await repo.loadKanji();
- if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
- setState(() => _status = 'Fetching kanji from WaniKani...');
- kanji = await repo.fetchAndCacheFromWk(apiKey);
- }
-
- var vocab = await repo.loadVocabulary();
- if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
- setState(() => _status = 'Fetching vocabulary from WaniKani...');
- vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
- }
-
- _kanjiDeck = kanji;
- _vocabDeck = vocab;
- _groupItemsByLevel();
-
- setState(() {
- _loading = false;
- _status =
- 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
- });
- } catch (e) {
- setState(() {
- _status = 'Error: $e';
- _loading = false;
- });
- }
- }
-
- void _groupItemsByLevel() {
- _kanjiByLevel = {};
- for (final item in _kanjiDeck) {
- if (item.level > 0) {
- (_kanjiByLevel[item.level] ??= []).add(item);
- }
- }
- _kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort();
-
- _vocabByLevel = {};
- for (final item in _vocabDeck) {
- if (item.level > 0) {
- (_vocabByLevel[item.level] ??= []).add(item);
- }
- }
- _vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
+ _loadCustomDeck();
}
@override
- Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: const Color(0xFF121212),
- appBar: AppBar(
- title: const Text('Browse Items'),
- backgroundColor: const Color(0xFF1F1F1F),
- foregroundColor: Colors.white,
- bottom: TabBar(
- controller: _tabController,
- tabs: const [
- Tab(text: 'Kanji'),
- Tab(text: 'Vocabulary'),
- ],
- labelColor: Colors.blueAccent,
- unselectedLabelColor: Colors.grey,
- indicatorColor: Colors.blueAccent,
- ),
- ),
- body: _loading
- ? Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const CircularProgressIndicator(color: Colors.blueAccent),
- const SizedBox(height: 16),
- Text(_status, style: const TextStyle(color: Colors.white)),
- ],
- ),
- )
- : Column(
- children: [
- Expanded(
- child: TabBarView(
- controller: _tabController,
- children: [
- _buildPaginatedView(
- _kanjiByLevel,
- _kanjiSortedLevels,
- _kanjiPageController,
- (items) => _buildGridView(items.cast())),
- _buildPaginatedView(
- _vocabByLevel,
- _vocabSortedLevels,
- _vocabPageController,
- (items) => _buildListView(items.cast())),
- ],
- ),
- ),
- SafeArea(
- top: false,
- child: _buildLevelSelector(),
- ),
- ],
+ void dispose() {
+ _tabController.dispose();
+ _kanjiPageController.dispose();
+ _vocabPageController.dispose();
+ super.dispose();
+ }
+
+ Future _loadCustomDeck() async {
+ final customDeck = await _customDeckRepository.getCustomDeck();
+ setState(() {
+ _customDeck = customDeck;
+ });
+ }
+
+ Widget _buildWaniKaniTab(Widget child) {
+ if (_apiKeyMissing) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ );
+ _loadDecks();
+ },
+ child: const Text('Go to Settings'),
),
- );
+ ],
+ ),
+ );
+ }
+
+ if (_loading) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const CircularProgressIndicator(color: Colors.blueAccent),
+ const SizedBox(height: 16),
+ Text(_status, style: const TextStyle(color: Colors.white)),
+ ],
+ ),
+ );
+ }
+
+ return child;
+ }
+
+ Widget _buildCustomSrsTab() {
+ if (_customDeck.isEmpty) {
+ return const Center(
+ child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)),
+ );
+ }
+ return _buildCustomGridView(_customDeck);
}
Widget _buildPaginatedView(
@@ -423,7 +376,7 @@ class _BrowseScreenState extends State
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text(
'No readings available.',
- style: TextStyle(color: Colors.white),
+ style: const TextStyle(color: Colors.white),
),
],
),
@@ -439,11 +392,330 @@ class _BrowseScreenState extends State
);
}
+ Future _loadDecks() async {
+ setState(() => _loading = true);
+ try {
+ final repo = Provider.of(context, listen: false);
+ await repo.loadApiKey();
+ final apiKey = repo.apiKey;
+
+ if (apiKey == null || apiKey.isEmpty) {
+ setState(() {
+ _apiKeyMissing = true;
+ _loading = false;
+ });
+ return;
+ }
+
+ var kanji = await repo.loadKanji();
+ if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
+ setState(() => _status = 'Fetching kanji from WaniKani...');
+ kanji = await repo.fetchAndCacheFromWk(apiKey);
+ }
+
+ var vocab = await repo.loadVocabulary();
+ if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
+ setState(() => _status = 'Fetching vocabulary from WaniKani...');
+ vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
+ }
+
+ _kanjiDeck = kanji;
+ _vocabDeck = vocab;
+ _groupItemsByLevel();
+
+ setState(() {
+ _loading = false;
+ _status =
+ 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
+ _apiKeyMissing = false;
+ });
+ } catch (e) {
+ setState(() {
+ _status = 'Error: $e';
+ _loading = false;
+ });
+ }
+ }
+
+ void _groupItemsByLevel() {
+ _kanjiByLevel = {};
+ for (final item in _kanjiDeck) {
+ if (item.level > 0) {
+ (_kanjiByLevel[item.level] ??= []).add(item);
+ }
+ }
+ _kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort();
+
+ _vocabByLevel = {};
+ for (final item in _vocabDeck) {
+ if (item.level > 0) {
+ (_vocabByLevel[item.level] ??= []).add(item);
+ }
+ }
+ _vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
+ }
+
@override
- void dispose() {
- _tabController.dispose();
- _kanjiPageController.dispose();
- _vocabPageController.dispose();
- super.dispose();
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
+ backgroundColor: const Color(0xFF121212),
+ body: Column(
+ children: [
+ Expanded(
+ child: TabBarView(
+ controller: _tabController,
+ children: [
+ _buildWaniKaniTab(
+ _buildPaginatedView(
+ _kanjiByLevel,
+ _kanjiSortedLevels,
+ _kanjiPageController,
+ (items) => _buildGridView(items.cast())),
+ ),
+ _buildWaniKaniTab(
+ _buildPaginatedView(
+ _vocabByLevel,
+ _vocabSortedLevels,
+ _vocabPageController,
+ (items) => _buildListView(items.cast())),
+ ),
+ _buildCustomSrsTab(),
+ ],
+ ),
+ ),
+ if (!_isSelectionMode)
+ SafeArea(
+ top: false,
+ child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(),
+ ),
+ ],
+ ),
+ );
+ }
+
+ AppBar _buildDefaultAppBar() {
+ return AppBar(
+ title: const Text('Browse'),
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(text: 'Kanji'),
+ Tab(text: 'Vocabulary'),
+ Tab(text: 'Custom SRS'),
+ ],
+ ),
+ );
+ }
+
+ AppBar _buildSelectionAppBar() {
+ return AppBar(
+ leading: IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () {
+ setState(() {
+ _isSelectionMode = false;
+ _selectedItems.clear();
+ });
+ },
+ ),
+ title: Text('${_selectedItems.length} selected'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.select_all),
+ onPressed: _selectAll,
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete),
+ onPressed: _deleteSelected,
+ ),
+ IconButton(
+ icon: Icon(_toggleIntervalIcon),
+ onPressed: _toggleIntervalForSelected,
+ ),
+ ],
+ );
+ }
+
+ IconData get _toggleIntervalIcon {
+ if (_selectedItems.isEmpty) {
+ return Icons.timer_off;
+ }
+ final bool willEnable = _selectedItems.any((item) => !item.useInterval);
+ return willEnable ? Icons.timer : Icons.timer_off;
+ }
+
+ void _selectAll() {
+ setState(() {
+ if (_selectedItems.length == _customDeck.length) {
+ _selectedItems.clear();
+ } else {
+ _selectedItems = List.from(_customDeck);
+ }
+ });
+ }
+
+ void _deleteSelected() {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete Selected'),
+ content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () async {
+ for (final item in _selectedItems) {
+ await _customDeckRepository.deleteCard(item);
+ }
+ setState(() {
+ _isSelectionMode = false;
+ _selectedItems.clear();
+ });
+ _loadCustomDeck();
+ Navigator.of(context).pop();
+ },
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _toggleIntervalForSelected() async {
+ if (_selectedItems.isEmpty) {
+ return;
+ }
+ final bool targetState = _selectedItems.any((item) => !item.useInterval);
+
+ final selectedCharacters = _selectedItems.map((item) => item.characters).toSet();
+
+ final List updatedItems = [];
+ for (final item in _selectedItems) {
+ final updatedItem = CustomKanjiItem(
+ characters: item.characters,
+ meaning: item.meaning,
+ kanji: item.kanji,
+ useInterval: targetState,
+ srsLevel: item.srsLevel,
+ nextReview: item.nextReview,
+ );
+ updatedItems.add(updatedItem);
+ }
+
+ await _customDeckRepository.updateCards(updatedItems);
+ await _loadCustomDeck();
+
+ final newSelectedItems = _customDeck
+ .where((item) => selectedCharacters.contains(item.characters))
+ .toList();
+
+ setState(() {
+ _selectedItems = newSelectedItems;
+ if (_selectedItems.isEmpty) {
+ _isSelectionMode = false;
+ }
+ });
+ }
+
+ Widget _buildCustomGridView(List items) {
+ return GridView.builder(
+ gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 200,
+ childAspectRatio: 1.2,
+ crossAxisSpacing: 8,
+ mainAxisSpacing: 8,
+ ),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ final item = items[index];
+ final isSelected = _selectedItems.contains(item);
+ return GestureDetector(
+ onLongPress: () {
+ setState(() {
+ _isSelectionMode = true;
+ _selectedItems.add(item);
+ });
+ },
+ onTap: () {
+ if (_isSelectionMode) {
+ setState(() {
+ if (isSelected) {
+ _selectedItems.remove(item);
+ if (_selectedItems.isEmpty) {
+ _isSelectionMode = false;
+ }
+ } else {
+ _selectedItems.add(item);
+ }
+ });
+ } else {
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => CustomCardDetailsScreen(
+ item: item,
+ repository: _customDeckRepository,
+ ),
+ ),
+ ).then((_) => _loadCustomDeck());
+ }
+ },
+ child: Card(
+ shape: RoundedRectangleBorder(
+ side: isSelected
+ ? const BorderSide(color: Colors.blue, width: 2.0)
+ : BorderSide.none,
+ borderRadius: BorderRadius.circular(12.0),
+ ),
+ color: isSelected
+ ? Colors.blue.withOpacity(0.5)
+ : const Color(0xFF1E1E1E),
+ child: Stack(
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Text(
+ item.kanji ?? item.characters,
+ style: const TextStyle(fontSize: 32, color: Colors.white),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ item.meaning,
+ style: const TextStyle(color: Colors.grey, fontSize: 16),
+ textAlign: TextAlign.center,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 8),
+ _buildSrsIndicator(item.srsLevel),
+ ],
+ ),
+ ),
+ if (item.useInterval)
+ Positioned(
+ top: 4,
+ right: 4,
+ child: Icon(
+ Icons.timer,
+ color: Colors.green,
+ size: 16,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ padding: const EdgeInsets.all(8),
+ );
}
}
diff --git a/lib/src/screens/custom_card_details_screen.dart b/lib/src/screens/custom_card_details_screen.dart
new file mode 100644
index 0000000..c303127
--- /dev/null
+++ b/lib/src/screens/custom_card_details_screen.dart
@@ -0,0 +1,127 @@
+import 'package:flutter/material.dart';
+import '../models/custom_kanji_item.dart';
+import '../services/custom_deck_repository.dart';
+
+class CustomCardDetailsScreen extends StatefulWidget {
+ final CustomKanjiItem item;
+ final CustomDeckRepository repository;
+
+ const CustomCardDetailsScreen(
+ {super.key, required this.item, required this.repository});
+
+ @override
+ State createState() =>
+ _CustomCardDetailsScreenState();
+}
+
+class _CustomCardDetailsScreenState extends State {
+ late TextEditingController _japaneseController;
+ late TextEditingController _englishController;
+ late TextEditingController _kanjiController;
+ late bool _useInterval;
+ late int _srsLevel;
+
+ @override
+ void initState() {
+ super.initState();
+ _japaneseController = TextEditingController(text: widget.item.characters);
+ _englishController = TextEditingController(text: widget.item.meaning);
+ _kanjiController = TextEditingController(text: widget.item.kanji);
+ _useInterval = widget.item.useInterval;
+ _srsLevel = widget.item.srsLevel;
+ }
+
+ @override
+ void dispose() {
+ _japaneseController.dispose();
+ _englishController.dispose();
+ _kanjiController.dispose();
+ super.dispose();
+ }
+
+ void _saveChanges() {
+ final updatedItem = CustomKanjiItem(
+ characters: _japaneseController.text,
+ meaning: _englishController.text,
+ kanji: _kanjiController.text,
+ useInterval: _useInterval,
+ srsLevel: _srsLevel,
+ nextReview: widget.item.nextReview,
+ );
+ widget.repository.updateCard(updatedItem);
+ Navigator.of(context).pop(true);
+ }
+
+ void _deleteCard() {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Delete Card'),
+ content: const Text('Are you sure you want to delete this card?'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () {
+ widget.repository.deleteCard(widget.item);
+ Navigator.of(context).pop();
+ Navigator.of(context).pop(true);
+ },
+ child: const Text('Delete'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Edit Card'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.delete),
+ onPressed: _deleteCard,
+ ),
+ ],
+ ),
+ body: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ children: [
+ TextFormField(
+ controller: _japaneseController,
+ decoration: const InputDecoration(labelText: 'Japanese (Kana)'),
+ ),
+ TextFormField(
+ controller: _kanjiController,
+ decoration: const InputDecoration(labelText: 'Japanese (Kanji)'),
+ ),
+ TextFormField(
+ controller: _englishController,
+ decoration: const InputDecoration(labelText: 'English'),
+ ),
+ SwitchListTile(
+ title: const Text('Use Interval SRS'),
+ value: _useInterval,
+ onChanged: (value) {
+ setState(() {
+ _useInterval = value;
+ });
+ },
+ ),
+ Text('SRS Level: $_srsLevel'),
+ const SizedBox(height: 20),
+ ElevatedButton(
+ onPressed: _saveChanges,
+ child: const Text('Save Changes'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/custom_quiz_screen.dart b/lib/src/screens/custom_quiz_screen.dart
new file mode 100644
index 0000000..b304703
--- /dev/null
+++ b/lib/src/screens/custom_quiz_screen.dart
@@ -0,0 +1,230 @@
+import 'package:flutter/material.dart';
+import 'dart:math';
+import 'package:flutter_tts/flutter_tts.dart';
+import '../models/custom_kanji_item.dart';
+import '../widgets/options_grid.dart';
+
+enum CustomQuizMode { japaneseToEnglish, englishToJapanese, listeningComprehension }
+
+class CustomQuizScreen extends StatefulWidget {
+ final List deck;
+ final CustomQuizMode quizMode;
+ final Function(CustomKanjiItem) onCardReviewed;
+ final bool useKanji;
+
+ const CustomQuizScreen({
+ super.key,
+ required this.deck,
+ required this.quizMode,
+ required this.onCardReviewed,
+ required this.useKanji,
+ });
+
+ @override
+ State createState() => CustomQuizScreenState();
+}
+
+class CustomQuizScreenState extends State
+ with TickerProviderStateMixin {
+ int _currentIndex = 0;
+ List _shuffledDeck = [];
+ List _options = [];
+ bool _answered = false;
+ bool? _correct;
+ late FlutterTts _flutterTts;
+ late AnimationController _shakeController;
+ late Animation _shakeAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _shuffledDeck = widget.deck.toList()..shuffle();
+ _initTts();
+ if (_shuffledDeck.isNotEmpty) {
+ _generateOptions();
+ }
+
+ _shakeController = AnimationController(
+ duration: const Duration(milliseconds: 500),
+ vsync: this,
+ );
+ _shakeAnimation = Tween(begin: 0, end: 1).animate(
+ CurvedAnimation(
+ parent: _shakeController,
+ curve: Curves.elasticIn,
+ ),
+ );
+ }
+
+ @override
+ void didUpdateWidget(CustomQuizScreen oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.useKanji != oldWidget.useKanji) {
+ setState(() {
+ _generateOptions();
+ });
+ }
+ }
+
+ void playAudio() {
+ if (widget.quizMode == CustomQuizMode.listeningComprehension) {
+ _speak(_shuffledDeck[_currentIndex].characters);
+ }
+ }
+
+ void _initTts() async {
+ _flutterTts = FlutterTts();
+ await _flutterTts.setLanguage("ja-JP");
+ if (_shuffledDeck.isNotEmpty && widget.quizMode == CustomQuizMode.listeningComprehension) {
+ _speak(_shuffledDeck[_currentIndex].characters);
+ }
+ }
+
+ @override
+ void dispose() {
+ _flutterTts.stop();
+ _shakeController.dispose();
+ super.dispose();
+ }
+
+ void _generateOptions() {
+ final currentItem = _shuffledDeck[_currentIndex];
+ if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) {
+ _options = [currentItem.meaning];
+ } else {
+ _options = [widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters];
+ }
+ final otherItems = widget.deck
+ .where((item) => item.characters != currentItem.characters)
+ .toList();
+ otherItems.shuffle();
+ for (var i = 0; i < min(3, otherItems.length); i++) {
+ if (widget.quizMode == CustomQuizMode.listeningComprehension || widget.quizMode == CustomQuizMode.japaneseToEnglish) {
+ _options.add(otherItems[i].meaning);
+ } else {
+ _options.add(widget.useKanji && otherItems[i].kanji != null ? otherItems[i].kanji! : otherItems[i].characters);
+ }
+ }
+ _options.shuffle();
+ }
+
+ void _checkAnswer(String answer) {
+ final currentItem = _shuffledDeck[_currentIndex];
+ final correctAnswer = (widget.quizMode == CustomQuizMode.englishToJapanese)
+ ? (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters)
+ : currentItem.meaning;
+ final isCorrect = answer == correctAnswer;
+
+ if (currentItem.useInterval) {
+ if (isCorrect) {
+ currentItem.srsLevel++;
+ final interval = pow(2, currentItem.srsLevel).toInt();
+ currentItem.nextReview = DateTime.now().add(Duration(hours: interval));
+ } else {
+ currentItem.srsLevel = max(0, currentItem.srsLevel - 1);
+ currentItem.nextReview = DateTime.now().add(const Duration(hours: 1));
+ }
+ widget.onCardReviewed(currentItem);
+ }
+
+ setState(() {
+ _answered = true;
+ _correct = isCorrect;
+ });
+
+ if (isCorrect) {
+ if (widget.quizMode == CustomQuizMode.japaneseToEnglish ||
+ widget.quizMode == CustomQuizMode.listeningComprehension) {
+ _speak(currentItem.characters);
+ }
+ } else {
+ _shakeController.forward(from: 0);
+ }
+ }
+
+ void _nextQuestion() {
+ setState(() {
+ _currentIndex = (_currentIndex + 1) % _shuffledDeck.length;
+ _answered = false;
+ _correct = null;
+ _generateOptions();
+ });
+ if (widget.quizMode == CustomQuizMode.listeningComprehension) {
+ _speak(_shuffledDeck[_currentIndex].characters);
+ }
+ }
+
+ Future _speak(String text) async {
+ await _flutterTts.speak(text);
+ }
+
+ void _onOptionSelected(String option) {
+ if (!(_answered && _correct!)) {
+ _checkAnswer(option);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_shuffledDeck.isEmpty) {
+ return const Center(
+ child: Text('Review session complete!'),
+ );
+ }
+
+ final currentItem = _shuffledDeck[_currentIndex];
+ final question = (widget.quizMode == CustomQuizMode.englishToJapanese)
+ ? currentItem.meaning
+ : (widget.useKanji && currentItem.kanji != null ? currentItem.kanji! : currentItem.characters);
+
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (widget.quizMode == CustomQuizMode.listeningComprehension)
+ IconButton(
+ icon: const Icon(Icons.volume_up, size: 64),
+ onPressed: () => _speak(currentItem.characters),
+ )
+ else
+ GestureDetector(
+ onTap: () => _speak(question),
+ child: Text(
+ question,
+ style: const TextStyle(fontSize: 48),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(height: 32),
+ if (_answered)
+ Text(
+ _correct! ? 'Correct!' : 'Incorrect, try again!',
+ style: TextStyle(
+ fontSize: 24,
+ color: _correct! ? Colors.green : Colors.red,
+ ),
+ ),
+ const SizedBox(height: 32),
+ AnimatedBuilder(
+ animation: _shakeAnimation,
+ builder: (context, child) {
+ return Transform.translate(
+ offset: Offset(_shakeAnimation.value * 10, 0),
+ child: child,
+ );
+ },
+ child: OptionsGrid(
+ options: _options,
+ onSelected: _onOptionSelected,
+ ),
+ ),
+ if (_answered && _correct!)
+ ElevatedButton(
+ onPressed: _nextQuestion,
+ child: const Text('Next'),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/custom_srs_screen.dart b/lib/src/screens/custom_srs_screen.dart
new file mode 100644
index 0000000..8727e28
--- /dev/null
+++ b/lib/src/screens/custom_srs_screen.dart
@@ -0,0 +1,144 @@
+import 'package:flutter/material.dart';
+import '../models/custom_kanji_item.dart';
+import '../services/custom_deck_repository.dart';
+import 'add_card_screen.dart';
+import 'custom_quiz_screen.dart';
+
+class CustomSrsScreen extends StatefulWidget {
+ const CustomSrsScreen({super.key});
+
+ @override
+ State createState() => _CustomSrsScreenState();
+}
+
+class _CustomSrsScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
+ final _deckRepository = CustomDeckRepository();
+ List _deck = [];
+ List _reviewDeck = [];
+ bool _useKanji = false;
+ final _quizScreenKeys = [
+ GlobalKey(),
+ GlobalKey(),
+ GlobalKey(),
+ ];
+
+ @override
+ void initState() {
+ super.initState();
+ _tabController = TabController(length: 3, vsync: this);
+ _tabController.addListener(() {
+ if (_tabController.indexIsChanging) {
+ final key = _quizScreenKeys[_tabController.index];
+ key.currentState?.playAudio();
+ }
+ setState(() {});
+ });
+ _loadDeck();
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
+ Future _loadDeck() async {
+ final deck = await _deckRepository.getCustomDeck();
+ final now = DateTime.now();
+ final reviewDeck = deck.where((item) {
+ if (!item.useInterval) {
+ return true;
+ }
+ return item.nextReview == null || item.nextReview!.isBefore(now);
+ }).toList();
+
+ setState(() {
+ _deck = deck;
+ _reviewDeck = reviewDeck;
+ });
+ }
+
+ Future _updateCard(CustomKanjiItem item) async {
+ final index = _deck.indexWhere((element) => element.characters == item.characters);
+ if (index != -1) {
+ setState(() {
+ _deck[index] = item;
+ _reviewDeck.removeWhere((element) => element.characters == item.characters);
+ });
+ await _deckRepository.saveDeck(_deck);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Custom SRS'),
+ actions: [
+ if (_tabController.index != 2)
+ Row(
+ children: [
+ const Text('Kanji'),
+ Switch(
+ value: _useKanji,
+ onChanged: (value) {
+ setState(() {
+ _useKanji = value;
+ });
+ },
+ ),
+ ],
+ ),
+ ],
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(text: 'Jpn→Eng'),
+ Tab(text: 'Eng→Jpn'),
+ Tab(text: 'Listening'),
+ ],
+ ),
+ ),
+ body: _deck.isEmpty
+ ? const Center(child: Text('Add cards to start quizzing!'))
+ : _reviewDeck.isEmpty
+ ? const Center(child: Text('No cards due for review.'))
+ : TabBarView(
+ controller: _tabController,
+ children: [
+ CustomQuizScreen(
+ key: _quizScreenKeys[0],
+ deck: _reviewDeck,
+ quizMode: CustomQuizMode.japaneseToEnglish,
+ onCardReviewed: _updateCard,
+ useKanji: _useKanji,
+ ),
+ CustomQuizScreen(
+ key: _quizScreenKeys[1],
+ deck: _reviewDeck,
+ quizMode: CustomQuizMode.englishToJapanese,
+ onCardReviewed: _updateCard,
+ useKanji: _useKanji,
+ ),
+ CustomQuizScreen(
+ key: _quizScreenKeys[2],
+ deck: _reviewDeck,
+ quizMode: CustomQuizMode.listeningComprehension,
+ onCardReviewed: _updateCard,
+ useKanji: _useKanji,
+ ),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const AddCardScreen()),
+ );
+ _loadDeck();
+ },
+ child: const Icon(Icons.add),
+ ),
+ );
+ }
+}
diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart
index e80d93f..3c3d77e 100644
--- a/lib/src/screens/home_screen.dart
+++ b/lib/src/screens/home_screen.dart
@@ -27,7 +27,8 @@ class HomeScreen extends StatefulWidget {
State createState() => _HomeScreenState();
}
-class _HomeScreenState extends State {
+class _HomeScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
List _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
@@ -35,7 +36,6 @@ class _HomeScreenState extends State {
final Random _random = Random();
final _audioPlayer = AudioPlayer();
- QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
List _options = [];
List _correctAnswers = [];
@@ -43,15 +43,27 @@ class _HomeScreenState extends State {
int _score = 0;
int _asked = 0;
bool _playCorrectSound = true;
+ bool _apiKeyMissing = false;
@override
void initState() {
super.initState();
+ _tabController = TabController(length: 3, vsync: this);
+ _tabController.addListener(() {
+ setState(() {});
+ _nextQuestion();
+ });
_dg = widget.distractorGenerator ?? DistractorGenerator();
_loadSettings();
_loadDeck();
}
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
Future _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -71,11 +83,10 @@ class _HomeScreenState extends State {
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
- if (mounted) {
- Navigator.of(context).pushReplacement(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
- );
- }
+ setState(() {
+ _apiKeyMissing = true;
+ _loading = false;
+ });
return;
}
@@ -91,6 +102,7 @@ class _HomeScreenState extends State {
_deck = items;
_status = 'Loaded ${items.length} kanji';
_loading = false;
+ _apiKeyMissing = false;
});
_nextQuestion();
@@ -123,6 +135,19 @@ class _HomeScreenState extends State {
return _ReadingInfo(readingsList, hint);
}
+ QuizMode get _mode {
+ switch (_tabController.index) {
+ case 0:
+ return QuizMode.kanjiToEnglish;
+ case 1:
+ return QuizMode.englishToKanji;
+ case 2:
+ return QuizMode.reading;
+ default:
+ return QuizMode.kanjiToEnglish;
+ }
+ }
+
void _nextQuestion() {
if (_deck.isEmpty) return;
@@ -277,6 +302,30 @@ class _HomeScreenState extends State {
@override
Widget build(BuildContext context) {
+ if (_apiKeyMissing) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Kanji Quiz')),
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ );
+ _loadDeck();
+ },
+ child: const Text('Go to Settings'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
String prompt = '';
String subtitle = '';
@@ -294,24 +343,18 @@ class _HomeScreenState extends State {
}
return Scaffold(
- backgroundColor: const Color(0xFF121212),
appBar: AppBar(
- title: const Text('Hirameki SRS - Kanji'),
- backgroundColor: const Color(0xFF1F1F1F),
- foregroundColor: Colors.white,
- elevation: 2,
- actions: [
- IconButton(
- icon: const Icon(Icons.settings),
- onPressed: () async {
- await Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
- );
- _loadSettings();
- },
- )
- ],
+ title: const Text('Kanji Quiz'),
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(text: 'Kanji→English'),
+ Tab(text: 'English→Kanji'),
+ Tab(text: 'Reading'),
+ ],
+ ),
),
+ backgroundColor: const Color(0xFF121212),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -328,17 +371,6 @@ class _HomeScreenState extends State {
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
- const SizedBox(height: 12),
- Wrap(
- spacing: 6,
- runSpacing: 4,
- alignment: WrapAlignment.center,
- children: [
- _buildChoiceChip('Kanji→English', QuizMode.kanjiToEnglish),
- _buildChoiceChip('English→Kanji', QuizMode.englishToKanji),
- _buildChoiceChip('Reading', QuizMode.reading),
- ],
- ),
const SizedBox(height: 18),
Expanded(
flex: 3,
@@ -382,21 +414,4 @@ class _HomeScreenState extends State {
),
);
}
-
- ChoiceChip _buildChoiceChip(String label, QuizMode mode) {
- final selected = _mode == mode;
- return ChoiceChip(
- label: Text(
- label,
- style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
- ),
- selected: selected,
- onSelected: (v) {
- setState(() => _mode = mode);
- _nextQuestion();
- },
- selectedColor: Colors.blueAccent,
- backgroundColor: const Color(0xFF1E1E1E),
- );
- }
-}
+}
\ No newline at end of file
diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart
index 0a742f4..61876c5 100644
--- a/lib/src/screens/start_screen.dart
+++ b/lib/src/screens/start_screen.dart
@@ -1,133 +1,128 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
-import '../services/deck_repository.dart';
+import 'package:hirameki_srs/src/screens/settings_screen.dart';
import 'browse_screen.dart';
import 'home_screen.dart';
import 'vocab_screen.dart';
+import 'custom_srs_screen.dart';
-class StartScreen extends StatefulWidget {
+class StartScreen extends StatelessWidget {
const StartScreen({super.key});
- @override
- State createState() => _StartScreenState();
-}
-
-class _StartScreenState extends State {
- bool _loading = true;
- bool _hasApiKey = false;
-
- @override
- void initState() {
- super.initState();
- _checkApiKey();
- }
-
- Future _checkApiKey() async {
- final repo = Provider.of(context, listen: false);
- await repo.loadApiKey();
-
- setState(() {
- _hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty;
- _loading = false;
- });
- }
-
@override
Widget build(BuildContext context) {
- if (_loading) {
- return const Scaffold(
- backgroundColor: Color(0xFF121212),
- body: Center(
- child: CircularProgressIndicator(color: Colors.blueAccent),
- ),
- );
- }
-
return Scaffold(
- backgroundColor: const Color(0xFF121212),
- body: Center(
+ appBar: AppBar(
+ title: const Text('Hirameki SRS'),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.settings),
+ onPressed: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ );
+ },
+ ),
+ ],
+ ),
+ body: Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ const Color(0xFF121212),
+ Colors.grey[900]!,
+ ],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ child: GridView.count(
+ crossAxisCount: 2,
+ padding: const EdgeInsets.all(16),
+ crossAxisSpacing: 16,
+ mainAxisSpacing: 16,
+ childAspectRatio: 0.8,
+ children: [
+ _buildModeCard(
+ context,
+ title: 'Kanji Quiz',
+ icon: Icons.extension,
+ description: 'Test your knowledge of kanji characters.',
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const HomeScreen()),
+ );
+ },
+ ),
+ _buildModeCard(
+ context,
+ title: 'Vocabulary Quiz',
+ icon: Icons.school,
+ description: 'Practice vocabulary from your WaniKani deck.',
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const VocabScreen()),
+ );
+ },
+ ),
+ _buildModeCard(
+ context,
+ title: 'Browse Items',
+ icon: Icons.grid_view,
+ description: 'Look through your kanji and vocabulary decks.',
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const BrowseScreen()),
+ );
+ },
+ ),
+ _buildModeCard(
+ context,
+ title: 'Custom SRS',
+ icon: Icons.create,
+ description: 'Create and study your own custom flashcards.',
+ onTap: () {
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const CustomSrsScreen()),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildModeCard(BuildContext context, {
+ required String title,
+ required IconData icon,
+ required String description,
+ required VoidCallback onTap,
+ }) {
+ return Card(
+ elevation: 4,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
child: Padding(
- padding: const EdgeInsets.all(32),
+ padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
- Text(
- 'Welcome to Hirameki SRS!',
- style: Theme.of(context)
- .textTheme
- .headlineMedium
- ?.copyWith(fontSize: 28, color: Colors.white),
- textAlign: TextAlign.center,
- ),
+ Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(
- _hasApiKey
- ? 'Your API key is set. You can start the quiz!'
- : 'Before you start, please set up your WaniKani API key in the settings.',
- style: Theme.of(context)
- .textTheme
- .bodyMedium
- ?.copyWith(color: Colors.grey[300]),
+ title,
+ style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
- const SizedBox(height: 32),
- ElevatedButton(
- onPressed: () {
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => HomeScreen()),
- );
- },
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.blueAccent,
- foregroundColor: Colors.white,
- padding:
- const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12)),
- ),
- child: const Text(
- 'Kanji Quiz',
- style: TextStyle(fontSize: 18),
- ),
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: () {
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const VocabScreen()),
- );
- },
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.blueAccent,
- foregroundColor: Colors.white,
- padding:
- const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12)),
- ),
- child: const Text(
- 'Vocabulary Quiz',
- style: TextStyle(fontSize: 18),
- ),
- ),
- const SizedBox(height: 16),
- ElevatedButton(
- onPressed: () {
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const BrowseScreen()),
- );
- },
- style: ElevatedButton.styleFrom(
- backgroundColor: Colors.deepPurpleAccent,
- foregroundColor: Colors.white,
- padding:
- const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(12)),
- ),
- child: const Text(
- 'Browse Items',
- style: TextStyle(fontSize: 18),
+ const SizedBox(height: 8),
+ Expanded(
+ child: Text(
+ description,
+ style: const TextStyle(fontSize: 12, color: Colors.grey),
+ textAlign: TextAlign.center,
+ softWrap: true,
),
),
],
@@ -136,4 +131,4 @@ class _StartScreenState extends State {
),
);
}
-}
+}
\ No newline at end of file
diff --git a/lib/src/screens/vocab_screen.dart b/lib/src/screens/vocab_screen.dart
index 7d2b9e5..b47facb 100644
--- a/lib/src/screens/vocab_screen.dart
+++ b/lib/src/screens/vocab_screen.dart
@@ -18,14 +18,14 @@ class VocabScreen extends StatefulWidget {
State createState() => _VocabScreenState();
}
-class _VocabScreenState extends State {
+class _VocabScreenState extends State with SingleTickerProviderStateMixin {
+ late TabController _tabController;
List _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final _audioPlayer = AudioPlayer();
- VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
List _options = [];
List _correctAnswers = [];
@@ -33,14 +33,26 @@ class _VocabScreenState extends State {
int _asked = 0;
bool _playAudio = true;
bool _playCorrectSound = true;
+ bool _apiKeyMissing = false;
@override
void initState() {
super.initState();
+ _tabController = TabController(length: 3, vsync: this);
+ _tabController.addListener(() {
+ setState(() {});
+ _nextQuestion();
+ });
_loadSettings();
_loadDeck();
}
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
+ }
+
Future _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -61,11 +73,10 @@ class _VocabScreenState extends State {
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
- if (mounted) {
- Navigator.of(context).pushReplacement(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
- );
- }
+ setState(() {
+ _apiKeyMissing = true;
+ _loading = false;
+ });
return;
}
@@ -82,6 +93,7 @@ class _VocabScreenState extends State {
_deck = items;
_status = 'Loaded ${items.length} vocabulary';
_loading = false;
+ _apiKeyMissing = false;
});
_nextQuestion();
@@ -101,6 +113,19 @@ class _VocabScreenState extends State {
.join(' ');
}
+ VocabQuizMode get _mode {
+ switch (_tabController.index) {
+ case 0:
+ return VocabQuizMode.vocabToEnglish;
+ case 1:
+ return VocabQuizMode.englishToVocab;
+ case 2:
+ return VocabQuizMode.audioToEnglish;
+ default:
+ return VocabQuizMode.vocabToEnglish;
+ }
+ }
+
void _nextQuestion() {
if (_deck.isEmpty) return;
@@ -257,6 +282,30 @@ class _VocabScreenState extends State {
@override
Widget build(BuildContext context) {
+ if (_apiKeyMissing) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Vocabulary Quiz')),
+ body: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () async {
+ await Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const SettingsScreen()),
+ );
+ _loadDeck();
+ },
+ child: const Text('Go to Settings'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
Widget promptWidget;
if (_current == null) {
@@ -283,24 +332,18 @@ class _VocabScreenState extends State {
}
return Scaffold(
- backgroundColor: const Color(0xFF121212),
appBar: AppBar(
- title: const Text('Hirameki SRS - Vocab'),
- backgroundColor: const Color(0xFF1F1F1F),
- foregroundColor: Colors.white,
- elevation: 2,
- actions: [
- IconButton(
- icon: const Icon(Icons.settings),
- onPressed: () async {
- await Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const SettingsScreen()),
- );
- _loadSettings();
- },
- )
- ],
+ title: const Text('Vocabulary Quiz'),
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(text: 'Vocab→English'),
+ Tab(text: 'English→Vocab'),
+ Tab(text: 'Listening'),
+ ],
+ ),
),
+ backgroundColor: const Color(0xFF121212),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -317,17 +360,6 @@ class _VocabScreenState extends State {
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
- const SizedBox(height: 12),
- Wrap(
- spacing: 6,
- runSpacing: 4,
- alignment: WrapAlignment.center,
- children: [
- _buildChoiceChip('Vocab→English', VocabQuizMode.vocabToEnglish),
- _buildChoiceChip('English→Vocab', VocabQuizMode.englishToVocab),
- _buildChoiceChip('Listening', VocabQuizMode.audioToEnglish),
- ],
- ),
const SizedBox(height: 18),
Expanded(
flex: 3,
@@ -370,23 +402,5 @@ class _VocabScreenState extends State {
),
),
);
-
}
-
- ChoiceChip _buildChoiceChip(String label, VocabQuizMode mode) {
- final selected = _mode == mode;
- return ChoiceChip(
- label: Text(
- label,
- style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
- ),
- selected: selected,
- onSelected: (v) {
- setState(() => _mode = mode);
- _nextQuestion();
- },
- selectedColor: Colors.blueAccent,
- backgroundColor: const Color(0xFF1E1E1E),
- );
- }
-}
+}
\ No newline at end of file
diff --git a/lib/src/services/custom_deck_repository.dart b/lib/src/services/custom_deck_repository.dart
new file mode 100644
index 0000000..0e21da4
--- /dev/null
+++ b/lib/src/services/custom_deck_repository.dart
@@ -0,0 +1,56 @@
+
+import 'dart:convert';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../models/custom_kanji_item.dart';
+
+class CustomDeckRepository {
+ static const _key = 'custom_deck';
+
+ Future> getCustomDeck() async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonString = prefs.getString(_key);
+ if (jsonString != null) {
+ final List jsonList = json.decode(jsonString);
+ return jsonList.map((json) => CustomKanjiItem.fromJson(json)).toList();
+ }
+ return [];
+ }
+
+ Future addCard(CustomKanjiItem item) async {
+ final deck = await getCustomDeck();
+ deck.add(item);
+ await saveDeck(deck);
+ }
+
+ Future updateCard(CustomKanjiItem item) async {
+ final deck = await getCustomDeck();
+ final index = deck.indexWhere((element) => element.characters == item.characters);
+ if (index != -1) {
+ deck[index] = item;
+ await saveDeck(deck);
+ }
+ }
+
+ Future updateCards(List itemsToUpdate) async {
+ final deck = await getCustomDeck();
+ for (var item in itemsToUpdate) {
+ final index = deck.indexWhere((element) => element.characters == item.characters);
+ if (index != -1) {
+ deck[index] = item;
+ }
+ }
+ await saveDeck(deck);
+ }
+
+ Future deleteCard(CustomKanjiItem item) async {
+ final deck = await getCustomDeck();
+ deck.removeWhere((element) => element.characters == item.characters);
+ await saveDeck(deck);
+ }
+
+ Future saveDeck(List deck) async {
+ final prefs = await SharedPreferences.getInstance();
+ final jsonList = deck.map((item) => item.toJson()).toList();
+ await prefs.setString(_key, json.encode(jsonList));
+ }
+}
diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart
index 200af1c..3d447ff 100644
--- a/lib/src/services/deck_repository.dart
+++ b/lib/src/services/deck_repository.dart
@@ -6,6 +6,8 @@ import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart';
import '../api/wk_client.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+
class DeckRepository {
Database? _db;
String? _apiKey;
@@ -98,6 +100,12 @@ class DeckRepository {
}
Future loadApiKey() async {
+ final envApiKey = dotenv.env['WANIKANI_API_KEY'];
+ if (envApiKey != null && envApiKey.isNotEmpty) {
+ _apiKey = envApiKey;
+ return _apiKey;
+ }
+
final db = await _openDb();
final rows = await db.query(
'settings',
diff --git a/pubspec.lock b/pubspec.lock
index e649ab0..58b4324 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -185,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
+ checks:
+ dependency: transitive
+ description:
+ name: checks
+ sha256: "016871c84732c1ac9856b8940236d5a5802ba638b3bd3e0ea7027b51a35f7aa7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.1"
cli_config:
dependency: transitive
description:
@@ -294,6 +302,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_dotenv:
+ dependency: "direct main"
+ description:
+ name: flutter_dotenv
+ sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
@@ -315,6 +331,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ flutter_tts:
+ dependency: "direct main"
+ description:
+ name: flutter_tts
+ sha256: cbb3fd43b946e62398560235469e6113e4fe26c40eab1b7cb5e7c417503fb3a8
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.8.5"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -400,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.0"
+ kana_kit:
+ dependency: "direct main"
+ description:
+ name: kana_kit
+ sha256: "4e99cfddae947971c327ef3d8d82d35cf036c046c7f460583785d48c0f777fa3"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
leak_tracker:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ad9a205..ad2c70d 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -8,12 +8,15 @@ dependencies:
audioplayers: any
flutter:
sdk: flutter
- http: any
- path: any
- path_provider: any
- provider: any
- shared_preferences: any
- sqflite: any
+ shared_preferences: ^2.5.3
+ sqflite: ^2.4.2
+ path_provider: ^2.1.5
+ path: ^1.9.1
+ provider: ^6.1.5
+ http: ^1.5.0
+ kana_kit: ^2.1.1
+ flutter_tts: ^3.8.5
+ flutter_dotenv: ^5.1.0
dev_dependencies:
flutter_test:
@@ -32,3 +35,4 @@ flutter:
uses-material-design: true
assets:
- assets/sfx/confirm.mp3
+ - .env