Merge pull request 'custom_srs' (#5) from custom_srs into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ app.*.map.json
|
|||||||
|
|
||||||
*.jks
|
*.jks
|
||||||
gradle.properties
|
gradle.properties
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
@@ -42,5 +42,8 @@
|
|||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
42
lib/src/models/custom_kanji_item.dart
Normal file
42
lib/src/models/custom_kanji_item.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'characters': characters,
|
||||||
|
'meaning': meaning,
|
||||||
|
'kanji': kanji,
|
||||||
|
'useInterval': useInterval,
|
||||||
|
'srsLevel': srsLevel,
|
||||||
|
'nextReview': nextReview?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
133
lib/src/screens/add_card_screen.dart
Normal file
133
lib/src/screens/add_card_screen.dart
Normal file
@@ -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<AddCardScreen> createState() => _AddCardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCardScreenState extends State<AddCardScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +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 'custom_card_details_screen.dart';
|
||||||
|
|
||||||
class BrowseScreen extends StatefulWidget {
|
class BrowseScreen extends StatefulWidget {
|
||||||
const BrowseScreen({super.key});
|
const BrowseScreen({super.key});
|
||||||
@@ -10,28 +14,34 @@ class BrowseScreen extends StatefulWidget {
|
|||||||
State<BrowseScreen> createState() => _BrowseScreenState();
|
State<BrowseScreen> createState() => _BrowseScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BrowseScreenState extends State<BrowseScreen>
|
class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderStateMixin {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late PageController _kanjiPageController;
|
late PageController _kanjiPageController;
|
||||||
late PageController _vocabPageController;
|
late PageController _vocabPageController;
|
||||||
|
|
||||||
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;
|
||||||
int _currentVocabPage = 0;
|
int _currentVocabPage = 0;
|
||||||
|
bool _apiKeyMissing = false;
|
||||||
|
|
||||||
@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();
|
||||||
|
|
||||||
@@ -56,126 +66,69 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
_loadDecks();
|
_loadDecks();
|
||||||
}
|
_loadCustomDeck();
|
||||||
|
|
||||||
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(() {
|
|
||||||
_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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void dispose() {
|
||||||
return Scaffold(
|
_tabController.dispose();
|
||||||
backgroundColor: const Color(0xFF121212),
|
_kanjiPageController.dispose();
|
||||||
appBar: AppBar(
|
_vocabPageController.dispose();
|
||||||
title: const Text('Browse Items'),
|
super.dispose();
|
||||||
backgroundColor: const Color(0xFF1F1F1F),
|
}
|
||||||
foregroundColor: Colors.white,
|
|
||||||
bottom: TabBar(
|
Future<void> _loadCustomDeck() async {
|
||||||
controller: _tabController,
|
final customDeck = await _customDeckRepository.getCustomDeck();
|
||||||
tabs: const [
|
setState(() {
|
||||||
Tab(text: 'Kanji'),
|
_customDeck = customDeck;
|
||||||
Tab(text: 'Vocabulary'),
|
});
|
||||||
],
|
}
|
||||||
labelColor: Colors.blueAccent,
|
|
||||||
unselectedLabelColor: Colors.grey,
|
Widget _buildWaniKaniTab(Widget child) {
|
||||||
indicatorColor: Colors.blueAccent,
|
if (_apiKeyMissing) {
|
||||||
),
|
return Center(
|
||||||
),
|
child: Column(
|
||||||
body: _loading
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
? Center(
|
children: [
|
||||||
child: Column(
|
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const SizedBox(height: 16),
|
||||||
children: [
|
ElevatedButton(
|
||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
onPressed: () async {
|
||||||
const SizedBox(height: 16),
|
await Navigator.of(context).push(
|
||||||
Text(_status, style: const TextStyle(color: Colors.white)),
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||||
],
|
);
|
||||||
),
|
_loadDecks();
|
||||||
)
|
},
|
||||||
: Column(
|
child: const Text('Go to Settings'),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _buildPaginatedView(
|
||||||
@@ -423,7 +376,7 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -439,11 +392,330 @@ class _BrowseScreenState extends State<BrowseScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
@override
|
||||||
void dispose() {
|
Widget build(BuildContext context) {
|
||||||
_tabController.dispose();
|
return Scaffold(
|
||||||
_kanjiPageController.dispose();
|
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
|
||||||
_vocabPageController.dispose();
|
backgroundColor: const Color(0xFF121212),
|
||||||
super.dispose();
|
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: 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<void> _toggleIntervalForSelected() async {
|
||||||
|
if (_selectedItems.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final bool targetState = _selectedItems.any((item) => !item.useInterval);
|
||||||
|
|
||||||
|
final selectedCharacters = _selectedItems.map((item) => item.characters).toSet();
|
||||||
|
|
||||||
|
final List<CustomKanjiItem> 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<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(
|
||||||
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
127
lib/src/screens/custom_card_details_screen.dart
Normal file
127
lib/src/screens/custom_card_details_screen.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
lib/src/screens/custom_quiz_screen.dart
Normal file
230
lib/src/screens/custom_quiz_screen.dart
Normal file
@@ -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<CustomKanjiItem> 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<CustomQuizScreen> createState() => CustomQuizScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomQuizScreenState extends State<CustomQuizScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
List<CustomKanjiItem> _shuffledDeck = [];
|
||||||
|
List<String> _options = [];
|
||||||
|
bool _answered = false;
|
||||||
|
bool? _correct;
|
||||||
|
late FlutterTts _flutterTts;
|
||||||
|
late AnimationController _shakeController;
|
||||||
|
late Animation<double> _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<double>(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<void> _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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
lib/src/screens/custom_srs_screen.dart
Normal file
144
lib/src/screens/custom_srs_screen.dart
Normal file
@@ -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<CustomSrsScreen> createState() => _CustomSrsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomSrsScreenState extends State<CustomSrsScreen> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final _deckRepository = CustomDeckRepository();
|
||||||
|
List<CustomKanjiItem> _deck = [];
|
||||||
|
List<CustomKanjiItem> _reviewDeck = [];
|
||||||
|
bool _useKanji = false;
|
||||||
|
final _quizScreenKeys = [
|
||||||
|
GlobalKey<CustomQuizScreenState>(),
|
||||||
|
GlobalKey<CustomQuizScreenState>(),
|
||||||
|
GlobalKey<CustomQuizScreenState>(),
|
||||||
|
];
|
||||||
|
|
||||||
|
@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<void> _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<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
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
List<KanjiItem> _deck = [];
|
List<KanjiItem> _deck = [];
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String _status = 'Loading deck...';
|
String _status = 'Loading deck...';
|
||||||
@@ -35,7 +36,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final Random _random = Random();
|
final Random _random = Random();
|
||||||
final _audioPlayer = AudioPlayer();
|
final _audioPlayer = AudioPlayer();
|
||||||
|
|
||||||
QuizMode _mode = QuizMode.kanjiToEnglish;
|
|
||||||
KanjiItem? _current;
|
KanjiItem? _current;
|
||||||
List<String> _options = [];
|
List<String> _options = [];
|
||||||
List<String> _correctAnswers = [];
|
List<String> _correctAnswers = [];
|
||||||
@@ -43,15 +43,27 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
int _score = 0;
|
int _score = 0;
|
||||||
int _asked = 0;
|
int _asked = 0;
|
||||||
bool _playCorrectSound = true;
|
bool _playCorrectSound = true;
|
||||||
|
bool _apiKeyMissing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
_nextQuestion();
|
||||||
|
});
|
||||||
_dg = widget.distractorGenerator ?? DistractorGenerator();
|
_dg = widget.distractorGenerator ?? DistractorGenerator();
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_loadDeck();
|
_loadDeck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -71,11 +83,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final apiKey = repo.apiKey;
|
final apiKey = repo.apiKey;
|
||||||
|
|
||||||
if (apiKey == null || apiKey.isEmpty) {
|
if (apiKey == null || apiKey.isEmpty) {
|
||||||
if (mounted) {
|
setState(() {
|
||||||
Navigator.of(context).pushReplacement(
|
_apiKeyMissing = true;
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
_loading = false;
|
||||||
);
|
});
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +102,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
_deck = items;
|
_deck = items;
|
||||||
_status = 'Loaded ${items.length} kanji';
|
_status = 'Loaded ${items.length} kanji';
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
_apiKeyMissing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_nextQuestion();
|
_nextQuestion();
|
||||||
@@ -123,6 +135,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
return _ReadingInfo(readingsList, hint);
|
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() {
|
void _nextQuestion() {
|
||||||
if (_deck.isEmpty) return;
|
if (_deck.isEmpty) return;
|
||||||
|
|
||||||
@@ -277,6 +302,30 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 prompt = '';
|
||||||
String subtitle = '';
|
String subtitle = '';
|
||||||
|
|
||||||
@@ -294,24 +343,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF121212),
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Hirameki SRS - Kanji'),
|
title: const Text('Kanji Quiz'),
|
||||||
backgroundColor: const Color(0xFF1F1F1F),
|
bottom: TabBar(
|
||||||
foregroundColor: Colors.white,
|
controller: _tabController,
|
||||||
elevation: 2,
|
tabs: const [
|
||||||
actions: [
|
Tab(text: 'Kanji→English'),
|
||||||
IconButton(
|
Tab(text: 'English→Kanji'),
|
||||||
icon: const Icon(Icons.settings),
|
Tab(text: 'Reading'),
|
||||||
onPressed: () async {
|
],
|
||||||
await Navigator.of(context).push(
|
),
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
||||||
);
|
|
||||||
_loadSettings();
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
backgroundColor: const Color(0xFF121212),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -328,17 +371,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
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),
|
const SizedBox(height: 18),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
@@ -382,21 +414,4 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,133 +1,128 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:hirameki_srs/src/screens/settings_screen.dart';
|
||||||
import '../services/deck_repository.dart';
|
|
||||||
import 'browse_screen.dart';
|
import 'browse_screen.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
import 'vocab_screen.dart';
|
import 'vocab_screen.dart';
|
||||||
|
import 'custom_srs_screen.dart';
|
||||||
|
|
||||||
class StartScreen extends StatefulWidget {
|
class StartScreen extends StatelessWidget {
|
||||||
const StartScreen({super.key});
|
const StartScreen({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<StartScreen> createState() => _StartScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StartScreenState extends State<StartScreen> {
|
|
||||||
bool _loading = true;
|
|
||||||
bool _hasApiKey = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_checkApiKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkApiKey() async {
|
|
||||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
|
||||||
await repo.loadApiKey();
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty;
|
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
|
||||||
return const Scaffold(
|
|
||||||
backgroundColor: Color(0xFF121212),
|
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.blueAccent),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF121212),
|
appBar: AppBar(
|
||||||
body: Center(
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||||
'Welcome to Hirameki SRS!',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.headlineMedium
|
|
||||||
?.copyWith(fontSize: 28, color: Colors.white),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_hasApiKey
|
title,
|
||||||
? 'Your API key is set. You can start the quiz!'
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
: 'Before you start, please set up your WaniKani API key in the settings.',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.bodyMedium
|
|
||||||
?.copyWith(color: Colors.grey[300]),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton(
|
Expanded(
|
||||||
onPressed: () {
|
child: Text(
|
||||||
Navigator.of(context).push(
|
description,
|
||||||
MaterialPageRoute(builder: (_) => HomeScreen()),
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
);
|
textAlign: TextAlign.center,
|
||||||
},
|
softWrap: true,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ class VocabScreen extends StatefulWidget {
|
|||||||
State<VocabScreen> createState() => _VocabScreenState();
|
State<VocabScreen> createState() => _VocabScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VocabScreenState extends State<VocabScreen> {
|
class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
List<VocabularyItem> _deck = [];
|
List<VocabularyItem> _deck = [];
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String _status = 'Loading deck...';
|
String _status = 'Loading deck...';
|
||||||
final DistractorGenerator _dg = DistractorGenerator();
|
final DistractorGenerator _dg = DistractorGenerator();
|
||||||
final _audioPlayer = AudioPlayer();
|
final _audioPlayer = AudioPlayer();
|
||||||
|
|
||||||
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
|
|
||||||
VocabularyItem? _current;
|
VocabularyItem? _current;
|
||||||
List<String> _options = [];
|
List<String> _options = [];
|
||||||
List<String> _correctAnswers = [];
|
List<String> _correctAnswers = [];
|
||||||
@@ -33,14 +33,26 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
int _asked = 0;
|
int _asked = 0;
|
||||||
bool _playAudio = true;
|
bool _playAudio = true;
|
||||||
bool _playCorrectSound = true;
|
bool _playCorrectSound = true;
|
||||||
|
bool _apiKeyMissing = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_tabController = TabController(length: 3, vsync: this);
|
||||||
|
_tabController.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
_nextQuestion();
|
||||||
|
});
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
_loadDeck();
|
_loadDeck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadSettings() async {
|
Future<void> _loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -61,11 +73,10 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
final apiKey = repo.apiKey;
|
final apiKey = repo.apiKey;
|
||||||
|
|
||||||
if (apiKey == null || apiKey.isEmpty) {
|
if (apiKey == null || apiKey.isEmpty) {
|
||||||
if (mounted) {
|
setState(() {
|
||||||
Navigator.of(context).pushReplacement(
|
_apiKeyMissing = true;
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
_loading = false;
|
||||||
);
|
});
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +93,7 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
_deck = items;
|
_deck = items;
|
||||||
_status = 'Loaded ${items.length} vocabulary';
|
_status = 'Loaded ${items.length} vocabulary';
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
_apiKeyMissing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_nextQuestion();
|
_nextQuestion();
|
||||||
@@ -101,6 +113,19 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
.join(' ');
|
.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() {
|
void _nextQuestion() {
|
||||||
if (_deck.isEmpty) return;
|
if (_deck.isEmpty) return;
|
||||||
|
|
||||||
@@ -257,6 +282,30 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
Widget promptWidget;
|
||||||
|
|
||||||
if (_current == null) {
|
if (_current == null) {
|
||||||
@@ -283,24 +332,18 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF121212),
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Hirameki SRS - Vocab'),
|
title: const Text('Vocabulary Quiz'),
|
||||||
backgroundColor: const Color(0xFF1F1F1F),
|
bottom: TabBar(
|
||||||
foregroundColor: Colors.white,
|
controller: _tabController,
|
||||||
elevation: 2,
|
tabs: const [
|
||||||
actions: [
|
Tab(text: 'Vocab→English'),
|
||||||
IconButton(
|
Tab(text: 'English→Vocab'),
|
||||||
icon: const Icon(Icons.settings),
|
Tab(text: 'Listening'),
|
||||||
onPressed: () async {
|
],
|
||||||
await Navigator.of(context).push(
|
),
|
||||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
||||||
);
|
|
||||||
_loadSettings();
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
backgroundColor: const Color(0xFF121212),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -317,17 +360,6 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
const CircularProgressIndicator(color: Colors.blueAccent),
|
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),
|
const SizedBox(height: 18),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
@@ -370,23 +402,5 @@ class _VocabScreenState extends State<VocabScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
56
lib/src/services/custom_deck_repository.dart
Normal file
56
lib/src/services/custom_deck_repository.dart
Normal file
@@ -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<List<CustomKanjiItem>> getCustomDeck() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_key);
|
||||||
|
if (jsonString != null) {
|
||||||
|
final List<dynamic> jsonList = json.decode(jsonString);
|
||||||
|
return jsonList.map((json) => CustomKanjiItem.fromJson(json)).toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addCard(CustomKanjiItem item) async {
|
||||||
|
final deck = await getCustomDeck();
|
||||||
|
deck.add(item);
|
||||||
|
await saveDeck(deck);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> updateCards(List<CustomKanjiItem> 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<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 jsonList = deck.map((item) => item.toJson()).toList();
|
||||||
|
await prefs.setString(_key, json.encode(jsonList));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@@ -185,6 +185,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
version: "2.0.4"
|
||||||
|
checks:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checks
|
||||||
|
sha256: "016871c84732c1ac9856b8940236d5a5802ba638b3bd3e0ea7027b51a35f7aa7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
cli_config:
|
cli_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -294,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:
|
||||||
@@ -315,6 +331,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -400,6 +424,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
16
pubspec.yaml
16
pubspec.yaml
@@ -8,12 +8,15 @@ dependencies:
|
|||||||
audioplayers: any
|
audioplayers: any
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
http: any
|
shared_preferences: ^2.5.3
|
||||||
path: any
|
sqflite: ^2.4.2
|
||||||
path_provider: any
|
path_provider: ^2.1.5
|
||||||
provider: any
|
path: ^1.9.1
|
||||||
shared_preferences: any
|
provider: ^6.1.5
|
||||||
sqflite: any
|
http: ^1.5.0
|
||||||
|
kana_kit: ^2.1.1
|
||||||
|
flutter_tts: ^3.8.5
|
||||||
|
flutter_dotenv: ^5.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -32,3 +35,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/sfx/confirm.mp3
|
- assets/sfx/confirm.mp3
|
||||||
|
- .env
|
||||||
|
|||||||
Reference in New Issue
Block a user