import 'dart:io' show Platform; 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/toast_util.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../widgets/shimmer_skeleton.dart'; import 'epub_detail_page.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(); 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((_) { if (mounted) _loadBooks(); }); } 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('阅读', 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('阅读', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6)))), ..._buildActions(colors), ]), ), // 主体 Expanded(child: _isLoading && _books.isEmpty ? _buildSkeletonList(colors) : _books.isEmpty ? _buildEmpty(colors) : _filteredBooks.isEmpty ? Center(child: Text('无搜索结果', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)))) : RefreshIndicator( color: colors.primary, onRefresh: _loadBooks, child: _buildContent(colors), )), ]), ); } /// 主体内容:书架列表 Widget _buildContent(ColorScheme colors) { return CustomScrollView( slivers: [ // 书架分隔标题 SliverToBoxAdapter(child: _buildSectionHeader(colors)), // 书架列表 _buildSliverListView(colors), ], ); } /// 书架分隔标题(带计数) Widget _buildSectionHeader(ColorScheme colors) { return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Text( '书架 (${_filteredBooks.length})', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.4), ), ), ); } 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(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('阅读', 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)), ), ), ], ), ), ); } // ─── Sliver 书架 ──────────────────────────────────────────────────── Widget _buildSliverListView(ColorScheme colors) { return SliverPadding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildListItem(colors, _filteredBooks[index]), childCount: _filteredBooks.length, ), ), ); } /// 加载骨架列表 Widget _buildSkeletonList(ColorScheme colors) { return ListView.builder( padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: 8, itemBuilder: (_, __) => Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ const ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: const [ ShimmerSkeleton(width: double.infinity, height: 15), SizedBox(height: 8), ShimmerSkeleton(width: 120, height: 12), SizedBox(height: 10), ShimmerSkeleton(width: 60, height: 11), ], ), ), ], ), ), ); } Widget _buildListItem(ColorScheme colors, Map book) { 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 updatedAt = book['updated_at'] as String?; // 阅读状态推断 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); } // 最后阅读时间 String? lastReadText; if (updatedAt != null && updatedAt.isNotEmpty) { try { final dt = DateTime.parse(updatedAt); lastReadText = _formatRelativeDate(dt); } catch (_) {} } return RepaintBoundary( child: 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), // 状态标签 + 最后阅读时间 Row( children: [ 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)), ), if (lastReadText != null) ...[ const SizedBox(width: 8), Text(lastReadText, style: TextStyle( fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3), )), ], ], ), // 进度条 if (progress > 0 && progress < 1.0) ...[ const SizedBox(height: 6), Row( children: [ Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: progress, minHeight: 3, backgroundColor: colors.outlineVariant, valueColor: AlwaysStoppedAnimation(colors.primary), ), ), ), const SizedBox(width: 8), Text('${(progress * 100).toInt()}%', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary, )), ], ), ], ], ), ), const SizedBox(width: 8), Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), ], ), ), ), ); } Widget _buildCover(String? path, ColorScheme colors) { final placeholder = 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)), ); return ClipRRect( borderRadius: BorderRadius.circular(6), child: FadeInLocalImage( path: path, fit: BoxFit.cover, width: double.infinity, height: double.infinity, placeholder: placeholder, errorWidget: placeholder, ), ); } /// 相对时间格式化 String _formatRelativeDate(DateTime date) { final now = DateTime.now(); final diff = now.difference(date); if (diff.isNegative) return '${date.month}月${date.day}日'; if (diff.inDays == 0) { if (diff.inHours == 0) { if (diff.inMinutes == 0) return '刚刚'; return '${diff.inMinutes}分钟前'; } return '${diff.inHours}小时前'; } else if (diff.inDays < 7) { return '${diff.inDays}天前'; } else if (diff.inDays < 30) { return '${(diff.inDays / 7).floor()}周前'; } else { return '${date.month}月${date.day}日'; } } }