change a bunch of stuff, seperate tracking for progress, updated custom srs layout

This commit is contained in:
Rene Kievits
2025-10-31 07:16:44 +01:00
parent cafec12888
commit d8edfa1686
12 changed files with 1378 additions and 661 deletions

View File

@@ -1,11 +1,15 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import '../models/kanji_item.dart';
import '../services/deck_repository.dart';
import 'package:hirameki_srs/src/services/vocab_deck_repository.dart';
import '../services/custom_deck_repository.dart';
import '../models/custom_kanji_item.dart';
import 'settings_screen.dart';
import 'custom_card_details_screen.dart';
import 'add_card_screen.dart';
class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key});
@@ -222,8 +226,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
final item = items[index];
return GestureDetector(
onTap: () => _showReadingsDialog(item),
child:
_buildSrsItemCard(item.characters, item.srsItems.values.toList()),
child: _buildSrsItemCard(item),
);
},
padding: const EdgeInsets.all(8),
@@ -241,53 +244,87 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}
Widget _buildVocabListTile(VocabularyItem item) {
final avgSrsStage = item.srsItems.isNotEmpty
? item.srsItems.values
.map((s) => s.srsStage)
.reduce((a, b) => a + b) /
item.srsItems.length
: 0.0;
final requiredModes = <String>[
VocabQuizMode.vocabToEnglish.toString(),
VocabQuizMode.englishToVocab.toString(),
VocabQuizMode.audioToEnglish.toString(),
];
return Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Expanded(
child: Text(
item.characters,
style: const TextStyle(fontSize: 24, color: Colors.white),
int minSrsStage = 9;
for (final mode in requiredModes) {
final srsItem = item.srsItems[mode];
if (srsItem == null) {
minSrsStage = 0;
break;
}
if (srsItem.srsStage < minSrsStage) {
minSrsStage = srsItem.srsStage;
}
}
return GestureDetector(
onTap: () => _showVocabDetailsDialog(context, item),
child: Card(
color: const Color(0xFF1E1E1E),
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Expanded(
child: Text(
item.characters,
style: const TextStyle(fontSize: 24, color: Colors.white),
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.meanings.join(', '),
style: const TextStyle(color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildSrsIndicator(avgSrsStage.round()),
],
const SizedBox(width: 16),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.meanings.join(', '),
style: const TextStyle(color: Colors.grey),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildSrsIndicator(minSrsStage),
],
),
),
),
],
],
),
),
),
);
}
Widget _buildSrsItemCard(String characters, List<dynamic> srsItems) {
final avgSrsStage = srsItems.isNotEmpty
? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) /
srsItems.length
: 0.0;
Widget _buildSrsItemCard(KanjiItem item) {
final requiredModes = <String>[
QuizMode.kanjiToEnglish.toString(),
QuizMode.englishToKanji.toString(),
];
if (item.onyomi.isNotEmpty) {
requiredModes.add('${QuizMode.reading}onyomi');
}
if (item.kunyomi.isNotEmpty) {
requiredModes.add('${QuizMode.reading}kunyomi');
}
int minSrsStage = 9;
for (final mode in requiredModes) {
final srsItem = item.srsItems[mode];
if (srsItem == null) {
minSrsStage = 0;
break;
}
if (srsItem.srsStage < minSrsStage) {
minSrsStage = srsItem.srsStage;
}
}
return Card(
color: const Color(0xFF1E1E1E),
@@ -297,12 +334,12 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
characters,
item.characters,
style: const TextStyle(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
_buildSrsIndicator(avgSrsStage.round()),
_buildSrsIndicator(minSrsStage),
],
),
),
@@ -339,6 +376,32 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}
void _showReadingsDialog(KanjiItem kanji) {
final srsScores = <String, int>{
'JP -> EN': 0,
'EN -> JP': 0,
'Reading (onyomi)': 0,
'Reading (kunyomi)': 0,
};
for (final entry in kanji.srsItems.entries) {
final srsItem = entry.value;
switch (srsItem.quizMode) {
case QuizMode.kanjiToEnglish:
srsScores['JP -> EN'] = srsItem.srsStage;
break;
case QuizMode.englishToKanji:
srsScores['EN -> JP'] = srsItem.srsStage;
break;
case QuizMode.reading:
if (srsItem.readingType == 'onyomi') {
srsScores['Reading (onyomi)'] = srsItem.srsStage;
} else if (srsItem.readingType == 'kunyomi') {
srsScores['Reading (kunyomi)'] = srsItem.srsStage;
}
break;
}
}
showDialog(
context: context,
builder: (context) {
@@ -348,37 +411,51 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
'Details for ${kanji.characters}',
style: const TextStyle(color: Colors.white),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Level: ${kanji.level}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (kanji.meanings.isNotEmpty)
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Meanings: ${kanji.meanings.join(', ')}',
'Level: ${kanji.level}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (kanji.onyomi.isNotEmpty)
Text(
'On\'yomi: ${kanji.onyomi.join(', ')}',
style: const TextStyle(color: Colors.white),
),
if (kanji.kunyomi.isNotEmpty)
Text(
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
style: const TextStyle(color: Colors.white),
),
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const SizedBox(height: 16),
if (kanji.meanings.isNotEmpty)
Text(
'Meanings: ${kanji.meanings.join(', ')}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (kanji.onyomi.isNotEmpty)
Text(
'On\'yomi: ${kanji.onyomi.join(', ')}',
style: const TextStyle(color: Colors.white),
),
if (kanji.kunyomi.isNotEmpty)
Text(
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
style: const TextStyle(color: Colors.white),
),
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text(
'No readings available.',
style: TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text(
'No readings available.',
style: const TextStyle(color: Colors.white),
'SRS Scores:',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
],
...srsScores.entries.map((entry) => Text(
' ${entry.key}: ${entry.value}',
style: const TextStyle(color: Colors.white),
)),
],
),
),
actions: [
TextButton(
@@ -395,9 +472,11 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
Future<void> _loadDecks() async {
setState(() => _loading = true);
try {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
final apiKey = repo.apiKey;
final kanjiRepo = Provider.of<DeckRepository>(context, listen: false);
final vocabRepo =
Provider.of<VocabDeckRepository>(context, listen: false);
await kanjiRepo.loadApiKey();
final apiKey = kanjiRepo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
setState(() {
@@ -407,16 +486,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
return;
}
var kanji = await repo.loadKanji();
var kanji = await kanjiRepo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey);
kanji = await kanjiRepo.fetchAndCacheFromWk(apiKey);
}
var vocab = await repo.loadVocabulary();
var vocab = await vocabRepo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey);
vocab = await vocabRepo.fetchAndCacheVocabularyFromWk(apiKey);
}
_kanjiDeck = kanji;
@@ -458,7 +537,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
appBar:
_isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
backgroundColor: const Color(0xFF121212),
body: Column(
children: [
@@ -478,7 +558,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
(items) =>
_buildListView(items.cast<VocabularyItem>())),
),
_buildCustomSrsTab(),
],
@@ -487,10 +568,23 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
if (!_isSelectionMode)
SafeArea(
top: false,
child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(),
child: _tabController.index < 2
? _buildLevelSelector()
: const SizedBox.shrink(),
),
],
),
floatingActionButton: _tabController.index == 2
? FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AddCardScreen()),
);
_loadCustomDeck();
},
child: const Icon(Icons.add),
)
: null,
);
}
@@ -560,7 +654,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
content:
Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
@@ -568,6 +663,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
),
TextButton(
onPressed: () async {
final navigator = Navigator.of(context);
for (final item in _selectedItems) {
await _customDeckRepository.deleteCard(item);
}
@@ -575,8 +671,9 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
_isSelectionMode = false;
_selectedItems.clear();
});
_loadCustomDeck();
Navigator.of(context).pop();
await _loadCustomDeck();
if (!mounted) return;
navigator.pop();
},
child: const Text('Delete'),
),
@@ -591,7 +688,8 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}
final bool targetState = _selectedItems.any((item) => !item.useInterval);
final selectedCharacters = _selectedItems.map((item) => item.characters).toSet();
final selectedCharacters =
_selectedItems.map((item) => item.characters).toSet();
final List<CustomKanjiItem> updatedItems = [];
for (final item in _selectedItems) {
@@ -600,8 +698,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
meaning: item.meaning,
kanji: item.kanji,
useInterval: targetState,
srsLevel: item.srsLevel,
nextReview: item.nextReview,
srsData: item.srsData,
);
updatedItems.add(updatedItem);
}
@@ -653,14 +750,16 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
}
});
} else {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CustomCardDetailsScreen(
item: item,
repository: _customDeckRepository,
),
),
).then((_) => _loadCustomDeck());
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (_) => CustomCardDetailsScreen(
item: item,
repository: _customDeckRepository,
),
),
)
.then((_) => _loadCustomDeck());
}
},
child: Card(
@@ -671,7 +770,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
borderRadius: BorderRadius.circular(12.0),
),
color: isSelected
? Colors.blue.withOpacity(0.5)
? Colors.blue.withAlpha((255 * 0.5).round())
: const Color(0xFF1E1E1E),
child: Stack(
children: [
@@ -683,20 +782,30 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item.kanji ?? item.characters,
style: const TextStyle(fontSize: 32, color: Colors.white),
item.kanji?.isNotEmpty == true
? item.kanji!
: item.characters,
style:
const TextStyle(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
Text(
item.meaning,
style: const TextStyle(color: Colors.grey, fontSize: 16),
style:
const TextStyle(color: Colors.grey, fontSize: 16),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildSrsIndicator(item.srsLevel),
Builder(builder: (context) {
final avgSrs = (item.srsData.japaneseToEnglish +
item.srsData.englishToJapanese +
item.srsData.listeningComprehension) /
3;
return _buildSrsIndicator(avgSrs.round());
}),
],
),
),
@@ -719,3 +828,171 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
);
}
}
class _VocabDetailsDialog extends StatefulWidget {
final VocabularyItem vocab;
const _VocabDetailsDialog({required this.vocab});
@override
State<_VocabDetailsDialog> createState() => _VocabDetailsDialogState();
}
class _VocabDetailsDialogState extends State<_VocabDetailsDialog> {
List<Widget> _exampleSentences = [const CircularProgressIndicator()];
@override
void initState() {
super.initState();
_fetchExampleSentences();
}
Future<void> _fetchExampleSentences() async {
try {
final uri = Uri.parse(
'https://jisho.org/api/v1/search/words?keyword=${Uri.encodeComponent(widget.vocab.characters)}');
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
final sentences = <Widget>[];
if (data['data'] != null && (data['data'] as List).isNotEmpty) {
for (final result in data['data']) {
if (result['japanese'] != null &&
(result['japanese'] as List).isNotEmpty &&
result['senses'] != null &&
(result['senses'] as List).isNotEmpty) {
final japaneseWord = result['japanese'][0]['word'] ?? result['japanese'][0]['reading'];
final englishDefinition = result['senses'][0]['english_definitions'].join(', ');
if (japaneseWord != null && englishDefinition != null) {
sentences.add(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(japaneseWord, style: const TextStyle(color: Colors.white)),
Text(englishDefinition, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
],
),
);
}
}
}
}
if (sentences.isEmpty) {
sentences.add(const Text('No example sentences found.', style: TextStyle(color: Colors.white)));
}
if (mounted) {
setState(() {
_exampleSentences = sentences;
});
}
} else {
if (mounted) {
setState(() {
_exampleSentences = [
const Text('Failed to load example sentences.', style: TextStyle(color: Colors.red))
];
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_exampleSentences = [
const Text('Error loading example sentences.', style: TextStyle(color: Colors.red))
];
});
}
}
}
@override
Widget build(BuildContext context) {
final srsScores = <String, int>{
'JP -> EN': 0,
'EN -> JP': 0,
'Audio': 0,
};
for (final entry in widget.vocab.srsItems.entries) {
final srsItem = entry.value;
switch (srsItem.quizMode) {
case VocabQuizMode.vocabToEnglish:
srsScores['JP -> EN'] = srsItem.srsStage;
break;
case VocabQuizMode.englishToVocab:
srsScores['EN -> JP'] = srsItem.srsStage;
break;
case VocabQuizMode.audioToEnglish:
srsScores['Audio'] = srsItem.srsStage;
break;
}
}
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Level: ${widget.vocab.level}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (widget.vocab.meanings.isNotEmpty)
Text(
'Meanings: ${widget.vocab.meanings.join(', ')}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
if (widget.vocab.readings.isNotEmpty)
Text(
'Readings: ${widget.vocab.readings.join(', ')}',
style: const TextStyle(color: Colors.white),
),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text(
'SRS Scores:',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
...srsScores.entries.map((entry) => Text(
' ${entry.key}: ${entry.value}',
style: const TextStyle(color: Colors.white),
)),
const SizedBox(height: 16),
const Divider(color: Colors.grey),
const SizedBox(height: 16),
const Text(
'Example Sentences:',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
..._exampleSentences,
],
),
);
}
}
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
backgroundColor: const Color(0xFF1E1E1E),
title: Text(
'Details for ${vocab.characters}',
style: const TextStyle(color: Colors.white),
),
content: _VocabDetailsDialog(vocab: vocab),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close', style: TextStyle(color: Colors.blueAccent)),
),
],
);
},
);
}