512 lines
14 KiB
Dart
512 lines
14 KiB
Dart
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../models/kanji_item.dart';
|
|
import '../models/srs_item.dart';
|
|
import '../services/deck_repository.dart';
|
|
import '../services/distractor_generator.dart';
|
|
import '../widgets/kanji_card.dart';
|
|
import '../widgets/options_grid.dart';
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
|
|
import 'settings_screen.dart';
|
|
|
|
class _ReadingInfo {
|
|
final List<String> correctReadings;
|
|
final String hint;
|
|
|
|
_ReadingInfo(this.correctReadings, this.hint);
|
|
}
|
|
|
|
class _QuizState {
|
|
KanjiItem? current;
|
|
List<String> options = [];
|
|
List<String> correctAnswers = [];
|
|
String readingHint = '';
|
|
int score = 0;
|
|
int asked = 0;
|
|
Key key = UniqueKey();
|
|
String? selectedOption;
|
|
bool showResult = false;
|
|
}
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key, this.distractorGenerator});
|
|
|
|
final DistractorGenerator? distractorGenerator;
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
List<KanjiItem> _deck = [];
|
|
bool _loading = false;
|
|
bool _isAnswering = false;
|
|
String _status = 'Loading deck...';
|
|
late final DistractorGenerator _dg;
|
|
final Random _random = Random();
|
|
final _audioPlayer = AudioPlayer();
|
|
|
|
final _quizStates = [_QuizState(), _QuizState(), _QuizState()];
|
|
_QuizState get _currentQuizState => _quizStates[_tabController.index];
|
|
|
|
bool _playCorrectSound = true;
|
|
bool _apiKeyMissing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this);
|
|
_tabController.addListener(() {
|
|
setState(() {});
|
|
});
|
|
_dg = widget.distractorGenerator ?? DistractorGenerator();
|
|
_loadSettings();
|
|
_loadDeck();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadSettings() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
setState(() {
|
|
_playCorrectSound = prefs.getBool('playCorrectSound') ?? true;
|
|
});
|
|
}
|
|
|
|
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) {
|
|
setState(() {
|
|
_apiKeyMissing = true;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
var items = await repo.loadKanji();
|
|
if (items.isEmpty) {
|
|
setState(() {
|
|
_status = 'Fetching deck...';
|
|
});
|
|
items = await repo.fetchAndCacheFromWk(apiKey);
|
|
}
|
|
|
|
setState(() {
|
|
_deck = items;
|
|
_status = 'Loaded ${items.length} kanji';
|
|
_loading = false;
|
|
_apiKeyMissing = false;
|
|
});
|
|
|
|
for (var i = 0; i < _tabController.length; i++) {
|
|
_nextQuestion(i);
|
|
}
|
|
} 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);
|
|
}
|
|
|
|
QuizMode _modeForIndex(int index) {
|
|
switch (index) {
|
|
case 0:
|
|
return QuizMode.kanjiToEnglish;
|
|
case 1:
|
|
return QuizMode.englishToKanji;
|
|
case 2:
|
|
return QuizMode.reading;
|
|
default:
|
|
return QuizMode.kanjiToEnglish;
|
|
}
|
|
}
|
|
|
|
void _nextQuestion([int? index]) {
|
|
if (_deck.isEmpty) return;
|
|
|
|
final quizState = _quizStates[index ?? _tabController.index];
|
|
final mode = _modeForIndex(index ?? _tabController.index);
|
|
|
|
_deck.sort((a, b) {
|
|
int getSrsStage(KanjiItem item) {
|
|
if (mode == QuizMode.reading) {
|
|
final onyomiStage =
|
|
item.srsItems['${QuizMode.reading}onyomi']?.srsStage;
|
|
final kunyomiStage =
|
|
item.srsItems['${QuizMode.reading}kunyomi']?.srsStage;
|
|
|
|
if (onyomiStage != null && kunyomiStage != null) {
|
|
return min(onyomiStage, kunyomiStage);
|
|
}
|
|
return onyomiStage ?? kunyomiStage ?? 0;
|
|
}
|
|
return item.srsItems[mode.toString()]?.srsStage ?? 0;
|
|
}
|
|
|
|
DateTime getLastAsked(KanjiItem item) {
|
|
if (mode == QuizMode.reading) {
|
|
final onyomiLastAsked =
|
|
item.srsItems['${QuizMode.reading}onyomi']?.lastAsked;
|
|
final kunyomiLastAsked =
|
|
item.srsItems['${QuizMode.reading}kunyomi']?.lastAsked;
|
|
|
|
if (onyomiLastAsked != null && kunyomiLastAsked != null) {
|
|
return onyomiLastAsked.isBefore(kunyomiLastAsked)
|
|
? onyomiLastAsked
|
|
: kunyomiLastAsked;
|
|
}
|
|
return onyomiLastAsked ??
|
|
kunyomiLastAsked ??
|
|
DateTime.fromMillisecondsSinceEpoch(0);
|
|
}
|
|
return item.srsItems[mode.toString()]?.lastAsked ??
|
|
DateTime.fromMillisecondsSinceEpoch(0);
|
|
}
|
|
|
|
final aStage = getSrsStage(a);
|
|
final bStage = getSrsStage(b);
|
|
|
|
if (aStage != bStage) {
|
|
return aStage.compareTo(bStage);
|
|
}
|
|
|
|
final aLastAsked = getLastAsked(a);
|
|
final bLastAsked = getLastAsked(b);
|
|
|
|
return aLastAsked.compareTo(bLastAsked);
|
|
});
|
|
|
|
quizState.current = _deck.first;
|
|
quizState.key = UniqueKey();
|
|
|
|
quizState.correctAnswers = [];
|
|
quizState.options = [];
|
|
quizState.readingHint = '';
|
|
quizState.selectedOption = null;
|
|
quizState.showResult = false;
|
|
|
|
switch (mode) {
|
|
case QuizMode.kanjiToEnglish:
|
|
quizState.correctAnswers = [quizState.current!.meanings.first];
|
|
quizState.options = [
|
|
quizState.correctAnswers.first,
|
|
..._dg.generateMeanings(quizState.current!, _deck, 3),
|
|
].map(_toTitleCase).toList()..shuffle();
|
|
break;
|
|
|
|
case QuizMode.englishToKanji:
|
|
quizState.correctAnswers = [quizState.current!.characters];
|
|
quizState.options = [
|
|
quizState.correctAnswers.first,
|
|
..._dg.generateKanji(quizState.current!, _deck, 3),
|
|
]..shuffle();
|
|
break;
|
|
|
|
case QuizMode.reading:
|
|
final info = _pickReading(quizState.current!);
|
|
quizState.correctAnswers = info.correctReadings;
|
|
quizState.readingHint = info.hint;
|
|
|
|
final readingsSource = quizState.readingHint.contains("on'yomi")
|
|
? _deck.expand((k) => k.onyomi)
|
|
: _deck.expand((k) => k.kunyomi);
|
|
|
|
final distractors =
|
|
readingsSource
|
|
.where((r) => !quizState.correctAnswers.contains(r))
|
|
.toSet()
|
|
.toList()
|
|
..shuffle();
|
|
quizState.options = ([
|
|
quizState.correctAnswers[_random.nextInt(
|
|
quizState.correctAnswers.length,
|
|
)],
|
|
...distractors.take(3),
|
|
])..shuffle();
|
|
break;
|
|
default:
|
|
// Handle other QuizMode cases if necessary, or throw an error
|
|
// if these modes are not expected in this context.
|
|
break;
|
|
}
|
|
|
|
setState(() {
|
|
_isAnswering = false;
|
|
});
|
|
}
|
|
|
|
void _answer(String option) async {
|
|
final quizState = _currentQuizState;
|
|
final mode = _modeForIndex(_tabController.index);
|
|
final isCorrect = quizState.correctAnswers
|
|
.map((a) => a.toLowerCase().trim())
|
|
.contains(option.toLowerCase().trim());
|
|
|
|
final repo = Provider.of<DeckRepository>(context, listen: false);
|
|
final current = quizState.current!;
|
|
|
|
String readingType = '';
|
|
if (mode == QuizMode.reading) {
|
|
readingType = quizState.readingHint.contains("on'yomi")
|
|
? 'onyomi'
|
|
: 'kunyomi';
|
|
}
|
|
final srsKey = mode.toString() + readingType;
|
|
|
|
var srsItem = current.srsItems[srsKey];
|
|
final isNew = srsItem == null;
|
|
final srsItemForUpdate = srsItem ??= SrsItem(
|
|
subjectId: current.id,
|
|
quizMode: mode,
|
|
readingType: readingType,
|
|
);
|
|
|
|
quizState.asked += 1;
|
|
|
|
quizState.selectedOption = option;
|
|
|
|
quizState.showResult = true;
|
|
|
|
setState(() {});
|
|
|
|
if (isCorrect) {
|
|
quizState.score += 1;
|
|
srsItemForUpdate.srsStage += 1;
|
|
if (_playCorrectSound) {
|
|
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
|
|
}
|
|
} else {
|
|
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
|
|
}
|
|
srsItemForUpdate.lastAsked = DateTime.now();
|
|
current.srsItems[srsKey] = srsItemForUpdate;
|
|
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
if (isNew) {
|
|
await repo.insertSrsItem(srsItemForUpdate);
|
|
} else {
|
|
await repo.updateSrsItem(srsItemForUpdate);
|
|
}
|
|
|
|
final correctDisplay = (mode == QuizMode.kanjiToEnglish)
|
|
? _toTitleCase(quizState.correctAnswers.first)
|
|
: (mode == QuizMode.reading
|
|
? quizState.correctAnswers.join(', ')
|
|
: quizState.correctAnswers.first);
|
|
|
|
final snack = SnackBar(
|
|
content: Text(
|
|
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
|
|
style: TextStyle(
|
|
color: isCorrect
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.error,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
backgroundColor: theme.colorScheme.surfaceContainerHighest,
|
|
duration: const Duration(milliseconds: 900),
|
|
);
|
|
if (mounted) {
|
|
scaffoldMessenger.showSnackBar(snack);
|
|
}
|
|
|
|
setState(() {
|
|
_isAnswering = true;
|
|
});
|
|
|
|
Future.delayed(const Duration(milliseconds: 900), () {
|
|
if (mounted) {
|
|
_nextQuestion();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_apiKeyMissing) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Kanji Quiz')),
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'WaniKani API key is not set.',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(builder: (_) => const SettingsScreen()),
|
|
);
|
|
_loadDeck();
|
|
},
|
|
child: const Text('Go to Settings'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Kanji Quiz'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: 'Kanji→English'),
|
|
Tab(text: 'English→Kanji'),
|
|
Tab(text: 'Reading'),
|
|
],
|
|
),
|
|
),
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [_buildQuizPage(0), _buildQuizPage(1), _buildQuizPage(2)],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildQuizPage(int index) {
|
|
final quizState = _quizStates[index];
|
|
final mode = _modeForIndex(index);
|
|
|
|
String prompt = '';
|
|
String subtitle = '';
|
|
|
|
if (quizState.current != null) {
|
|
switch (mode) {
|
|
case QuizMode.kanjiToEnglish:
|
|
prompt = quizState.current!.characters;
|
|
break;
|
|
case QuizMode.englishToKanji:
|
|
prompt = _toTitleCase(quizState.current!.meanings.first);
|
|
break;
|
|
case QuizMode.reading:
|
|
prompt = quizState.current!.characters;
|
|
subtitle = quizState.readingHint;
|
|
break;
|
|
default:
|
|
// Handle other QuizMode cases if necessary, or throw an error
|
|
// if these modes are not expected in this context.
|
|
break;
|
|
}
|
|
}
|
|
|
|
return Padding(
|
|
key: quizState.key,
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_status,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
if (_loading)
|
|
CircularProgressIndicator(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
],
|
|
),
|
|
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: Theme.of(context).colorScheme.surface,
|
|
textColor: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
children: [
|
|
OptionsGrid(
|
|
options: quizState.options,
|
|
onSelected: _isAnswering ? (option) {} : _answer,
|
|
isDisabled: false,
|
|
selectedOption: null,
|
|
correctAnswers: [],
|
|
showResult: false,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Score: ${quizState.score} / ${quizState.asked}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|