This commit is contained in:
Rene Kievits
2025-10-27 18:52:16 +01:00
commit ba82e662f6
140 changed files with 6443 additions and 0 deletions

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

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

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