v1
This commit is contained in:
54
lib/src/api/wk_client.dart
Normal file
54
lib/src/api/wk_client.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class WkClient {
|
||||
final String apiKey;
|
||||
final Map<String, String> headers;
|
||||
final String base = 'https://api.wanikani.com/v2';
|
||||
|
||||
WkClient(this.apiKey) : headers = {'Authorization': 'Bearer $apiKey', 'Wanikani-Revision': '20170710', 'Accept': 'application/json'};
|
||||
|
||||
Future<List<Map<String, dynamic>>> fetchAllAssignments({List<String>? subjectTypes}) async {
|
||||
final out = <Map<String, dynamic>>[];
|
||||
String url = '$base/assignments?page=1';
|
||||
if (subjectTypes != null && subjectTypes.isNotEmpty) {
|
||||
final types = subjectTypes.join(',');
|
||||
url = '$base/assignments?subject_types=$types&page=1';
|
||||
}
|
||||
while (url.isNotEmpty) {
|
||||
final resp = await http.get(Uri.parse(url), headers: headers);
|
||||
if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}');
|
||||
final j = json.decode(resp.body) as Map<String, dynamic>;
|
||||
out.addAll((j['data'] as List).cast<Map<String, dynamic>>());
|
||||
final pages = j['pages'] as Map<String, dynamic>?;
|
||||
if (pages != null && pages['next_url'] != null) {
|
||||
url = pages['next_url'] as String;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> fetchSubjectsByIds(List<int> ids) async {
|
||||
final out = <Map<String, dynamic>>[];
|
||||
const batch = 100;
|
||||
for (var i = 0; i < ids.length; i += batch) {
|
||||
final chunk = ids.sublist(i, i + batch > ids.length ? ids.length : i + batch);
|
||||
String url = '$base/subjects?ids=${chunk.join(',')}&page=1';
|
||||
while (true) {
|
||||
final resp = await http.get(Uri.parse(url), headers: headers);
|
||||
if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}');
|
||||
final j = json.decode(resp.body) as Map<String, dynamic>;
|
||||
out.addAll((j['data'] as List).cast<Map<String, dynamic>>());
|
||||
final pages = j['pages'] as Map<String, dynamic>?;
|
||||
if (pages != null && pages['next_url'] != null) {
|
||||
url = pages['next_url'] as String;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
22
lib/src/app.dart
Normal file
22
lib/src/app.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/src/models/kanji_item.dart
Normal file
62
lib/src/models/kanji_item.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
class KanjiItem {
|
||||
final int id;
|
||||
final String characters;
|
||||
final List<String> meanings;
|
||||
final List<String> onyomi;
|
||||
final List<String> kunyomi;
|
||||
|
||||
KanjiItem({
|
||||
required this.id,
|
||||
required this.characters,
|
||||
required this.meanings,
|
||||
required this.onyomi,
|
||||
required this.kunyomi,
|
||||
});
|
||||
|
||||
factory KanjiItem.fromSubject(Map<String, dynamic> subj) {
|
||||
final int id = subj['id'] as int;
|
||||
final data = subj['data'] as Map<String, dynamic>;
|
||||
final String characters = (data['characters'] ?? '') as String;
|
||||
final List<String> meanings = <String>[];
|
||||
final List<String> onyomi = <String>[];
|
||||
final List<String> kunyomi = <String>[];
|
||||
|
||||
if (data['meanings'] != null) {
|
||||
for (final m in data['meanings'] as List) {
|
||||
meanings.add((m['meaning'] as String).toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
if (data['readings'] != null) {
|
||||
for (final r in data['readings'] as List) {
|
||||
final typ = r['type'] as String? ?? '';
|
||||
final reading = r['reading'] as String? ?? '';
|
||||
if (typ == 'onyomi') {
|
||||
onyomi.add(_katakanaToHiragana(reading));
|
||||
} else if (typ == 'kunyomi') {
|
||||
kunyomi.add(reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return KanjiItem(
|
||||
id: id,
|
||||
characters: characters,
|
||||
meanings: meanings,
|
||||
onyomi: onyomi,
|
||||
kunyomi: kunyomi,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _katakanaToHiragana(String input) {
|
||||
final buf = StringBuffer();
|
||||
for (final r in input.runes) {
|
||||
if (r >= 0x30A1 && r <= 0x30FA) {
|
||||
buf.writeCharCode(r - 0x60);
|
||||
} else {
|
||||
buf.writeCharCode(r);
|
||||
}
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
310
lib/src/screens/home_screen.dart
Normal file
310
lib/src/screens/home_screen.dart
Normal file
@@ -0,0 +1,310 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/kanji_item.dart';
|
||||
import '../services/deck_repository.dart';
|
||||
import '../services/distractor_generator.dart';
|
||||
import '../widgets/kanji_card.dart';
|
||||
import '../widgets/options_grid.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum QuizMode { kanjiToEnglish, englishToKanji, reading }
|
||||
|
||||
class _ReadingInfo {
|
||||
final List<String> correctReadings;
|
||||
final String hint;
|
||||
|
||||
_ReadingInfo(this.correctReadings, this.hint);
|
||||
}
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
List<KanjiItem> _deck = [];
|
||||
bool _loading = false;
|
||||
String _status = 'Loading deck...';
|
||||
final DistractorGenerator _dg = DistractorGenerator();
|
||||
final Random _random = Random();
|
||||
|
||||
QuizMode _mode = QuizMode.kanjiToEnglish;
|
||||
KanjiItem? _current;
|
||||
List<String> _options = [];
|
||||
List<String> _correctAnswers = [];
|
||||
String _readingHint = '';
|
||||
int _score = 0;
|
||||
int _asked = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDeck();
|
||||
}
|
||||
|
||||
Future<void> _loadDeck() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_status = 'Loading deck...';
|
||||
});
|
||||
|
||||
try {
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
await repo.loadApiKey();
|
||||
final apiKey = repo.apiKey;
|
||||
|
||||
if (apiKey == null || apiKey.isEmpty) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_status = 'Fetching deck...';
|
||||
});
|
||||
|
||||
final items = await repo.fetchAndCacheFromWk(apiKey);
|
||||
|
||||
setState(() {
|
||||
_deck = items;
|
||||
_status = 'Loaded ${items.length} kanji';
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
_nextQuestion();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_status = 'Error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _toTitleCase(String s) {
|
||||
if (s.isEmpty) return s;
|
||||
return s
|
||||
.split(' ')
|
||||
.map((w) => w.isEmpty ? w : w[0].toUpperCase() + w.substring(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
_ReadingInfo _pickReading(KanjiItem item) {
|
||||
final choices = <String>[];
|
||||
if (item.onyomi.isNotEmpty) choices.add('onyomi');
|
||||
if (item.kunyomi.isNotEmpty) choices.add('kunyomi');
|
||||
if (choices.isEmpty) return _ReadingInfo([], '');
|
||||
|
||||
final pickedType = choices[_random.nextInt(choices.length)];
|
||||
final readingsList = pickedType == 'onyomi' ? item.onyomi : item.kunyomi;
|
||||
final hint = 'Select the ${pickedType == 'onyomi' ? "on\'yomi" : "kunyomi"}';
|
||||
|
||||
return _ReadingInfo(readingsList, hint);
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
if (_deck.isEmpty) return;
|
||||
_current = (_deck..shuffle()).first;
|
||||
|
||||
_correctAnswers = [];
|
||||
_options = [];
|
||||
_readingHint = '';
|
||||
|
||||
switch (_mode) {
|
||||
case QuizMode.kanjiToEnglish:
|
||||
_correctAnswers = [_current!.meanings.first];
|
||||
_options = [_correctAnswers.first, ..._dg.generateMeanings(_current!, _deck, 3)]
|
||||
.map(_toTitleCase)
|
||||
.toList()
|
||||
..shuffle();
|
||||
break;
|
||||
|
||||
case QuizMode.englishToKanji:
|
||||
_correctAnswers = [_current!.characters];
|
||||
_options = [_correctAnswers.first, ..._dg.generateKanji(_current!, _deck, 3)]
|
||||
..shuffle();
|
||||
break;
|
||||
|
||||
case QuizMode.reading:
|
||||
final info = _pickReading(_current!);
|
||||
_correctAnswers = info.correctReadings;
|
||||
_readingHint = info.hint;
|
||||
|
||||
final readingsSource = _readingHint.contains("on'yomi")
|
||||
? _deck.expand((k) => k.onyomi)
|
||||
: _deck.expand((k) => k.kunyomi);
|
||||
|
||||
final distractors =
|
||||
readingsSource.where((r) => !_correctAnswers.contains(r)).toSet().toList()
|
||||
..shuffle();
|
||||
_options = ([_correctAnswers[_random.nextInt(_correctAnswers.length)], ...distractors.take(3)])
|
||||
..shuffle();
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _answer(String option) {
|
||||
final isCorrect = _correctAnswers
|
||||
.map((a) => a.toLowerCase().trim())
|
||||
.contains(option.toLowerCase().trim());
|
||||
setState(() {
|
||||
_asked += 1;
|
||||
if (isCorrect) _score += 1;
|
||||
});
|
||||
|
||||
final correctDisplay = (_mode == QuizMode.kanjiToEnglish)
|
||||
? _toTitleCase(_correctAnswers.first)
|
||||
: (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first);
|
||||
|
||||
final snack = SnackBar(
|
||||
content: Text(
|
||||
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
|
||||
style: TextStyle(
|
||||
color: isCorrect ? Colors.greenAccent : Colors.redAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFF222222),
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(snack);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String prompt = '';
|
||||
String subtitle = '';
|
||||
|
||||
switch (_mode) {
|
||||
case QuizMode.kanjiToEnglish:
|
||||
prompt = _current?.characters ?? '';
|
||||
break;
|
||||
case QuizMode.englishToKanji:
|
||||
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : '';
|
||||
break;
|
||||
case QuizMode.reading:
|
||||
prompt = _current?.characters ?? '';
|
||||
subtitle = _readingHint;
|
||||
break;
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
appBar: AppBar(
|
||||
title: const Text('WaniKani Kanji SRS'),
|
||||
backgroundColor: const Color(0xFF1F1F1F),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 2,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_status,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (_loading)
|
||||
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,
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 0,
|
||||
maxWidth: 500,
|
||||
minHeight: 150,
|
||||
),
|
||||
child: KanjiCard(
|
||||
characters: prompt,
|
||||
subtitle: subtitle,
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
OptionsGrid(
|
||||
options: _options,
|
||||
onSelected: _answer,
|
||||
buttonColor: const Color(0xFF1E1E1E),
|
||||
textColor: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Score: $_score / $_asked',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/src/screens/settings_screen.dart
Normal file
93
lib/src/screens/settings_screen.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/deck_repository.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final TextEditingController _apiKeyController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_apiKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveApiKey() async {
|
||||
final apiKey = _apiKeyController.text.trim();
|
||||
if (apiKey.isEmpty) return;
|
||||
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
await repo.setApiKey(apiKey);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('API key saved!')),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final repo = Provider.of<DeckRepository>(context, listen: false);
|
||||
repo.loadApiKey().then((key) {
|
||||
if (key != null) {
|
||||
_apiKeyController.text = key;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF121212),
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
backgroundColor: const Color(0xFF1F1F1F),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'WaniKani API Key',
|
||||
labelStyle: const TextStyle(color: Colors.grey),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF1E1E1E),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _saveApiKey,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Save & Start Quiz'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/src/screens/start_screen.dart
Normal file
102
lib/src/screens/start_screen.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/deck_repository.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class StartScreen extends StatefulWidget {
|
||||
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(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to WaniKani Kanji SRS!',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineMedium
|
||||
?.copyWith(fontSize: 28, color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_hasApiKey) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||
);
|
||||
} else {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(
|
||||
_hasApiKey ? 'Start Quiz' : 'Go to Settings',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/src/services/deck_repository.dart
Normal file
140
lib/src/services/deck_repository.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
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 {
|
||||
Database? _db;
|
||||
String? _apiKey;
|
||||
|
||||
Future<void> setApiKey(String apiKey) async {
|
||||
_apiKey = apiKey;
|
||||
await saveApiKey(apiKey);
|
||||
}
|
||||
|
||||
String? get apiKey => _apiKey;
|
||||
|
||||
Future<Database> _openDb() async {
|
||||
if (_db != null) return _db!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final path = join(dir.path, 'wanikani_srs.db');
|
||||
|
||||
_db = await openDatabase(
|
||||
path,
|
||||
version: 2,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute(
|
||||
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''');
|
||||
await db.execute(
|
||||
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
await db.execute(
|
||||
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''');
|
||||
},
|
||||
);
|
||||
|
||||
return _db!;
|
||||
}
|
||||
|
||||
Future<void> saveApiKey(String apiKey) async {
|
||||
final db = await _openDb();
|
||||
await db.insert(
|
||||
'settings',
|
||||
{'key': 'apiKey', 'value': apiKey},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> loadApiKey() async {
|
||||
final db = await _openDb();
|
||||
final rows =
|
||||
await db.query('settings', where: 'key = ?', whereArgs: ['apiKey']);
|
||||
if (rows.isNotEmpty) {
|
||||
_apiKey = rows.first['value'] as String;
|
||||
return _apiKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> saveKanji(List<KanjiItem> items) async {
|
||||
final db = await _openDb();
|
||||
final batch = db.batch();
|
||||
for (final it in items) {
|
||||
batch.insert(
|
||||
'kanji',
|
||||
{
|
||||
'id': it.id,
|
||||
'characters': it.characters,
|
||||
'meanings': it.meanings.join('|'),
|
||||
'onyomi': it.onyomi.join('|'),
|
||||
'kunyomi': it.kunyomi.join('|'),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<KanjiItem>> loadKanji() async {
|
||||
final db = await _openDb();
|
||||
final rows = await db.query('kanji');
|
||||
return rows
|
||||
.map((r) => KanjiItem(
|
||||
id: r['id'] as int,
|
||||
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();
|
||||
}
|
||||
|
||||
Future<List<KanjiItem>> fetchAndCacheFromWk([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: ['kanji']);
|
||||
|
||||
final unlocked = <int>{};
|
||||
for (final a in assignments) {
|
||||
final data = a['data'] as Map<String, dynamic>;
|
||||
final sidRaw = data['subject_id'];
|
||||
if (sidRaw == null) continue;
|
||||
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
|
||||
if (sid == null) continue;
|
||||
final started = data['started_at'];
|
||||
final srs = data['srs_stage'];
|
||||
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
|
||||
if (isUnlocked) unlocked.add(sid);
|
||||
}
|
||||
|
||||
if (unlocked.isEmpty) return [];
|
||||
|
||||
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'))
|
||||
.map((s) => KanjiItem.fromSubject(s))
|
||||
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
await saveKanji(items);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
69
lib/src/services/distractor_generator.dart
Normal file
69
lib/src/services/distractor_generator.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import '../models/kanji_item.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class DistractorGenerator {
|
||||
final Random _rnd = Random();
|
||||
|
||||
List<String> generateMeanings(KanjiItem correct, List<KanjiItem> pool, int needed) {
|
||||
final correctMeaning = correct.meanings.first;
|
||||
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
|
||||
final candidates = <String>[];
|
||||
for (final k in pool) {
|
||||
if (k.id == correct.id) continue;
|
||||
for (final m in k.meanings) {
|
||||
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
|
||||
if (mTokens.intersection(tokens).isNotEmpty) {
|
||||
candidates.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length < needed) {
|
||||
for (final k in pool) {
|
||||
if (k.id == correct.id) continue;
|
||||
for (final m in k.meanings) {
|
||||
if (!candidates.contains(m)) candidates.add(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.shuffle(_rnd);
|
||||
final out = <String>[];
|
||||
for (final c in candidates) {
|
||||
if (out.length >= needed) break;
|
||||
if (c.toLowerCase() == correctMeaning.toLowerCase()) continue;
|
||||
out.add(_toTitleCase(c));
|
||||
}
|
||||
while (out.length < needed) out.add('(no more)');
|
||||
return out;
|
||||
}
|
||||
|
||||
List<String> generateKanji(KanjiItem correct, List<KanjiItem> pool, int needed) {
|
||||
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList();
|
||||
others.shuffle(_rnd);
|
||||
final out = <String>[];
|
||||
for (final o in others) {
|
||||
if (out.length >= needed) break;
|
||||
out.add(o);
|
||||
}
|
||||
while (out.length < needed) out.add('—');
|
||||
return out;
|
||||
}
|
||||
|
||||
List<String> generateReadings(String correct, List<KanjiItem> pool, int needed) {
|
||||
final poolReadings = <String>[];
|
||||
for (final k in pool) {
|
||||
poolReadings.addAll(k.onyomi);
|
||||
poolReadings.addAll(k.kunyomi);
|
||||
}
|
||||
poolReadings.removeWhere((r) => r == correct || r.isEmpty);
|
||||
poolReadings.shuffle(_rnd);
|
||||
final out = <String>[];
|
||||
for (final r in poolReadings) {
|
||||
if (out.length >= needed) break;
|
||||
out.add(r);
|
||||
}
|
||||
while (out.length < needed) out.add('—');
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
String _toTitleCase(String s) => s.split(' ').map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1))).join(' ');
|
||||
57
lib/src/widgets/kanji_card.dart
Normal file
57
lib/src/widgets/kanji_card.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class KanjiCard extends StatelessWidget {
|
||||
final String characters;
|
||||
final String subtitle;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
const KanjiCard({
|
||||
super.key,
|
||||
required this.characters,
|
||||
this.subtitle = '',
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bgColor = backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface;
|
||||
final fgColor = textColor ?? theme.textTheme.bodyMedium?.color ?? theme.colorScheme.onSurface;
|
||||
|
||||
return Card(
|
||||
elevation: theme.cardTheme.elevation ?? 12,
|
||||
color: bgColor,
|
||||
shape: theme.cardTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 240,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
characters,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 56,
|
||||
color: fgColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: fgColor.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
lib/src/widgets/options_grid.dart
Normal file
52
lib/src/widgets/options_grid.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class OptionsGrid extends StatelessWidget {
|
||||
final List<String> options;
|
||||
final void Function(String) onSelected;
|
||||
final Color? buttonColor;
|
||||
final Color? textColor;
|
||||
|
||||
const OptionsGrid({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.onSelected,
|
||||
this.buttonColor,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (options.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final bg = buttonColor ?? theme.colorScheme.primary;
|
||||
final fg = textColor ?? theme.colorScheme.onPrimary;
|
||||
|
||||
return Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
alignment: WrapAlignment.center,
|
||||
children: options.map((o) {
|
||||
return SizedBox(
|
||||
width: 160,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => onSelected(o),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: bg,
|
||||
foregroundColor: fg,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
),
|
||||
child: Text(
|
||||
o,
|
||||
style: TextStyle(fontSize: 20, color: fg),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user