9 Commits

Author SHA1 Message Date
Rene Kievits
78fc69aeb4 fix .env on dev 2025-10-30 17:48:12 +01:00
45d52dbc84 Merge pull request 'custom_srs' (#5) from custom_srs into master
Reviewed-on: #5
2025-10-30 17:42:46 +01:00
Rene Kievits
d8a5c27fb3 finish custom srs for now 2025-10-30 17:41:53 +01:00
Rene Kievits
ee4fd7ffc1 add a shit ton of feature for the custom srs 2025-10-30 03:44:04 +01:00
Rene Kievits
b58a4020e1 add custom srs 2025-10-30 02:00:29 +01:00
Rene Kievits
fe5ac30294 quick fix 2025-10-29 02:56:56 +01:00
Rene Kievits
6785bb9133 some build stuff and quick fix 2025-10-29 02:47:41 +01:00
Rene Kievits
d2070cd6b8 small readme change 2025-10-28 22:26:20 +01:00
Rene Kievits
e034ecd763 remove prints 2025-10-28 21:43:21 +01:00
21 changed files with 1630 additions and 528 deletions

6
.gitignore vendored
View File

@@ -43,3 +43,9 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
*.jks
gradle.properties
# Environment variables
.env

View File

@@ -32,8 +32,8 @@ Getting the app up and running is simple.
Clone the repository and install the dependencies:
```bash
git clone https://github.com/your-username/wanikani-kanji-srs.git
cd wanikani-kanji-srs
git clone https://github.com/crylia/hirameki-srs.git
cd hirameki-srs
flutter pub get
```
@@ -57,5 +57,4 @@ flutter run
- **Start Screen**: From the main screen, you can jump into a "Kanji Quiz", "Vocabulary Quiz", or "Browse Items".
- **Quiz Screen**: Choose your desired quiz mode at the top. The app will start presenting questions based on your SRS progress.
- **Browse Screen**: Switch between Kanji and Vocabulary using the tabs at the top. Swipe left or right to navigate between levels. Use the scrollable navigator at the bottom to jump to a specific level quickly.
- **Browse Screen**: Switch between Kanji and Vocabulary using the tabs at the top. Swipe left or right to navigate between levels. Use the scrollable navigator at the bottom to jump to a specific level quickly.

View File

@@ -1,12 +1,12 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("org.jetbrains.kotlin.android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.untitled1"
namespace = "com.crylia.hirameki"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -20,17 +20,31 @@ android {
}
defaultConfig {
applicationId = "com.crylia.wanikani_kanji_srs"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
applicationId = "com.crylia.hirameki_srs"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
storeFile = file("hirameki-release-key.jks")
storePassword = project.findProperty("KEYSTORE_PASSWORD")?.toString()
keyAlias = project.findProperty("KEY_ALIAS")?.toString()
keyPassword = project.findProperty("KEY_PASSWORD")?.toString()
}
}
buildTypes {
release {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}

View File

@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Hirameki SRS">
android:label="Hirameki SRS"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -42,5 +42,8 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
</queries>
</manifest>

View File

@@ -1,4 +1,4 @@
package com.example.untitled1
package com.crylia.hirameki
import io.flutter.embedding.android.FlutterActivity

View File

@@ -1,3 +1,11 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
// Ensure these match your Flutter Gradle plugin requirements
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
allprojects {
repositories {
google()
@@ -5,18 +13,17 @@ allprojects {
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
// Optional: custom build directory (keep this if you really need a shared build folder)
val newBuildDir = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.set(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
val newSubprojectBuildDir = newBuildDir.dir(name)
layout.buildDirectory.set(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {

View File

@@ -1,10 +1,17 @@
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();
try {
await dotenv.load(fileName: ".env");
} catch (e) {
// It's okay if the .env file is not found.
// This is expected in release builds.
}
runApp(
Provider<DeckRepository>(
@@ -20,10 +27,10 @@ class WkApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WaniKani SRS',
title: 'Hirameki SRS',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true),
home: const StartScreen(),
);
}
}
}

View File

@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'services/deck_repository.dart';
class WkApp extends StatelessWidget {
const WkApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<DeckRepository>(create: (_) => DeckRepository()),
],
child: MaterialApp(
title: 'WaniKani SRS',
theme: ThemeData(useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)),
home: const HomeScreen(),
),
);
}
}

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

View 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'),
),
],
),
),
),
);
}
}

View File

@@ -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<BrowseScreen> createState() => _BrowseScreenState();
}
class _BrowseScreenState extends State<BrowseScreen>
with SingleTickerProviderStateMixin {
class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
late PageController _kanjiPageController;
late PageController _vocabPageController;
List<KanjiItem> _kanjiDeck = [];
List<VocabularyItem> _vocabDeck = [];
List<CustomKanjiItem> _customDeck = [];
Map<int, List<KanjiItem>> _kanjiByLevel = {};
Map<int, List<VocabularyItem>> _vocabByLevel = {};
List<int> _kanjiSortedLevels = [];
List<int> _vocabSortedLevels = [];
final _customDeckRepository = CustomDeckRepository();
bool _isSelectionMode = false;
List<CustomKanjiItem> _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<BrowseScreen>
});
_loadDecks();
}
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();
_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<KanjiItem>())),
_buildPaginatedView(
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
],
),
),
SafeArea(
top: false,
child: _buildLevelSelector(),
),
],
void dispose() {
_tabController.dispose();
_kanjiPageController.dispose();
_vocabPageController.dispose();
super.dispose();
}
Future<void> _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<BrowseScreen>
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<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
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<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),
);
}
}

View File

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

View File

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

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

View File

@@ -27,7 +27,8 @@ class HomeScreen extends StatefulWidget {
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
List<KanjiItem> _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
@@ -35,7 +36,6 @@ class _HomeScreenState extends State<HomeScreen> {
final Random _random = Random();
final _audioPlayer = AudioPlayer();
QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
@@ -43,15 +43,27 @@ class _HomeScreenState extends State<HomeScreen> {
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<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -71,11 +83,10 @@ class _HomeScreenState extends State<HomeScreen> {
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<HomeScreen> {
_deck = items;
_status = 'Loaded ${items.length} kanji';
_loading = false;
_apiKeyMissing = false;
});
_nextQuestion();
@@ -123,8 +135,20 @@ class _HomeScreenState extends State<HomeScreen> {
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() {
print('deck size: ${_deck.length}');
if (_deck.isEmpty) return;
_deck.sort((a, b) {
@@ -165,7 +189,6 @@ class _HomeScreenState extends State<HomeScreen> {
});
_current = _deck.first;
print('current: ${_current?.characters}');
_correctAnswers = [];
_options = [];
@@ -211,9 +234,6 @@ class _HomeScreenState extends State<HomeScreen> {
break;
}
print('options: $_options');
print('correctAnswers: $_correctAnswers');
setState(() {});
}
@@ -282,6 +302,30 @@ class _HomeScreenState extends State<HomeScreen> {
@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 = '';
@@ -299,24 +343,18 @@ class _HomeScreenState extends State<HomeScreen> {
}
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(
@@ -333,17 +371,6 @@ class _HomeScreenState extends State<HomeScreen> {
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,
@@ -387,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),
);
}
}
}

View File

@@ -1,130 +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<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
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,
),
),
],
@@ -133,4 +131,4 @@ class _StartScreenState extends State<StartScreen> {
),
);
}
}
}

View File

@@ -18,14 +18,14 @@ class VocabScreen extends StatefulWidget {
State<VocabScreen> createState() => _VocabScreenState();
}
class _VocabScreenState extends State<VocabScreen> {
class _VocabScreenState extends State<VocabScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
List<VocabularyItem> _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final _audioPlayer = AudioPlayer();
VocabQuizMode _mode = VocabQuizMode.vocabToEnglish;
VocabularyItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
@@ -33,14 +33,26 @@ class _VocabScreenState extends State<VocabScreen> {
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<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
@@ -61,11 +73,10 @@ class _VocabScreenState extends State<VocabScreen> {
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<VocabScreen> {
_deck = items;
_status = 'Loaded ${items.length} vocabulary';
_loading = false;
_apiKeyMissing = false;
});
_nextQuestion();
@@ -101,6 +113,19 @@ class _VocabScreenState extends State<VocabScreen> {
.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<VocabScreen> {
@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<VocabScreen> {
}
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<VocabScreen> {
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<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),
);
}
}
}

View 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));
}
}

View File

@@ -1,13 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart';
import '../api/wk_client.dart';
class DeckRepository with ChangeNotifier {
import 'package:flutter_dotenv/flutter_dotenv.dart';
class DeckRepository {
Database? _db;
String? _apiKey;
@@ -28,39 +29,50 @@ class DeckRepository with ChangeNotifier {
version: 7,
onCreate: (db, version) async {
await db.execute(
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''');
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''',
);
await db.execute(
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''');
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''');
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
);
await db.execute(
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''');
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''');
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
);
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute(
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''');
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''',
);
}
if (oldVersion < 3) {
// Migration from version 2 to 3 was flawed, so we just drop the columns if they exist
}
if (oldVersion < 4) {
await db.execute(
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''');
'''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))''',
);
// We are not migrating the old srs data, as it was not mode-specific.
// Old columns will be dropped.
}
if (oldVersion < 5) {
await db.execute(
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''');
'''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT)''',
);
await db.execute(
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''');
'''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))''',
);
}
if (oldVersion < 6) {
try {
await db.execute('ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT');
await db.execute(
'ALTER TABLE vocabulary ADD COLUMN pronunciation_audios TEXT',
);
} catch (_) {
// Ignore error, column might already exist
}
@@ -81,17 +93,25 @@ class DeckRepository with ChangeNotifier {
Future<void> saveApiKey(String apiKey) async {
final db = await _openDb();
await db.insert(
'settings',
{'key': 'apiKey', 'value': apiKey},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert('settings', {
'key': 'apiKey',
'value': apiKey,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
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 rows =
await db.query('settings', where: 'key = ?', whereArgs: ['apiKey']);
final rows = await db.query(
'settings',
where: 'key = ?',
whereArgs: ['apiKey'],
);
if (rows.isNotEmpty) {
_apiKey = rows.first['value'] as String;
return _apiKey;
@@ -103,18 +123,14 @@ class DeckRepository with ChangeNotifier {
final db = await _openDb();
final batch = db.batch();
for (final it in items) {
batch.insert(
'kanji',
{
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'onyomi': it.onyomi.join('|'),
'kunyomi': it.kunyomi.join('|'),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
batch.insert('kanji', {
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'onyomi': it.onyomi.join('|'),
'kunyomi': it.kunyomi.join('|'),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
@@ -123,23 +139,25 @@ class DeckRepository with ChangeNotifier {
final db = await _openDb();
final rows = await db.query('kanji');
final kanjiItems = rows
.map((r) => KanjiItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
onyomi: (r['onyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
kunyomi: (r['kunyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
))
.map(
(r) => KanjiItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
onyomi: (r['onyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
kunyomi: (r['kunyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
),
)
.toList();
for (final item in kanjiItems) {
@@ -155,11 +173,17 @@ class DeckRepository with ChangeNotifier {
Future<List<SrsItem>> getSrsItems(int kanjiId) async {
final db = await _openDb();
final rows = await db.query('srs_items', where: 'kanjiId = ?', whereArgs: [kanjiId]);
final rows = await db.query(
'srs_items',
where: 'kanjiId = ?',
whereArgs: [kanjiId],
);
return rows.map((r) {
return SrsItem(
kanjiId: r['kanjiId'] as int,
quizMode: QuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String),
quizMode: QuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String,
),
readingType: r['readingType'] as String?,
srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String),
@@ -182,17 +206,13 @@ class DeckRepository with ChangeNotifier {
Future<void> insertSrsItem(SrsItem item) async {
final db = await _openDb();
await db.insert(
'srs_items',
{
'kanjiId': item.kanjiId,
'quizMode': item.quizMode.toString(),
'readingType': item.readingType,
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert('srs_items', {
'kanjiId': item.kanjiId,
'quizMode': item.quizMode.toString(),
'readingType': item.readingType,
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<KanjiItem>> fetchAndCacheFromWk([String? apiKey]) async {
@@ -200,8 +220,9 @@ class DeckRepository with ChangeNotifier {
if (key == null) throw Exception('API key not set');
final client = WkClient(key);
final assignments =
await client.fetchAllAssignments(subjectTypes: ['kanji']);
final assignments = await client.fetchAllAssignments(
subjectTypes: ['kanji'],
);
final unlocked = <int>{};
for (final a in assignments) {
@@ -220,10 +241,12 @@ class DeckRepository with ChangeNotifier {
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
final items = subjects
.where((s) =>
s['object'] == 'kanji' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'kanji'))
.where(
(s) =>
s['object'] == 'kanji' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'kanji'),
)
.map((s) => KanjiItem.fromSubject(s))
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
.toList();
@@ -234,11 +257,17 @@ class DeckRepository with ChangeNotifier {
Future<List<VocabSrsItem>> getVocabSrsItems(int vocabId) async {
final db = await _openDb();
final rows = await db.query('srs_vocab_items', where: 'vocabId = ?', whereArgs: [vocabId]);
final rows = await db.query(
'srs_vocab_items',
where: 'vocabId = ?',
whereArgs: [vocabId],
);
return rows.map((r) {
return VocabSrsItem(
vocabId: r['vocabId'] as int,
quizMode: VocabQuizMode.values.firstWhere((e) => e.toString() == r['quizMode'] as String),
quizMode: VocabQuizMode.values.firstWhere(
(e) => e.toString() == r['quizMode'] as String,
),
srsStage: r['srsStage'] as int,
lastAsked: DateTime.parse(r['lastAsked'] as String),
);
@@ -260,16 +289,12 @@ class DeckRepository with ChangeNotifier {
Future<void> insertVocabSrsItem(VocabSrsItem item) async {
final db = await _openDb();
await db.insert(
'srs_vocab_items',
{
'vocabId': item.vocabId,
'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert('srs_vocab_items', {
'vocabId': item.vocabId,
'quizMode': item.quizMode.toString(),
'srsStage': item.srsStage,
'lastAsked': item.lastAsked.toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> saveVocabulary(List<VocabularyItem> items) async {
@@ -279,18 +304,14 @@ class DeckRepository with ChangeNotifier {
final audios = it.pronunciationAudios
.map((a) => {'url': a.url, 'gender': a.gender})
.toList();
batch.insert(
'vocabulary',
{
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'readings': it.readings.join('|'),
'pronunciation_audios': jsonEncode(audios),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
batch.insert('vocabulary', {
'id': it.id,
'level': it.level,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'readings': it.readings.join('|'),
'pronunciation_audios': jsonEncode(audios),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
await batch.commit(noResult: true);
}
@@ -298,39 +319,39 @@ class DeckRepository with ChangeNotifier {
Future<List<VocabularyItem>> loadVocabulary() async {
final db = await _openDb();
final rows = await db.query('vocabulary');
final vocabItems = rows
.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?;
final List<PronunciationAudio> audios = [];
if (audiosRaw != null && audiosRaw.isNotEmpty) {
try {
final decoded = jsonDecode(audiosRaw) as List;
for (final audioData in decoded) {
audios.add(PronunciationAudio(
url: audioData['url'] as String,
gender: audioData['gender'] as String,
));
}
} catch (e) {
// Error decoding, so we'll just have no audio for this item
}
final vocabItems = rows.map((r) {
final audiosRaw = r['pronunciation_audios'] as String?;
final List<PronunciationAudio> audios = [];
if (audiosRaw != null && audiosRaw.isNotEmpty) {
try {
final decoded = jsonDecode(audiosRaw) as List;
for (final audioData in decoded) {
audios.add(
PronunciationAudio(
url: audioData['url'] as String,
gender: audioData['gender'] as String,
),
);
}
return VocabularyItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
readings: (r['readings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
pronunciationAudios: audios,
);
})
.toList();
} catch (e) {
// Error decoding, so we'll just have no audio for this item
}
}
return VocabularyItem(
id: r['id'] as int,
level: r['level'] as int? ?? 0,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
readings: (r['readings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
pronunciationAudios: audios,
);
}).toList();
for (final item in vocabItems) {
final srsItems = await getVocabSrsItems(item.id);
@@ -343,13 +364,16 @@ class DeckRepository with ChangeNotifier {
return vocabItems;
}
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([String? apiKey]) async {
Future<List<VocabularyItem>> fetchAndCacheVocabularyFromWk([
String? apiKey,
]) async {
final key = apiKey ?? _apiKey;
if (key == null) throw Exception('API key not set');
final client = WkClient(key);
final assignments =
await client.fetchAllAssignments(subjectTypes: ['vocabulary']);
final assignments = await client.fetchAllAssignments(
subjectTypes: ['vocabulary'],
);
final unlocked = <int>{};
for (final a in assignments) {
@@ -368,10 +392,12 @@ class DeckRepository with ChangeNotifier {
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
final items = subjects
.where((s) =>
s['object'] == 'vocabulary' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'vocabulary'))
.where(
(s) =>
s['object'] == 'vocabulary' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'vocabulary'),
)
.map((s) => VocabularyItem.fromSubject(s))
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
.toList();
@@ -379,4 +405,4 @@ class DeckRepository with ChangeNotifier {
await saveVocabulary(items);
return items;
}
}
}

View File

@@ -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:

View File

@@ -2,27 +2,30 @@ name: hirameki_srs
description: A simple and effective Spaced Repetition System (SRS) app for learning Japanese kanji and vocabulary.
version: 0.1.0+1
environment:
sdk: '>=3.3.0 <4.0.0'
sdk: ">=3.9.0 <4.0.0"
dependencies:
audioplayers: any
flutter:
sdk: flutter
shared_preferences: ^2.5.3
sqflite: ^2.4.2
path_provider: ^2.1.5
path: ^1.9.1
provider: ^6.1.5+1
provider: ^6.1.5
http: ^1.5.0
audioplayers: ^6.0.0
kana_kit: ^2.1.1
flutter_tts: ^3.8.5
flutter_dotenv: ^5.1.0
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.5.0
test: ^1.26.2
build_runner: ^2.4.10
flutter_launcher_icons: ^0.14.4
flutter_lints: ^6.0.0
mockito: any
test: any
build_runner: any
flutter_launcher_icons: any
flutter_lints: any
flutter_icons:
android: true
@@ -32,3 +35,4 @@ flutter:
uses-material-design: true
assets:
- assets/sfx/confirm.mp3
- .env