1186 lines
36 KiB
Dart
1186 lines
36 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hirameki_srs/src/themes.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import '../models/kanji_item.dart';
|
|
import '../models/vocabulary_item.dart';
|
|
import '../models/srs_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});
|
|
|
|
@override
|
|
State<BrowseScreen> createState() => _BrowseScreenState();
|
|
}
|
|
|
|
class _BrowseScreenState extends State<BrowseScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
late PageController _kanjiPageController;
|
|
late PageController _vocabPageController;
|
|
|
|
List<KanjiItem> _kanjiDeck = [];
|
|
List<VocabularyItem> _vocabDeck = [];
|
|
List<CustomKanjiItem> _customDeck = [];
|
|
Map<int, List<KanjiItem>> _kanjiByLevel = {};
|
|
Map<int, List<VocabularyItem>> _vocabByLevel = {};
|
|
List<int> _kanjiSortedLevels = [];
|
|
List<int> _vocabSortedLevels = [];
|
|
|
|
final _customDeckRepository = CustomDeckRepository();
|
|
|
|
bool _isSelectionMode = false;
|
|
List<CustomKanjiItem> _selectedItems = [];
|
|
|
|
bool _loading = true;
|
|
String _status = 'Loading...';
|
|
int _currentKanjiPage = 0;
|
|
int _currentVocabPage = 0;
|
|
bool _apiKeyMissing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 3, vsync: this);
|
|
_kanjiPageController = PageController();
|
|
_vocabPageController = PageController();
|
|
|
|
_tabController.addListener(() {
|
|
setState(() {});
|
|
});
|
|
|
|
_kanjiPageController.addListener(() {
|
|
if (_kanjiPageController.page?.round() != _currentKanjiPage) {
|
|
setState(() {
|
|
_currentKanjiPage = _kanjiPageController.page!.round();
|
|
});
|
|
}
|
|
});
|
|
|
|
_vocabPageController.addListener(() {
|
|
if (_vocabPageController.page?.round() != _currentVocabPage) {
|
|
setState(() {
|
|
_currentVocabPage = _vocabPageController.page!.round();
|
|
});
|
|
}
|
|
});
|
|
|
|
_loadDecks();
|
|
_loadCustomDeck();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_kanjiPageController.dispose();
|
|
_vocabPageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadCustomDeck() async {
|
|
final customDeck = await _customDeckRepository.getCustomDeck();
|
|
setState(() {
|
|
_customDeck = customDeck;
|
|
});
|
|
}
|
|
|
|
Widget _buildWaniKaniTab(Widget child) {
|
|
if (_apiKeyMissing) {
|
|
return 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()),
|
|
);
|
|
if (!mounted) return;
|
|
_loadDecks();
|
|
},
|
|
child: const Text('Go to Settings'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_loading) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_status,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return child;
|
|
}
|
|
|
|
Widget _buildCustomSrsTab() {
|
|
if (_customDeck.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No custom cards yet.',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
);
|
|
}
|
|
return _buildCustomGridView(_customDeck);
|
|
}
|
|
|
|
Widget _buildPaginatedView(
|
|
Map<int, List<dynamic>> groupedItems,
|
|
List<int> sortedLevels,
|
|
PageController pageController,
|
|
Widget Function(List<dynamic>) buildPageContent,
|
|
dynamic repository,
|
|
) {
|
|
if (sortedLevels.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No items to display.',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
);
|
|
}
|
|
|
|
return PageView.builder(
|
|
controller: pageController,
|
|
itemCount: sortedLevels.length,
|
|
itemBuilder: (context, index) {
|
|
final level = sortedLevels[index];
|
|
final levelItems = groupedItems[level]!;
|
|
final bool isDisabled = levelItems.every(
|
|
(item) => (item as dynamic).srsItems.values.every(
|
|
(srs) => (srs as SrsItem).disabled,
|
|
),
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'Level $level',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Checkbox(
|
|
value: !isDisabled,
|
|
onChanged: (value) {
|
|
_toggleLevelExclusion(level, repository);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(child: buildPageContent(levelItems)),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildLevelSelector() {
|
|
final isKanji = _tabController.index == 0;
|
|
final levels = isKanji ? _kanjiSortedLevels : _vocabSortedLevels;
|
|
final controller = isKanji ? _kanjiPageController : _vocabPageController;
|
|
final currentPage = isKanji ? _currentKanjiPage : _currentVocabPage;
|
|
|
|
if (levels.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: List.generate(levels.length, (index) {
|
|
final level = levels[index];
|
|
final isSelected = index == currentPage;
|
|
final items = isKanji ? _kanjiByLevel[level] : _vocabByLevel[level];
|
|
final bool isDisabled =
|
|
items?.every(
|
|
(item) => (item as dynamic).srsItems.values.every(
|
|
(srs) => (srs as SrsItem).disabled,
|
|
),
|
|
) ??
|
|
false;
|
|
|
|
return Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
controller.animateToPage(
|
|
index,
|
|
|
|
duration: const Duration(milliseconds: 300),
|
|
|
|
curve: Curves.easeInOut,
|
|
);
|
|
},
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: isSelected
|
|
? Theme.of(context).colorScheme.primary
|
|
: isDisabled
|
|
? Theme.of(context).colorScheme.surfaceVariant
|
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
|
|
foregroundColor: isSelected
|
|
? Theme.of(context).colorScheme.onPrimary
|
|
: isDisabled
|
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
|
: Theme.of(context).colorScheme.onSurface,
|
|
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
|
|
padding: const EdgeInsets.all(12),
|
|
),
|
|
|
|
child: Text(level.toString()),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGridView(List<KanjiItem> items) {
|
|
return GridView.builder(
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 150,
|
|
childAspectRatio: 1,
|
|
crossAxisSpacing: 8,
|
|
mainAxisSpacing: 8,
|
|
),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
return GestureDetector(
|
|
onTap: () => _showReadingsDialog(item),
|
|
child: _buildSrsItemCard(item),
|
|
);
|
|
},
|
|
padding: const EdgeInsets.all(8),
|
|
);
|
|
}
|
|
|
|
Widget _buildListView(List<VocabularyItem> items) {
|
|
return ListView.builder(
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
return _buildVocabListTile(item);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildVocabListTile(VocabularyItem item) {
|
|
final requiredModes = <String>[
|
|
QuizMode.vocabToEnglish.toString(),
|
|
QuizMode.englishToVocab.toString(),
|
|
QuizMode.audioToEnglish.toString(),
|
|
];
|
|
|
|
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: Theme.of(context).colorScheme.surfaceContainer,
|
|
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: TextStyle(
|
|
fontSize: 24,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.meanings.join(', '),
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSrsIndicator(minSrsStage),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: Theme.of(context).colorScheme.surfaceContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
item.characters,
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildSrsIndicator(minSrsStage),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSrsIndicator(int level) {
|
|
return Tooltip(
|
|
message: 'SRS Level: $level',
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: SizedBox(
|
|
height: 10,
|
|
child: LinearProgressIndicator(
|
|
value: level / 9.0,
|
|
backgroundColor: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
_getColorForSrsLevel(level),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getColorForSrsLevel(int level) {
|
|
final srsColors = Theme.of(context).srsColors;
|
|
if (level >= 9) return srsColors.level9;
|
|
if (level >= 8) return srsColors.level8;
|
|
if (level >= 7) return srsColors.level7;
|
|
if (level >= 6) return srsColors.level6;
|
|
if (level >= 5) return srsColors.level5;
|
|
if (level >= 4) return srsColors.level4;
|
|
if (level >= 3) return srsColors.level3;
|
|
if (level >= 2) return srsColors.level2;
|
|
if (level >= 1) return srsColors.level1;
|
|
return Colors.grey;
|
|
}
|
|
|
|
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;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
|
title: Text(
|
|
'Details for ${kanji.characters}',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
|
|
),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Level: ${kanji.level}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (kanji.meanings.isNotEmpty)
|
|
Text(
|
|
'Meanings: ${kanji.meanings.join(', ')}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (kanji.onyomi.isNotEmpty)
|
|
Text(
|
|
'On\'yomi: ${kanji.onyomi.join(', ')}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
if (kanji.kunyomi.isNotEmpty)
|
|
Text(
|
|
'Kun\'yomi: ${kanji.kunyomi.join(', ')}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
|
|
Text(
|
|
'No readings available.',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Divider(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'SRS Scores:',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
...srsScores.entries.map(
|
|
(entry) => Text(
|
|
' ${entry.key}: ${entry.value}',
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(
|
|
'Close',
|
|
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _loadDecks() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
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(() {
|
|
_apiKeyMissing = true;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
var kanji = await kanjiRepo.loadKanji();
|
|
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
|
|
setState(() => _status = 'Fetching kanji from WaniKani...');
|
|
kanji = await kanjiRepo.fetchAndCacheFromWk(apiKey);
|
|
}
|
|
|
|
var vocab = await vocabRepo.loadVocabulary();
|
|
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
|
|
setState(() => _status = 'Fetching vocabulary from WaniKani...');
|
|
vocab = await vocabRepo.fetchAndCacheVocabularyFromWk(apiKey);
|
|
}
|
|
|
|
_kanjiDeck = kanji;
|
|
_vocabDeck = vocab;
|
|
_groupItemsByLevel();
|
|
|
|
setState(() {
|
|
_loading = false;
|
|
_status =
|
|
'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.';
|
|
_apiKeyMissing = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_status = 'Error: $e';
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _groupItemsByLevel() {
|
|
_kanjiByLevel = {};
|
|
for (final item in _kanjiDeck) {
|
|
if (item.level > 0) {
|
|
(_kanjiByLevel[item.level] ??= []).add(item);
|
|
}
|
|
}
|
|
_kanjiSortedLevels = _kanjiByLevel.keys.toList()..sort();
|
|
|
|
_vocabByLevel = {};
|
|
for (final item in _vocabDeck) {
|
|
if (item.level > 0) {
|
|
(_vocabByLevel[item.level] ??= []).add(item);
|
|
}
|
|
}
|
|
_vocabSortedLevels = _vocabByLevel.keys.toList()..sort();
|
|
}
|
|
|
|
Future<void> _toggleLevelExclusion(int level, dynamic repository) async {
|
|
final List<SrsItem> itemsToUpdate = [];
|
|
final bool currentlyDisabled;
|
|
|
|
if (repository is DeckRepository) {
|
|
final items = _kanjiByLevel[level] ?? [];
|
|
currentlyDisabled = items.every(
|
|
(item) => item.srsItems.values.every((srs) => srs.disabled),
|
|
);
|
|
for (final item in items) {
|
|
for (final srsItem in item.srsItems.values) {
|
|
itemsToUpdate.add(srsItem);
|
|
}
|
|
}
|
|
} else if (repository is VocabDeckRepository) {
|
|
final items = _vocabByLevel[level] ?? [];
|
|
currentlyDisabled = items.every(
|
|
(item) => item.srsItems.values.every((srs) => srs.disabled),
|
|
);
|
|
for (final item in items) {
|
|
for (final srsItem in item.srsItems.values) {
|
|
itemsToUpdate.add(srsItem);
|
|
}
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
for (final item in itemsToUpdate) {
|
|
item.disabled = !currentlyDisabled;
|
|
}
|
|
|
|
await repository.updateSrsItems(itemsToUpdate);
|
|
_loadDecks();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: _isSelectionMode
|
|
? _buildSelectionAppBar()
|
|
: _buildDefaultAppBar(),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildWaniKaniTab(
|
|
_buildPaginatedView(
|
|
_kanjiByLevel,
|
|
_kanjiSortedLevels,
|
|
_kanjiPageController,
|
|
(items) => _buildGridView(items.cast<KanjiItem>()),
|
|
Provider.of<DeckRepository>(context, listen: false),
|
|
),
|
|
),
|
|
_buildWaniKaniTab(
|
|
_buildPaginatedView(
|
|
_vocabByLevel,
|
|
_vocabSortedLevels,
|
|
_vocabPageController,
|
|
(items) => _buildListView(items.cast<VocabularyItem>()),
|
|
Provider.of<VocabDeckRepository>(context, listen: false),
|
|
),
|
|
),
|
|
_buildCustomSrsTab(),
|
|
],
|
|
),
|
|
),
|
|
if (!_isSelectionMode)
|
|
SafeArea(
|
|
top: false,
|
|
child: _tabController.index < 2
|
|
? _buildLevelSelector()
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: _tabController.index == 2
|
|
? FloatingActionButton(
|
|
onPressed: () async {
|
|
await Navigator.of(
|
|
context,
|
|
).push(MaterialPageRoute(builder: (_) => AddCardScreen()));
|
|
if (!mounted) return;
|
|
_loadCustomDeck();
|
|
},
|
|
child: const Icon(Icons.add),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
AppBar _buildDefaultAppBar() {
|
|
return AppBar(
|
|
title: const Text('Browse'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: 'Kanji'),
|
|
Tab(text: 'Vocabulary'),
|
|
Tab(text: 'Custom SRS'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
AppBar _buildSelectionAppBar() {
|
|
return AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () {
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedItems.clear();
|
|
});
|
|
},
|
|
),
|
|
title: Text('${_selectedItems.length} selected'),
|
|
actions: [
|
|
IconButton(icon: const Icon(Icons.select_all), onPressed: _selectAll),
|
|
IconButton(icon: const Icon(Icons.delete), onPressed: _deleteSelected),
|
|
IconButton(
|
|
icon: Icon(_toggleIntervalIcon),
|
|
onPressed: _toggleIntervalForSelected,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
IconData get _toggleIntervalIcon {
|
|
if (_selectedItems.isEmpty) {
|
|
return Icons.timer_off;
|
|
}
|
|
final bool willEnable = _selectedItems.any((item) => !item.useInterval);
|
|
return willEnable ? Icons.timer : Icons.timer_off;
|
|
}
|
|
|
|
void _selectAll() {
|
|
setState(() {
|
|
if (_selectedItems.length == _customDeck.length) {
|
|
_selectedItems.clear();
|
|
} else {
|
|
_selectedItems = List.from(_customDeck);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _deleteSelected() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text('Delete Selected'),
|
|
content: Text(
|
|
'Are you sure you want to delete ${_selectedItems.length} cards?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(dialogContext).pop();
|
|
() async {
|
|
for (final item in _selectedItems) {
|
|
await _customDeckRepository.deleteCard(item);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isSelectionMode = false;
|
|
_selectedItems.clear();
|
|
});
|
|
_loadCustomDeck();
|
|
}();
|
|
},
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _toggleIntervalForSelected() async {
|
|
if (_selectedItems.isEmpty) {
|
|
return;
|
|
}
|
|
final bool targetState = _selectedItems.any((item) => !item.useInterval);
|
|
|
|
final selectedCharacters = _selectedItems
|
|
.map((item) => item.characters)
|
|
.toSet();
|
|
|
|
final List<CustomKanjiItem> updatedItems = [];
|
|
for (final item in _selectedItems) {
|
|
final updatedItem = CustomKanjiItem(
|
|
characters: item.characters,
|
|
meaning: item.meaning,
|
|
kanji: item.kanji,
|
|
useInterval: targetState,
|
|
srsData: item.srsData,
|
|
);
|
|
updatedItems.add(updatedItem);
|
|
}
|
|
|
|
await _customDeckRepository.updateCards(updatedItems);
|
|
await _loadCustomDeck();
|
|
|
|
final newSelectedItems = _customDeck
|
|
.where((item) => selectedCharacters.contains(item.characters))
|
|
.toList();
|
|
|
|
setState(() {
|
|
_selectedItems = newSelectedItems;
|
|
if (_selectedItems.isEmpty) {
|
|
_isSelectionMode = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
Widget _buildCustomGridView(List<CustomKanjiItem> items) {
|
|
return GridView.builder(
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 200,
|
|
childAspectRatio: 1.2,
|
|
crossAxisSpacing: 8,
|
|
mainAxisSpacing: 8,
|
|
),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
final item = items[index];
|
|
final isSelected = _selectedItems.contains(item);
|
|
return GestureDetector(
|
|
onLongPress: () {
|
|
setState(() {
|
|
_isSelectionMode = true;
|
|
_selectedItems.add(item);
|
|
});
|
|
},
|
|
onTap: () {
|
|
if (_isSelectionMode) {
|
|
setState(() {
|
|
if (isSelected) {
|
|
_selectedItems.remove(item);
|
|
if (_selectedItems.isEmpty) {
|
|
_isSelectionMode = false;
|
|
}
|
|
} else {
|
|
_selectedItems.add(item);
|
|
}
|
|
});
|
|
} else {
|
|
Navigator.of(context)
|
|
.push(
|
|
MaterialPageRoute(
|
|
builder: (_) => CustomCardDetailsScreen(
|
|
item: item,
|
|
repository: _customDeckRepository,
|
|
),
|
|
),
|
|
)
|
|
.then((_) => _loadCustomDeck());
|
|
}
|
|
},
|
|
child: Card(
|
|
shape: RoundedRectangleBorder(
|
|
side: isSelected
|
|
? BorderSide(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
width: 2.0,
|
|
)
|
|
: BorderSide.none,
|
|
borderRadius: BorderRadius.circular(12.0),
|
|
),
|
|
color: isSelected
|
|
? Theme.of(
|
|
context,
|
|
).colorScheme.primary.withAlpha((255 * 0.5).round())
|
|
: Theme.of(context).colorScheme.surfaceContainer,
|
|
child: Stack(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
item.kanji?.isNotEmpty == true
|
|
? item.kanji!
|
|
: item.characters,
|
|
style: TextStyle(
|
|
fontSize: 32,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
item.meaning,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontSize: 16,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Builder(
|
|
builder: (context) {
|
|
final avgSrs =
|
|
(item.srsData.japaneseToEnglish +
|
|
item.srsData.englishToJapanese +
|
|
item.srsData.listeningComprehension) /
|
|
3;
|
|
return _buildSrsIndicator(avgSrs.round());
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (item.useInterval)
|
|
Positioned(
|
|
top: 4,
|
|
right: 4,
|
|
child: Icon(
|
|
Icons.timer,
|
|
color: Theme.of(context).colorScheme.tertiary,
|
|
size: 16,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
padding: const EdgeInsets.all(8),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VocabDetailsDialog extends StatefulWidget {
|
|
final VocabularyItem vocab;
|
|
final ThemeData theme;
|
|
|
|
const _VocabDetailsDialog({required this.vocab, required this.theme});
|
|
|
|
@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: TextStyle(
|
|
color: widget.theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
englishDefinition,
|
|
style: TextStyle(
|
|
color: widget.theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (sentences.isEmpty) {
|
|
sentences.add(
|
|
Text(
|
|
'No example sentences found.',
|
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
|
),
|
|
);
|
|
}
|
|
if (mounted) {
|
|
setState(() {
|
|
_exampleSentences = sentences;
|
|
});
|
|
}
|
|
} else {
|
|
if (mounted) {
|
|
setState(() {
|
|
_exampleSentences = [
|
|
Text(
|
|
'Failed to load example sentences.',
|
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
|
),
|
|
];
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_exampleSentences = [
|
|
Text(
|
|
'Error loading example sentences.',
|
|
style: TextStyle(color: widget.theme.colorScheme.error),
|
|
),
|
|
];
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@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 QuizMode.vocabToEnglish:
|
|
srsScores['JP -> EN'] = srsItem.srsStage;
|
|
break;
|
|
case QuizMode.englishToVocab:
|
|
srsScores['EN -> JP'] = srsItem.srsStage;
|
|
break;
|
|
case QuizMode.audioToEnglish:
|
|
srsScores['Audio'] = srsItem.srsStage;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Level: ${widget.vocab.level}',
|
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (widget.vocab.meanings.isNotEmpty)
|
|
Text(
|
|
'Meanings: ${widget.vocab.meanings.join(', ')}',
|
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (widget.vocab.readings.isNotEmpty)
|
|
Text(
|
|
'Readings: ${widget.vocab.readings.join(', ')}',
|
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'SRS Scores:',
|
|
style: TextStyle(
|
|
color: widget.theme.colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
...srsScores.entries.map(
|
|
(entry) => Text(
|
|
' ${entry.key}: ${entry.value}',
|
|
style: TextStyle(color: widget.theme.colorScheme.onSurface),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Divider(color: widget.theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Example Sentences:',
|
|
style: TextStyle(
|
|
color: widget.theme.colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
..._exampleSentences,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showVocabDetailsDialog(BuildContext context, VocabularyItem vocab) {
|
|
final currentTheme = Theme.of(context);
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
backgroundColor: currentTheme.colorScheme.surfaceContainer,
|
|
title: Text(
|
|
'Details for ${vocab.characters}',
|
|
style: TextStyle(color: currentTheme.colorScheme.onSurface),
|
|
),
|
|
content: _VocabDetailsDialog(vocab: vocab, theme: currentTheme),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(
|
|
'Close',
|
|
style: TextStyle(color: currentTheme.colorScheme.primary),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|