Files
Hirameki-SRS/lib/src/screens/home_screen.dart
2025-11-01 06:38:14 +01:00

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