add a shit ton of feature for the custom srs

This commit is contained in:
Rene Kievits
2025-10-30 03:44:04 +01:00
parent b58a4020e1
commit ee4fd7ffc1
13 changed files with 787 additions and 311 deletions

View File

@@ -2,7 +2,10 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/kanji_item.dart';
import '../services/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';
class BrowseScreen extends StatefulWidget {
const BrowseScreen({super.key});
@@ -18,11 +21,17 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
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;
@@ -32,7 +41,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController = TabController(length: 3, vsync: this);
_kanjiPageController = PageController();
_vocabPageController = PageController();
@@ -57,6 +66,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
});
_loadDecks();
_loadCustomDeck();
}
@override
@@ -67,144 +77,58 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
super.dispose();
}
Future<void> _loadDecks() async {
setState(() => _loading = true);
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 kanji = await repo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey);
}
var vocab = await repo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.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;
});
}
Future<void> _loadCustomDeck() async {
final customDeck = await _customDeckRepository.getCustomDeck();
setState(() {
_customDeck = customDeck;
});
}
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();
}
@override
Widget build(BuildContext context) {
Widget _buildWaniKaniTab(Widget child) {
if (_apiKeyMissing) {
return Scaffold(
appBar: AppBar(title: const Text('Browse')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
_loadDecks();
},
child: const Text('Go to Settings'),
),
],
),
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('WaniKani API key is not set.', style: TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
_loadDecks();
},
child: const Text('Go to Settings'),
),
],
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Browse'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Kanji'),
Tab(text: 'Vocabulary'),
if (_loading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.blueAccent),
const SizedBox(height: 16),
Text(_status, style: const TextStyle(color: Colors.white)),
],
),
),
backgroundColor: const Color(0xFF121212),
body: _loading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(color: Colors.blueAccent),
const SizedBox(height: 16),
Text(_status, style: const TextStyle(color: Colors.white)),
],
),
)
: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildPaginatedView(
_kanjiByLevel,
_kanjiSortedLevels,
_kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>())),
_buildPaginatedView(
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
],
),
),
SafeArea(
top: false,
child: _buildLevelSelector(),
),
],
),
);
);
}
return child;
}
Widget _buildCustomSrsTab() {
if (_customDeck.isEmpty) {
return const Center(
child: Text('No custom cards yet.', style: TextStyle(color: Colors.white)),
);
}
return _buildCustomGridView(_customDeck);
}
Widget _buildPaginatedView(
@@ -452,7 +376,7 @@ class _BrowseScreenState extends State<BrowseScreen> with SingleTickerProviderSt
if (kanji.onyomi.isEmpty && kanji.kunyomi.isEmpty)
const Text(
'No readings available.',
style: TextStyle(color: Colors.white),
style: const TextStyle(color: Colors.white),
),
],
),
@@ -467,4 +391,290 @@ 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;
if (apiKey == null || apiKey.isEmpty) {
setState(() {
_apiKeyMissing = true;
_loading = false;
});
return;
}
var kanji = await repo.loadKanji();
if (kanji.isEmpty || kanji.every((k) => k.level == 0)) {
setState(() => _status = 'Fetching kanji from WaniKani...');
kanji = await repo.fetchAndCacheFromWk(apiKey);
}
var vocab = await repo.loadVocabulary();
if (vocab.isEmpty || vocab.every((v) => v.level == 0)) {
setState(() => _status = 'Fetching vocabulary from WaniKani...');
vocab = await repo.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();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildDefaultAppBar(),
backgroundColor: const Color(0xFF121212),
body: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildWaniKaniTab(
_buildPaginatedView(
_kanjiByLevel,
_kanjiSortedLevels,
_kanjiPageController,
(items) => _buildGridView(items.cast<KanjiItem>())),
),
_buildWaniKaniTab(
_buildPaginatedView(
_vocabByLevel,
_vocabSortedLevels,
_vocabPageController,
(items) => _buildListView(items.cast<VocabularyItem>())),
),
_buildCustomSrsTab(),
],
),
),
if (!_isSelectionMode)
SafeArea(
top: false,
child: _tabController.index < 2 ? _buildLevelSelector() : const SizedBox.shrink(),
),
],
),
);
}
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: const Icon(Icons.timer_off),
onPressed: _toggleIntervalForSelected,
),
],
);
}
void _selectAll() {
setState(() {
if (_selectedItems.length == _customDeck.length) {
_selectedItems.clear();
} else {
_selectedItems = List.from(_customDeck);
}
});
}
void _deleteSelected() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Are you sure you want to delete ${_selectedItems.length} cards?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
for (final item in _selectedItems) {
await _customDeckRepository.deleteCard(item);
}
setState(() {
_isSelectionMode = false;
_selectedItems.clear();
});
_loadCustomDeck();
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
),
);
}
void _toggleIntervalForSelected() {
for (final item in _selectedItems) {
final updatedItem = CustomKanjiItem(
characters: item.characters,
meaning: item.meaning,
kanji: item.kanji,
useInterval: !item.useInterval,
srsLevel: item.srsLevel,
nextReview: item.nextReview,
);
_customDeckRepository.updateCard(updatedItem);
}
setState(() {
_isSelectionMode = false;
_selectedItems.clear();
});
_loadCustomDeck();
}
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(
color: isSelected
? Colors.blue.withOpacity(0.5)
: item.useInterval
? Color.lerp(const Color(0xFF1E1E1E), Colors.blue, 0.1)
: const Color(0xFF1E1E1E),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
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),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
_buildSrsIndicator(item.srsLevel),
],
),
),
),
);
},
padding: const EdgeInsets.all(8),
);
}
}