Files
Hirameki-SRS/lib/src/screens/home_screen.dart
Rene Kievits 6dabb9c977 added sound
2025-10-28 05:44:14 +01:00

364 lines
10 KiB
Dart

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 'package:audioplayers/audioplayers.dart';
import 'settings_screen.dart';
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();
final _audioPlayer = AudioPlayer();
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;
}
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;
});
_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() {
_deck.sort((a, b) {
final aSrsItem = a.srsItems[_mode.toString()];
final bSrsItem = b.srsItems[_mode.toString()];
final aStage = aSrsItem?.srsStage ?? 0;
final bStage = bSrsItem?.srsStage ?? 0;
if (aStage != bStage) {
return aStage.compareTo(bStage);
}
final aLastAsked = aSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
final bLastAsked = bSrsItem?.lastAsked ?? DateTime.fromMillisecondsSinceEpoch(0);
return aLastAsked.compareTo(bLastAsked);
});
_current = _deck.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) async {
final isCorrect = _correctAnswers
.map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim());
final repo = Provider.of<DeckRepository>(context, listen: false);
final current = _current!;
String readingType = '';
if (_mode == QuizMode.reading) {
readingType = _readingHint.contains("on'yomi") ? 'onyomi' : 'kunyomi';
}
final srsKey = _mode.toString() + readingType;
var srsItem = current.srsItems[srsKey];
final isNew = srsItem == null;
final srsItemForUpdate = srsItem ??=
SrsItem(kanjiId: current.id, quizMode: _mode, readingType: readingType);
setState(() {
_asked += 1;
if (isCorrect) {
_score += 1;
_audioPlayer.play(AssetSource('sfx/confirm.mp3'));
} else {
srsItemForUpdate.srsStage = max(0, srsItemForUpdate.srsStage - 1);
}
srsItemForUpdate.lastAsked = DateTime.now();
current.srsItems[srsKey] = srsItemForUpdate;
});
if (isNew) {
await repo.insertSrsItem(srsItemForUpdate);
} else {
await repo.updateSrsItem(srsItemForUpdate);
}
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),
);
if (mounted) {
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),
);
}
}