From 295b4706501737e052892d7b62cc3dea0bd19ae0 Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Tue, 28 Oct 2025 19:18:07 +0100 Subject: [PATCH] add overview browser for kanji and vocabularies with progress --- lib/src/screens/browse_screen.dart | 297 +++++++++++++++++++++++++++++ lib/src/screens/start_screen.dart | 20 ++ 2 files changed, 317 insertions(+) create mode 100644 lib/src/screens/browse_screen.dart diff --git a/lib/src/screens/browse_screen.dart b/lib/src/screens/browse_screen.dart new file mode 100644 index 0000000..e730936 --- /dev/null +++ b/lib/src/screens/browse_screen.dart @@ -0,0 +1,297 @@ + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/kanji_item.dart'; +import '../services/deck_repository.dart'; + +class BrowseScreen extends StatefulWidget { + const BrowseScreen({super.key}); + + @override + State createState() => _BrowseScreenState(); +} + +class _BrowseScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + List _kanjiDeck = []; + List _vocabDeck = []; + bool _loading = true; + String _status = 'Loading...'; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _loadDecks(); + } + + Future _loadDecks() async { + setState(() { + _loading = true; + _status = 'Loading decks...'; + }); + + 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; + }); + return; + } + + var kanji = await repo.loadKanji(); + if (kanji.isEmpty) { + setState(() => _status = 'Fetching kanji from WaniKani...'); + kanji = await repo.fetchAndCacheFromWk(apiKey); + } + + var vocab = await repo.loadVocabulary(); + if (vocab.isEmpty) { + setState(() => _status = 'Fetching vocabulary from WaniKani...'); + vocab = await repo.fetchAndCacheVocabularyFromWk(apiKey); + } + + setState(() { + _kanjiDeck = kanji; + _vocabDeck = vocab; + _loading = false; + _status = 'Loaded ${_kanjiDeck.length} kanji and ${_vocabDeck.length} vocabulary.'; + }); + } catch (e) { + setState(() { + _status = 'Error: $e'; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF121212), + appBar: AppBar( + title: const Text('Browse Items'), + backgroundColor: const Color(0xFF1F1F1F), + foregroundColor: Colors.white, + ), + 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: [ + _buildGridView(_kanjiDeck), + _buildListView(_vocabDeck), + ], + ), + ), + ], + ), + bottomNavigationBar: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Kanji'), + Tab(text: 'Vocabulary'), + ], + labelColor: Colors.blueAccent, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.blueAccent, + ), + ); + } + + Widget _buildGridView(List 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]; + 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(); + }, + padding: const EdgeInsets.all(8), + ); + } + + Widget _buildListView(List items) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _buildVocabListTile(item); + }, + ); + } + + 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; + + 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), + ), + ), + 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()), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildSrsItemCard(String characters, List srsItems) { + final avgSrsStage = srsItems.isNotEmpty + ? srsItems.map((s) => s.srsStage).reduce((a, b) => a + b) / srsItems.length + : 0.0; + + return Card( + color: const Color(0xFF1E1E1E), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + characters, + style: const TextStyle(fontSize: 32, color: Colors.white), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + _buildSrsIndicator(avgSrsStage.round()), + ], + ), + ), + ); + } + + 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, // Max SRS level is 9 + backgroundColor: Colors.grey[800], + valueColor: AlwaysStoppedAnimation( + _getColorForSrsLevel(level), + ), + ), + ), + ), + ); + } + + Color _getColorForSrsLevel(int level) { + if (level >= 9) return Colors.purple; + if (level >= 8) return Colors.blue; + if (level >= 7) return Colors.lightBlue; + if (level >= 5) return Colors.green; + if (level >= 3) return Colors.yellow; + if (level >= 1) return Colors.orange; + return Colors.red; + } + + void _showReadingsDialog(KanjiItem kanji) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: Text( + 'Readings for ${kanji.characters}', + style: const TextStyle(color: Colors.white), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close', style: TextStyle(color: Colors.blueAccent)), + ), + ], + ); + }, + ); + }} diff --git a/lib/src/screens/start_screen.dart b/lib/src/screens/start_screen.dart index 3eda768..02147e2 100644 --- a/lib/src/screens/start_screen.dart +++ b/lib/src/screens/start_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/deck_repository.dart'; +import 'browse_screen.dart'; import 'home_screen.dart'; import 'vocab_screen.dart'; @@ -111,6 +112,25 @@ class _StartScreenState extends State { style: TextStyle(fontSize: 18), ), ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const BrowseScreen()), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurpleAccent, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: const Text( + 'Browse Items', + style: TextStyle(fontSize: 18), + ), + ), ], ), ),