import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import '../../data/epub/reader_dao.dart'; import '../../services/epub/epub_service.dart'; import '../../utils/user_prefs.dart'; import '../../utils/responsive.dart'; import '../../utils/toast_util.dart'; import 'epub_detail_page.dart'; import 'widgets/book_grid_item.dart'; /// EPUB 书架页面 class EpubLibraryPage extends StatefulWidget { const EpubLibraryPage({super.key}); @override State createState() => _EpubLibraryPageState(); } class _EpubLibraryPageState extends State { final ReaderDao _dao = ReaderDao(); final EpubService _service = EpubService(); List> _books = []; List> _filteredBooks = []; bool _isLoading = true; bool _isSearching = false; final TextEditingController _searchCtrl = TextEditingController(); ViewMode _viewMode = UserPrefs().epubViewMode == 1 ? ViewMode.compact : ViewMode.relaxed; int _sortMode = UserPrefs().epubSortMode; @override void initState() { super.initState(); _loadBooks(); } Future _loadBooks() async { if (mounted) setState(() => _isLoading = true); final books = await _dao.getAllReaderBooks(sortMode: _sortMode); if (mounted) { setState(() { _books = books; _isLoading = false; _applyFilter(); }); } } void _applyFilter() { final query = _searchCtrl.text.trim().toLowerCase(); if (query.isEmpty) { _filteredBooks = _books; } else { _filteredBooks = _books.where((b) { final title = (b['title'] as String? ?? '').toLowerCase(); final author = (b['author'] as String? ?? '').toLowerCase(); return title.contains(query) || author.contains(query); }).toList(); } } void _onSearchChanged() { setState(() => _applyFilter()); } void _toggleSearch() { setState(() { _isSearching = !_isSearching; if (!_isSearching) { _searchCtrl.clear(); _applyFilter(); } }); } Future _pickAndImport() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['epub'], ); if (result == null || result.files.isEmpty) return; final path = result.files.single.path; if (path == null) return; if (!path.toLowerCase().endsWith('.epub')) { if (mounted) { ToastUtil.show(context, '\u4EC5\u652F\u6301\u5BFC\u5165 .epub \u683C\u5F0F\u7684\u6587\u4EF6'); } return; } if (!mounted) return; showDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), ); final imported = await _service.importBook(path); if (mounted) Navigator.pop(context); if (imported != null) { await _loadBooks(); } else if (mounted) { ToastUtil.show(context, 'EPUB \u89E3\u6790\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6'); } } Future _deleteBook(Map book) async { final colors = Theme.of(context).colorScheme; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: colors.surface, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text('删除书籍', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), content: Text('确定删除《${book['title']}》?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), actions: [ TextButton( style: TextButton.styleFrom( foregroundColor: colors.onSurface.withValues(alpha: 0.6), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), onPressed: () => Navigator.pop(ctx, true), child: const Text('删除'), ), ], ), ); if (confirm == true) { await _service.deleteBook(book['id']); await _loadBooks(); } } void _openBook(Map book) { Navigator.push( context, MaterialPageRoute( builder: (_) => EpubDetailPage(bookId: book['id'], book: book), ), ).then((_) => _loadBooks()); } void _toggleViewMode() { setState(() { _viewMode = _viewMode == ViewMode.relaxed ? ViewMode.compact : ViewMode.relaxed; }); UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0); } void _showSortMenu() { final colors = Theme.of(context).colorScheme; final options = [ (0, '按更新时间排序', Icons.update), (1, '按创建时间排序', Icons.calendar_today_outlined), (2, '按阅读进度排序', Icons.auto_stories_outlined), (3, '按书名排序', Icons.sort_by_alpha), ]; showModalBottomSheet( context: context, backgroundColor: colors.surface, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (ctx) => SafeArea( child: Column(mainAxisSize: MainAxisSize.min, children: [ Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), Align(alignment: Alignment.centerLeft, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 20), child: Text('书架排序', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), const SizedBox(height: 8), for (int i = 0; i < options.length; i++) ...[ if (i > 0) Divider(height: 0.5, indent: 20, endIndent: 20, color: colors.outlineVariant), _sortOption(ctx, options[i].$1, options[i].$2, options[i].$3, colors), ], const SizedBox(height: 12), ]), ), ); } Widget _sortOption(BuildContext ctx, int value, String label, IconData icon, ColorScheme colors) { final selected = _sortMode == value; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20), leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: selected ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), title: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)), trailing: selected ? Icon(Icons.check, size: 20, color: colors.primary) : null, onTap: () { Navigator.pop(ctx); if (_sortMode != value) { setState(() => _sortMode = value); UserPrefs().setEpubSortMode(value); _loadBooks(); } }, ); } @override void dispose() { _searchCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final isWin = Platform.isWindows; return Scaffold( backgroundColor: colors.surface, appBar: isWin ? null : AppBar( backgroundColor: colors.surface, elevation: 0, title: _isSearching ? TextField( controller: _searchCtrl, autofocus: true, style: TextStyle(fontSize: 16, color: colors.onSurface), decoration: InputDecoration( hintText: '搜索书名或作者', hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.35)), border: InputBorder.none, ), onChanged: (_) => _onSearchChanged(), ) : Text('EPUB 阅读', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), leading: IconButton( icon: Icon(_isSearching ? Icons.close : Icons.arrow_back, size: 20), onPressed: _isSearching ? _toggleSearch : () => Navigator.pop(context), ), actions: _buildActions(colors), ), body: Column(children: [ // Windows: 自定义顶栏 if (isWin) Container( height: 52, decoration: BoxDecoration(color: colors.surface, border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))), child: Row(children: [ const SizedBox(width: 8), IconButton(icon: Icon(_isSearching ? Icons.close : Icons.arrow_back, color: colors.onSurface, size: 18), onPressed: _isSearching ? _toggleSearch : () => Navigator.pop(context)), Expanded(child: _isSearching ? TextField(controller: _searchCtrl, autofocus: true, style: TextStyle(fontSize: 14, color: colors.onSurface), decoration: InputDecoration(hintText: '搜索书名或作者', hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero), onChanged: (_) => _onSearchChanged()) : Text('EPUB 阅读', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6)))), ..._buildActions(colors), ]), ), // 主体 Expanded(child: _isLoading ? Center(child: CircularProgressIndicator(color: colors.primary)) : _books.isEmpty ? _buildEmpty(colors) : _filteredBooks.isEmpty ? Center(child: Text('无搜索结果', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)))) : _buildGrid(colors)), ]), ); } List _buildActions(ColorScheme colors) { return [ if (!_isSearching) IconButton( icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), onPressed: _toggleSearch, ), IconButton( icon: Icon( _viewMode == ViewMode.relaxed ? Icons.view_compact_outlined : Icons.view_agenda_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6), ), onPressed: _toggleViewMode, ), IconButton( icon: Icon(Icons.sort, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), onPressed: _showSortMenu, ), IconButton( icon: Icon(Icons.add_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), onPressed: _pickAndImport, ), const SizedBox(width: 4), ]; } Widget _buildEmpty(ColorScheme colors) { return Center( child: Padding( padding: const EdgeInsets.all(40), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Icon(Icons.auto_stories_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), ), const SizedBox(height: 24), Text('EPUB 阅读', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: colors.onSurface)), const SizedBox(height: 8), Text('点击右上角导入 .epub 文件', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), const SizedBox(height: 32), GestureDetector( onTap: _pickAndImport, child: Container( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), decoration: BoxDecoration( color: colors.primary, borderRadius: BorderRadius.circular(24), ), child: Text('导入 EPUB', style: TextStyle(fontSize: 15, color: colors.onPrimary, fontWeight: FontWeight.w500)), ), ), ], ), ), ); } Widget _buildGrid(ColorScheme colors) { final bool showList = _viewMode == ViewMode.compact; if (showList) { return _buildListView(colors); } return _buildGridView(colors); } Widget _buildGridView(ColorScheme colors) { return LayoutBuilder( builder: (context, constraints) { final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 120); return GridView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: count, crossAxisSpacing: 12, mainAxisSpacing: 16, childAspectRatio: 0.55, ), itemCount: _filteredBooks.length, itemBuilder: (context, index) { final book = _filteredBooks[index]; return BookGridItem( book: book, viewMode: ViewMode.relaxed, onTap: () => _openBook(book), onLongPress: () => _deleteBook(book), ); }, ); }, ); } Widget _buildListView(ColorScheme colors) { return ListView.builder( padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: _filteredBooks.length, itemBuilder: (context, index) { final book = _filteredBooks[index]; final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; final coverPath = book['cover_path'] as String?; final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0; // 阅读状态推断 final String statusLabel; final Color statusColor; if (progress >= 1.0) { statusLabel = '已读'; statusColor = const Color(0xFF16A34A); } else if (progress > 0.0) { statusLabel = '在读'; statusColor = colors.primary; } else { statusLabel = '未读'; statusColor = const Color(0xFFDC2626); } return GestureDetector( onTap: () => _openBook(book), onLongPress: () => _deleteBook(book), child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ // 封面 Container( width: 48, height: 64, decoration: BoxDecoration( color: colors.outlineVariant, borderRadius: BorderRadius.circular(6), ), clipBehavior: Clip.antiAlias, child: _buildCover(coverPath, colors), ), const SizedBox(width: 12), // 信息 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), if (author.isNotEmpty) ...[ const SizedBox(height: 3), Text(author, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], const SizedBox(height: 6), // 状态标签 Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text(statusLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: statusColor)), ), ], ), ), const SizedBox(width: 8), Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), ], ), ), ); }, ); } Widget _buildCover(String? path, ColorScheme colors) { if (path != null && path.isNotEmpty && File(path).existsSync()) { return ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.file(File(path), fit: BoxFit.cover, width: double.infinity, height: double.infinity), ); } return Container( decoration: BoxDecoration( color: colors.outlineVariant, borderRadius: BorderRadius.circular(6), ), child: Icon(Icons.auto_stories_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), ); } }