From bef4519ab5c3e9bf2f35576567b5917d342a79f8 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Tue, 28 Oct 2025 20:13:46 +0100 Subject: [PATCH] add pages and level grouping for the browser --- lib/src/models/kanji_item.dart | 8 + lib/src/screens/browse_screen.dart | 228 +++++++++++++++++++++----- lib/src/services/deck_repository.dart | 18 +- test/distractor_test.dart | 4 +- 4 files changed, 215 insertions(+), 43 deletions(-) diff --git a/lib/src/models/kanji_item.dart b/lib/src/models/kanji_item.dart index 3ccae88..f1bade7 100644 --- a/lib/src/models/kanji_item.dart +++ b/lib/src/models/kanji_item.dart @@ -18,6 +18,7 @@ class SrsItem { class KanjiItem { final int id; + final int level; final String characters; final List meanings; final List onyomi; @@ -26,6 +27,7 @@ class KanjiItem { KanjiItem({ required this.id, + required this.level, required this.characters, required this.meanings, required this.onyomi, @@ -35,6 +37,7 @@ class KanjiItem { factory KanjiItem.fromSubject(Map subj) { final int id = subj['id'] as int; final data = subj['data'] as Map; + final int level = data['level'] as int; final String characters = (data['characters'] ?? '') as String; final List meanings = []; final List onyomi = []; @@ -60,6 +63,7 @@ class KanjiItem { return KanjiItem( id: id, + level: level, characters: characters, meanings: meanings, onyomi: onyomi, @@ -105,6 +109,7 @@ class PronunciationAudio { class VocabularyItem { final int id; + final int level; final String characters; final List meanings; final List readings; @@ -113,6 +118,7 @@ class VocabularyItem { VocabularyItem( {required this.id, + required this.level, required this.characters, required this.meanings, required this.readings, @@ -121,6 +127,7 @@ class VocabularyItem { factory VocabularyItem.fromSubject(Map subj) { final int id = subj['id'] as int; final data = subj['data'] as Map; + final int level = data['level'] as int; final String characters = (data['characters'] ?? '') as String; final List meanings = []; final List readings = []; @@ -155,6 +162,7 @@ class VocabularyItem { return VocabularyItem( id: id, + level: level, characters: characters, meanings: meanings, readings: readings, diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart index e730936..e199b18 100644 --- a/lib/src/screens/browse_screen.dart +++ b/lib/src/screens/browse_screen.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/kanji_item.dart'; @@ -14,31 +13,59 @@ class BrowseScreen extends StatefulWidget { class _BrowseScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + late PageController _kanjiPageController; + late PageController _vocabPageController; + List _kanjiDeck = []; List _vocabDeck = []; + Map> _kanjiByLevel = {}; + Map> _vocabByLevel = {}; + List _kanjiSortedLevels = []; + List _vocabSortedLevels = []; + bool _loading = true; String _status = 'Loading...'; + int _currentKanjiPage = 0; + int _currentVocabPage = 0; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); + _kanjiPageController = PageController(); + _vocabPageController = PageController(); + + _tabController.addListener(() { + setState(() {}); // Rebuild to update the level selector + }); + + _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(); } Future _loadDecks() async { - setState(() { - _loading = true; - _status = 'Loading decks...'; - }); - + setState(() => _loading = true); try { final repo = Provider.of(context, listen: false); await repo.loadApiKey(); final apiKey = repo.apiKey; if (apiKey == null || apiKey.isEmpty) { - // Optionally, navigate to settings or show an error setState(() { _status = 'API key not set.'; _loading = false; @@ -47,22 +74,25 @@ class _BrowseScreenState extends State } var kanji = await repo.loadKanji(); - if (kanji.isEmpty) { + 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) { + 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(() { - _kanjiDeck = kanji; - _vocabDeck = vocab; _loading = false; - _status = 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.'; + _status = + 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.'; }); } catch (e) { setState(() { @@ -72,6 +102,24 @@ class _BrowseScreenState extends State } } + 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( @@ -80,6 +128,16 @@ class _BrowseScreenState extends State title: const Text('Browse Items'), backgroundColor: const Color(0xFF1F1F1F), foregroundColor: Colors.white, + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Kanji'), + Tab(text: 'Vocabulary'), + ], + labelColor: Colors.blueAccent, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.blueAccent, + ), ), body: _loading ? Center( @@ -98,27 +156,107 @@ class _BrowseScreenState extends State child: TabBarView( controller: _tabController, children: [ - _buildGridView(_kanjiDeck), - _buildListView(_vocabDeck), + _buildPaginatedView( + _kanjiByLevel, + _kanjiSortedLevels, + _kanjiPageController, + (items) => _buildGridView(items.cast())), + _buildPaginatedView( + _vocabByLevel, + _vocabSortedLevels, + _vocabPageController, + (items) => _buildListView(items.cast())), ], ), ), + SafeArea( + top: false, + child: _buildLevelSelector(), + ), ], ), - bottomNavigationBar: TabBar( - controller: _tabController, - tabs: const [ - Tab(text: 'Kanji'), - Tab(text: 'Vocabulary'), - ], - labelColor: Colors.blueAccent, - unselectedLabelColor: Colors.grey, - indicatorColor: Colors.blueAccent, + ); + } + + Widget _buildPaginatedView( + Map> groupedItems, + List sortedLevels, + PageController pageController, + Widget Function(List) buildPageContent) { + if (sortedLevels.isEmpty) { + return const Center( + child: + Text('No items to display.', style: TextStyle(color: Colors.white)), + ); + } + + return PageView.builder( + controller: pageController, + itemCount: sortedLevels.length, + itemBuilder: (context, index) { + final level = sortedLevels[index]; + final levelItems = groupedItems[level]!; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Level $level', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold), + ), + ), + 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: const Color(0xFF1F1F1F), + height: 60, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(levels.length, (index) { + final level = levels[index]; + final isSelected = index == currentPage; + return 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 ? Colors.blueAccent : const Color(0xFF333333), + foregroundColor: Colors.white, + shape: const CircleBorder(), + padding: const EdgeInsets.all(12), + ), + child: Text(level.toString()), + ), + ); + }), + ), ), ); } - Widget _buildGridView(List items) { + Widget _buildGridView(List items) { return GridView.builder( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 150, @@ -129,15 +267,11 @@ class _BrowseScreenState extends State itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - if (item is KanjiItem) { - return GestureDetector( - onTap: () => _showReadingsDialog(item), - child: _buildSrsItemCard(item.characters, item.srsItems.values.toList()), - ); - } else if (item is VocabularyItem) { - return _buildSrsItemCard(item.characters, item.srsItems.values.toList()); - } - return const SizedBox.shrink(); + return GestureDetector( + onTap: () => _showReadingsDialog(item), + child: + _buildSrsItemCard(item.characters, item.srsItems.values.toList()), + ); }, padding: const EdgeInsets.all(8), ); @@ -155,7 +289,9 @@ class _BrowseScreenState extends State Widget _buildVocabListTile(VocabularyItem item) { final avgSrsStage = item.srsItems.isNotEmpty - ? item.srsItems.values.map((s) => s.srsStage).reduce((a, b) => a + b) / + ? item.srsItems.values + .map((s) => s.srsStage) + .reduce((a, b) => a + b) / item.srsItems.length : 0.0; @@ -196,7 +332,8 @@ class _BrowseScreenState extends State Widget _buildSrsItemCard(String characters, List srsItems) { final avgSrsStage = srsItems.isNotEmpty - ? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) / srsItems.length + ? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) / + srsItems.length : 0.0; return Card( @@ -255,13 +392,18 @@ class _BrowseScreenState extends State return AlertDialog( backgroundColor: const Color(0xFF1E1E1E), title: Text( - 'Readings for ${kanji.characters}', + '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) Text( 'Meanings: ${kanji.meanings.join(', ')}', @@ -288,10 +430,20 @@ class _BrowseScreenState extends State actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Close', style: TextStyle(color: Colors.blueAccent)), + child: const Text('Close', + style: TextStyle(color: Colors.blueAccent)), ), ], ); }, ); - }} + } + + @override + void dispose() { + _tabController.dispose(); + _kanjiPageController.dispose(); + _vocabPageController.dispose(); + super.dispose(); + } +} diff --git a/lib/src/services/deck_repository.dart b/lib/src/services/deck_repository.dart index 02fb917..0d69c7c 100644 --- a/lib/src/services/deck_repository.dart +++ b/lib/src/services/deck_repository.dart @@ -24,16 +24,16 @@ class DeckRepository { _db = await openDatabase( path, - version: 6, + version: 7, onCreate: (db, version) async { await db.execute( - '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); + '''CREATE TABLE kanji (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)'''); await db.execute( '''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)'''); await db.execute( '''CREATE TABLE srs_items (kanjiId INTEGER, quizMode TEXT, readingType TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (kanjiId, quizMode, readingType))'''); await db.execute( - '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)'''); + '''CREATE TABLE vocabulary (id INTEGER PRIMARY KEY, level INTEGER, characters TEXT, meanings TEXT, readings TEXT, pronunciation_audios TEXT)'''); await db.execute( '''CREATE TABLE srs_vocab_items (vocabId INTEGER, quizMode TEXT, srsStage INTEGER, lastAsked TEXT, PRIMARY KEY (vocabId, quizMode))'''); }, @@ -64,6 +64,14 @@ class DeckRepository { // Ignore error, column might already exist } } + if (oldVersion < 7) { + try { + await db.execute('ALTER TABLE kanji ADD COLUMN level INTEGER'); + await db.execute('ALTER TABLE vocabulary ADD COLUMN level INTEGER'); + } catch (_) { + // Ignore error, column might already exist + } + } }, ); @@ -98,6 +106,7 @@ class DeckRepository { 'kanji', { 'id': it.id, + 'level': it.level, 'characters': it.characters, 'meanings': it.meanings.join('|'), 'onyomi': it.onyomi.join('|'), @@ -115,6 +124,7 @@ class DeckRepository { final kanjiItems = rows .map((r) => KanjiItem( id: r['id'] as int, + level: r['level'] as int? ?? 0, characters: r['characters'] as String, meanings: (r['meanings'] as String) .split('|') @@ -272,6 +282,7 @@ class DeckRepository { 'vocabulary', { 'id': it.id, + 'level': it.level, 'characters': it.characters, 'meanings': it.meanings.join('|'), 'readings': it.readings.join('|'), @@ -305,6 +316,7 @@ class DeckRepository { } return VocabularyItem( id: r['id'] as int, + level: r['level'] as int? ?? 0, characters: r['characters'] as String, meanings: (r['meanings'] as String) .split('|') diff --git a/test/distractor_test.dart b/test/distractor_test.dart index 1aca54d..f9293c5 100644 --- a/test/distractor_test.dart +++ b/test/distractor_test.dart @@ -5,8 +5,8 @@ import 'package:wanikani_kanji_srs/src/models/kanji_item.dart'; void main() { test('meaning distractors include plausible items', () { final dg = DistractorGenerator(); - final correct = KanjiItem(id: 1, characters: '日', meanings: ['sun', 'day'], onyomi: ['にち'], kunyomi: ['ひ']); - final pool = [correct, KanjiItem(id:2, characters:'明', meanings:['bright','light'], onyomi:['めい'], kunyomi:['あか']), KanjiItem(id:3, characters:'曜', meanings:['weekday'], onyomi:['よう'], kunyomi:[])]; + final correct = KanjiItem(id: 1, level: 1, characters: '日', meanings: ['sun', 'day'], onyomi: ['にち'], kunyomi: ['ひ']); + final pool = [correct, KanjiItem(id:2, level: 1, characters:'明', meanings:['bright','light'], onyomi:['めい'], kunyomi:['あか']), KanjiItem(id:3, level: 1, characters:'曜', meanings:['weekday'], onyomi:['よう'], kunyomi:[])]; final d = dg.generateMeanings(correct, pool, 3); expect(d.length, 3); });