From bcff4f6c01e0a788ec49f6e74936c77517331f0e Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 27 Jun 2026 18:13:34 +0800 Subject: [PATCH] =?UTF-8?q?epub=E9=98=85=E8=AF=BB+=E5=85=B3=E8=81=94?= =?UTF-8?q?=E4=B9=A6=E7=B1=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 +- lib/pages/book/book_detail_page.dart | 66 +++ lib/pages/epub_reader/book_link_page.dart | 188 ++++++++ lib/pages/epub_reader/book_session.dart | 69 ++- lib/pages/epub_reader/epub_detail_page.dart | 420 ++++++++++++++++++ lib/pages/epub_reader/epub_library_page.dart | 169 ++++--- .../mixins/spine_navigation_mixin.dart | 27 +- lib/pages/epub_reader/reader_screen.dart | 18 +- lib/pages/epub_reader/toc_drawer.dart | 6 +- .../epub_reader/widgets/book_grid_item.dart | 212 +++++++++ lib/utils/database_helper.dart | 10 +- lib/utils/epub/epub_parser.dart | 7 +- lib/utils/epub/reader_dao.dart | 36 ++ lib/utils/epub/reader_scripts.dart | 6 +- lib/utils/user_prefs.dart | 4 + 15 files changed, 1143 insertions(+), 124 deletions(-) create mode 100644 lib/pages/epub_reader/book_link_page.dart create mode 100644 lib/pages/epub_reader/epub_detail_page.dart create mode 100644 lib/pages/epub_reader/widgets/book_grid_item.dart diff --git a/README.md b/README.md index ba2bcb0..c68b18a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC ## 应用预览 | 影视列表 | 书籍列表 | 笔记界面 | -| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: | -| ![1782484732881](image/README/1782484732881.png) | ![1782484753882](image/README/1782484753882.png) | ![1782484769014](image/README/1782484769014.png) | +| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | +| ![1782484732881](image/README/1782484732881.png) | ![1782484753882](image/README/1782484753882.png) | ![1782484769014](image/README/1782484769014.png) | 更多预览图片请到**应用功能****详情预览**里面查看 @@ -32,6 +32,7 @@ http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC - 书摘 / 摘录记录 - 书籍分享卡片 - 书籍状态筛选 +- Epub阅读功能 ### 笔记管理 @@ -149,21 +150,27 @@ flutter build appbundle --release ## 应用功能详情预览 | 数据统计 | 侧边栏 | 我的界面 | -| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: | -| ![1782484806542](image/README/1782484806542.png) | ![1782484815573](image/README/1782484815573.png) | ![1782484824223](image/README/1782484824223.png) | +| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | +| ![1782484806542](image/README/1782484806542.png) | ![1782484815573](image/README/1782484815573.png) | ![1782484824223](image/README/1782484824223.png) | | 详情界面1 | 详情界面2 | 编辑笔记 | -| :--------------------------------------------: | :--------------------------------------------: | :---------------------------------------------: | -| ![1782484832641](image/README/1782484832641.png) | ![1782484836809](image/README/1782484836809.png) | ![1782484844066](image/README/1782484844066.png) | +| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | +| ![1782484832641](image/README/1782484832641.png) | ![1782484836809](image/README/1782484836809.png) | ![1782484844066](image/README/1782484844066.png) | -| 分享界面1 | 分享界面2 | 预览笔记 | -| :---------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | -| ![1782484860728](image/README/1782484860728.png) | ![1782484855884](image/README/1782484855884.png) | ![1782484849659](image/README/1782484849659.png) | +| 分享界面1 | 分享界面2 | 预览笔记 | +| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: | +| ![1782484860728](image/README/1782484860728.png) | ![1782484855884](image/README/1782484855884.png) | ![1782484849659](image/README/1782484849659.png) | -## 数据对接 +## 影视书籍资源数据对接 -数据已对接6w+影视基础数据,以及300w+书籍基础信息,如需对接接口,或技术交流请联系作者。 +数据已对接6w+影视基础数据,以及300w+书籍基础信息,如需对接接口,或技术交流请联系作者。所有影视书籍来源皆为网络资源收集,部分数据可能会有偏差,如数据有问题,也请联系开发者及时修复 ## 开源协议 本项目采用 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) 开源协议。 + +## 致谢 + +**[lumina](https://github.com/MilkFeng/lumina)** + +**[NLCISBNPlugin](https://github.com/DoiiarX/NLCISBNPlugin)** diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 0469c65..da14cd1 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -13,6 +13,8 @@ import '../../utils/user_prefs.dart'; import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; +import '../../utils/epub/reader_dao.dart'; +import '../epub_reader/reader_screen.dart'; /// 书籍详情页 - 极简主义设计 class BookDetailPage extends StatefulWidget { @@ -280,6 +282,8 @@ class _BookDetailPageState extends State { foregroundColor: colors.onError, ), const SizedBox(height: 12), + _buildEpubReadButton(book), + const SizedBox(height: 12), _buildFloatingButton( icon: Icons.share_outlined, onPressed: () => _showSharePoster(book), @@ -291,6 +295,39 @@ class _BookDetailPageState extends State { ); } + /// EPUB 阅读悬浮按钮(仅有关联 EPUB 时显示) + Widget _buildEpubReadButton(Book book) { + return FutureBuilder?>( + future: ReaderDao().getReaderBookByBookId(book.id), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const SizedBox.shrink(); + } + final readerBook = snapshot.data!; + return _buildFloatingButton( + icon: Icons.auto_stories_outlined, + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReaderScreen( + bookId: readerBook['id'] as String, + filePath: readerBook['file_path'] as String, + title: readerBook['title'] as String? ?? '', + coverPath: readerBook['cover_path'] as String?, + bookData: readerBook, + ), + ), + ); + }, + tooltip: 'EPUB 阅读', + backgroundColor: const Color(0xFF6750A4), + foregroundColor: Colors.white, + ); + }, + ); + } + Widget _buildFloatingButton({ required IconData icon, required VoidCallback onPressed, @@ -549,6 +586,32 @@ class _BookDetailPageState extends State { ); } + /// EPUB 阅读进度条(仅有关联 EPUB 时显示) + Widget _buildEpubProgressBar(Book book) { + final colors = Theme.of(context).colorScheme; + return FutureBuilder?>( + future: ReaderDao().getReaderBookByBookId(book.id), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const SizedBox.shrink(); + } + final progress = (snapshot.data!['reading_percentage'] as num?)?.toDouble() ?? 0.0; + if (progress <= 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + backgroundColor: colors.surfaceContainerHighest, + ), + ), + ); + }, + ); + } + Widget _buildBasicInfo(Book book) { final colors = Theme.of(context).colorScheme; return Padding( @@ -576,6 +639,8 @@ class _BookDetailPageState extends State { ), ), ], + // EPUB 阅读进度条 + _buildEpubProgressBar(book), const SizedBox(height: 16), Row( children: [ @@ -946,6 +1011,7 @@ class _BookDetailPageState extends State { ); } + /// EPUB 阅读入口(通过关联的 reader_books) /// 叠层模式:书评、摘抄各自独立毛玻璃卡片 Widget _buildExtraSectionsOverlay(Book book) { return Padding( diff --git a/lib/pages/epub_reader/book_link_page.dart b/lib/pages/epub_reader/book_link_page.dart new file mode 100644 index 0000000..1ada310 --- /dev/null +++ b/lib/pages/epub_reader/book_link_page.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import '../../utils/book/book_dao.dart'; +import '../../utils/epub/reader_dao.dart'; +import '../../models/data_models.dart'; + +/// 选择关联书籍页面(带搜索功能) +class BookLinkPage extends StatefulWidget { + final String readerBookId; + + const BookLinkPage({super.key, required this.readerBookId}); + + @override + State createState() => _BookLinkPageState(); +} + +class _BookLinkPageState extends State { + final BookDao _bookDao = BookDao(); + final ReaderDao _readerDao = ReaderDao(); + final TextEditingController _searchCtrl = TextEditingController(); + + List _allBooks = []; + List _filteredBooks = []; + bool _isLoading = true; + String _query = ''; + + @override + void initState() { + super.initState(); + _loadBooks(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _loadBooks() async { + final books = await _bookDao.getAllBooks(); + if (mounted) { + setState(() { + _allBooks = books; + _filteredBooks = books; + _isLoading = false; + }); + } + } + + void _onSearch(String query) { + _query = query.trim().toLowerCase(); + if (_query.isEmpty) { + setState(() => _filteredBooks = _allBooks); + return; + } + setState(() { + _filteredBooks = _allBooks.where((b) { + return b.title.toLowerCase().contains(_query) || + b.authors.any((a) => a.toLowerCase().contains(_query)); + }).toList(); + }); + } + + Future _selectBook(Book book) async { + await _readerDao.linkToBook(widget.readerBookId, book.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已关联《${book.title}》')), + ); + Navigator.pop(context, true); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + title: Text('关联书籍', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => Navigator.pop(context), + ), + ), + body: Column( + children: [ + // 搜索栏 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: TextField( + controller: _searchCtrl, + onChanged: _onSearch, + decoration: InputDecoration( + hintText: '搜索书名或作者...', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), + prefixIcon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.4)), + suffixIcon: _query.isNotEmpty + ? IconButton( + icon: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + onPressed: () { + _searchCtrl.clear(); + _onSearch(''); + }, + ) + : null, + filled: true, + fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + style: const TextStyle(fontSize: 14), + ), + ), + + // 书籍列表 + Expanded( + child: _isLoading + ? Center(child: CircularProgressIndicator(color: colors.primary)) + : _filteredBooks.isEmpty + ? Center( + child: Text( + _query.isEmpty ? '暂无书籍' : '未找到匹配的书籍', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)), + ), + ) + : ListView.separated( + itemCount: _filteredBooks.length, + separatorBuilder: (_, __) => + Divider(height: 0.5, indent: 72, color: colors.outlineVariant), + itemBuilder: (_, index) => _buildBookTile(_filteredBooks[index], colors), + ), + ), + ], + ), + ); + } + + Widget _buildBookTile(Book book, ColorScheme colors) { + final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; + + return InkWell( + onTap: () => _selectBook(book), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + // 封面缩略图 + Container( + width: 44, height: 62, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + clipBehavior: Clip.antiAlias, + child: hasCover + ? Image.asset(book.coverPath!, fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2))) + : Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)), + ), + const SizedBox(width: 14), + // 书名 + 作者 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)), + if (book.authors.isNotEmpty) ...[ + const SizedBox(height: 3), + Text(book.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ], + ), + ), + Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), + ]), + ), + ); + } +} diff --git a/lib/pages/epub_reader/book_session.dart b/lib/pages/epub_reader/book_session.dart index 0643504..86b2041 100644 --- a/lib/pages/epub_reader/book_session.dart +++ b/lib/pages/epub_reader/book_session.dart @@ -37,6 +37,38 @@ class BookSession { _debounceTimer = null; } + /// 立即保存进度(不做 debounce,用于退出时调用) + Future flushProgress({ + required int currentChapterIndex, + required int currentPageInChapter, + required int totalPagesInChapter, + }) async { + _debounceTimer?.cancel(); + _debounceTimer = null; + + var progress = 0.0; + if (_spine.isNotEmpty) { + progress = (currentChapterIndex + 1) / _spine.length; + if (totalPagesInChapter > 0) { + final delta = 1.0 / _spine.length; + progress -= delta; + progress += delta * ((currentPageInChapter + 1) / totalPagesInChapter); + } + } + + // 保存格式: "chapterIndex:pageRatio" + final pageRatio = totalPagesInChapter > 0 + ? (currentPageInChapter / totalPagesInChapter).toStringAsFixed(4) + : '0'; + final cfi = '$currentChapterIndex:$pageRatio'; + + await _readerDao.updateReadingProgress( + fileHash, + cfi, + progress, + ); + } + /// Update EPUB info after parsing (called when session is created before parse) void updateEpubInfo(EpubBookInfo info) { epubInfo = info; @@ -98,13 +130,18 @@ class BookSession { } // Build fallback: for each spine item, pick the nearest preceding TOC entry - TocEntry? fallback; + // 初始 fallback 为书名(确保每个 spine 都有对应的 TOC 条目) + TocEntry fallback = TocEntry( + label: bookData['title'] as String? ?? '', + href: '', + spineIndex: -1, + ); _tocItemFallback.clear(); - for (final spineItem in _spine) { - if (fallback != null) _tocItemFallback.add(fallback); - final anchors = _spineToAnchorsMap[spineItem.href] ?? []; + for (int i = 0; i < _spine.length; i++) { + _tocItemFallback.add(fallback); + final anchors = _spineToAnchorsMap[_spine[i].href] ?? []; if (anchors.isNotEmpty) { - final lastHref = '${spineItem.href}#${anchors.last}'; + final lastHref = '${_spine[i].href}#${anchors.last}'; final idx = _hrefToTocIndexMap[lastHref]; if (idx != null) { fallback = _flatToc[idx]; @@ -135,9 +172,15 @@ class BookSession { } } + // 保存格式: "chapterIndex:pageRatio" + final pageRatio = totalPagesInChapter > 0 + ? (currentPageInChapter / totalPagesInChapter).toStringAsFixed(4) + : '0'; + final cfi = '$currentChapterIndex:$pageRatio'; + await _readerDao.updateReadingProgress( fileHash, - '$currentChapterIndex', + cfi, progress, ); }); @@ -255,12 +298,20 @@ class BookSession { // ─── Initial position (from saved state) ────────────────────────── /// The last-read chapter index stored in the book record. + /// last_read_cfi 格式: "chapterIndex" 或 "chapterIndex:pageRatio" int get initialChapterIndex { final cfi = bookData['last_read_cfi'] as String? ?? ''; if (cfi.isEmpty) return 0; - return int.tryParse(cfi) ?? 0; + final parts = cfi.split(':'); + return int.tryParse(parts[0]) ?? 0; } - /// No scroll-position column in reader_books yet; return null. - double? get initialScrollPosition => null; + /// 页内滚动位置比例 (0.0~1.0),从 last_read_cfi 解析 + double? get initialScrollPosition { + final cfi = bookData['last_read_cfi'] as String? ?? ''; + if (cfi.isEmpty) return null; + final parts = cfi.split(':'); + if (parts.length < 2) return null; + return double.tryParse(parts[1]); + } } diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart new file mode 100644 index 0000000..016f8b2 --- /dev/null +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -0,0 +1,420 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../utils/epub/reader_dao.dart'; +import '../../utils/epub/epub_parser.dart'; +import '../../utils/epub/reader_models.dart'; +import '../../utils/book/book_dao.dart'; +import '../../models/data_models.dart'; +import 'book_link_page.dart'; +import 'reader_screen.dart'; + +/// EPUB 书籍详情页 +class EpubDetailPage extends StatefulWidget { + final String bookId; + final Map book; + + const EpubDetailPage({ + super.key, + required this.bookId, + required this.book, + }); + + @override + State createState() => _EpubDetailPageState(); +} + +class _EpubDetailPageState extends State { + final ReaderDao _dao = ReaderDao(); + final BookDao _bookDao = BookDao(); + final EpubParser _parser = EpubParser(); + + late Map _book; + EpubBookInfo? _bookInfo; + bool _descriptionExpanded = false; + + @override + void initState() { + super.initState(); + _book = widget.book; + _loadBookInfo(); + } + + Future _loadBookInfo() async { + final filePath = _book['file_path'] as String?; + if (filePath == null || filePath.isEmpty) return; + final info = await _parser.parseFromFile(filePath); + if (mounted && info != null) setState(() => _bookInfo = info); + } + + Future _refreshBook() async { + final updated = await _dao.getReaderBookById(widget.bookId); + if (mounted && updated != null) setState(() => _book = updated); + } + + void _navigateToReader() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReaderScreen( + bookId: _book['id'] as String, + filePath: _book['file_path'] as String, + title: _book['title'] as String? ?? '', + coverPath: _book['cover_path'] as String?, + bookData: _book, + ), + ), + ).then((_) => _refreshBook()); + } + + // ─── 编辑对话框 ───────────────────────────────────────────── + + void _showEditDialog() { + final titleCtrl = TextEditingController(text: _book['title'] as String? ?? ''); + final authorCtrl = TextEditingController(text: _book['author'] as String? ?? ''); + final colors = Theme.of(context).colorScheme; + + 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: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleCtrl, + decoration: InputDecoration( + labelText: '标题', + labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 12), + TextField( + controller: authorCtrl, + decoration: InputDecoration( + labelText: '作者', + labelStyle: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + style: const TextStyle(fontSize: 14), + ), + ], + ), + 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), + child: const Text('取消'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + onPressed: () async { + final newTitle = titleCtrl.text.trim(); + final newAuthor = authorCtrl.text.trim(); + if (newTitle.isEmpty) return; + await _dao.updateReaderBook(widget.bookId, { + 'title': newTitle, + 'author': newAuthor, + 'updated_at': DateTime.now().toIso8601String(), + }); + if (ctx.mounted) Navigator.pop(ctx); + await _refreshBook(); + }, + child: const Text('保存'), + ), + ], + ), + ); + } + + // ─── Build ────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final progress = (_book['reading_percentage'] as num?)?.toDouble() ?? 0.0; + final title = _book['title'] as String? ?? ''; + final author = _book['author'] as String? ?? ''; + final coverPath = _book['cover_path'] as String?; + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: Icon(Icons.edit_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + onPressed: _showEditDialog, + ), + const SizedBox(width: 4), + ], + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 封面 + 基本信息(横向布局)── + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面 + GestureDetector( + onTap: _navigateToReader, + child: SizedBox( + width: 110, height: 154, + child: _buildCover(coverPath, colors), + ), + ), + const SizedBox(width: 16), + // 信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, maxLines: 3, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, + color: colors.onSurface, height: 1.3)), + if (author.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(author, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.5))), + ], + // 元数据标签 + if (_bookInfo != null) ...[ + const SizedBox(height: 10), + Wrap(spacing: 6, runSpacing: 6, children: [ + _buildTag('${_bookInfo!.spine.length}章', colors), + _buildTag('EPUB ${_bookInfo!.epubVersion}', colors), + ]), + ], + const SizedBox(height: 12), + // 进度 + Row(children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress > 0 ? progress : 0, + minHeight: 3, + backgroundColor: colors.surfaceContainerHighest, + ), + ), + ), + const SizedBox(width: 8), + Text(progress > 0 ? '${(progress * 100).toInt()}%' : '未开始', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + ], + ), + ), + ], + ), + ), + + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + + // ── 描述 ── + if (_bookInfo?.description != null && _bookInfo!.description!.isNotEmpty) ...[ + _buildSectionHeader('简介', colors), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GestureDetector( + onTap: () => setState(() => _descriptionExpanded = !_descriptionExpanded), + child: Text(_stripHtmlTags(_bookInfo!.description!), + maxLines: _descriptionExpanded ? null : 4, + overflow: _descriptionExpanded ? null : TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, height: 1.7, color: colors.onSurface)), + ), + ), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + ], + + // ── 关联书籍 ── + _buildSectionHeader('关联书籍', colors), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: _buildLinkedBookCard(colors), + ), + ], + ), + ), + bottomNavigationBar: Container( + padding: EdgeInsets.fromLTRB(16, 12, 16, 12 + MediaQuery.of(context).padding.bottom), + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: SizedBox( + height: 48, + width: double.infinity, + child: FilledButton( + onPressed: _navigateToReader, + style: FilledButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + progress > 0 ? '继续阅读' : '开始阅读', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onPrimary), + ), + ), + ), + ), + ); + } + + // ─── 构建组件 ──────────────────────────────────────────────── + + Widget _buildSectionHeader(String title, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Row(children: [ + Container(width: 4, height: 14, + decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + ); + } + + Widget _buildTag(String label, ColorScheme colors) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ); + } + + Widget _buildLinkedBookCard(ColorScheme colors) { + final linkedBookId = _book['book_id'] as String? ?? ''; + if (linkedBookId.isEmpty) { + return InkWell( + onTap: _navigateToLinkPage, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Row(children: [ + Icon(Icons.link_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 10), + Expanded( + child: Text('选择关联书籍', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))), + ), + Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), + ]), + ), + ); + } + + return FutureBuilder( + future: _bookDao.getBookById(linkedBookId), + builder: (context, snapshot) { + final linkedTitle = snapshot.data?.title ?? '未知书籍'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Row(children: [ + Icon(Icons.link_outlined, size: 18, color: colors.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('已关联', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + Text(linkedTitle, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), + ], + ), + ), + GestureDetector( + onTap: _unlinkBook, + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ]), + ); + }, + ); + } + + Future _navigateToLinkPage() async { + final linked = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => BookLinkPage(readerBookId: widget.bookId)), + ); + if (linked == true && mounted) await _refreshBook(); + } + + Future _unlinkBook() async { + await _dao.unlinkBook(widget.bookId); + await _refreshBook(); + } + + Widget _buildCover(String? coverPath, ColorScheme colors) { + if (coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync()) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.file(File(coverPath), fit: BoxFit.cover, + width: double.infinity, height: double.infinity), + ); + } + return Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(Icons.auto_stories_outlined, size: 36, color: colors.onSurface.withValues(alpha: 0.2)), + ); + } + + static String _stripHtmlTags(String? htmlContent) { + if (htmlContent == null || htmlContent.isEmpty) return ''; + String text = htmlContent; + text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n\n'); + text = text.replaceAll(RegExp(r']*>', caseSensitive: false), '• '); + text = text.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + text = text.replaceAll(RegExp(r'<[^>]*>'), ''); + text = text + .replaceAll(' ', ' ').replaceAll('<', '<').replaceAll('>', '>') + .replaceAll('&', '&').replaceAll('"', '"').replaceAll(''', "'") + .replaceAll(''', "'").replaceAll('—', '—').replaceAll('–', '–'); + text = text.replaceAll(RegExp(r'[ \t]+'), ' '); + text = text.replaceAll(RegExp(r'\n\s*\n+'), '\n\n'); + return text.trim(); + } +} diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index dadde6b..c4ed3a6 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -1,9 +1,10 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/epub_service.dart'; -import 'reader_screen.dart'; +import '../../utils/user_prefs.dart'; +import 'epub_detail_page.dart'; +import 'widgets/book_grid_item.dart'; /// EPUB 书架页面 class EpubLibraryPage extends StatefulWidget { @@ -18,6 +19,9 @@ class _EpubLibraryPageState extends State { final EpubService _service = EpubService(); List> _books = []; bool _isLoading = true; + ViewMode _viewMode = UserPrefs().epubViewMode == 1 + ? ViewMode.compact + : ViewMode.relaxed; @override void initState() { @@ -26,7 +30,7 @@ class _EpubLibraryPageState extends State { } Future _loadBooks() async { - setState(() => _isLoading = true); + if (mounted) setState(() => _isLoading = true); final books = await _dao.getAllReaderBooks(); if (mounted) { setState(() { @@ -54,7 +58,7 @@ class _EpubLibraryPageState extends State { final imported = await _service.importBook(path); - if (mounted) Navigator.pop(context); // 关闭 loading + if (mounted) Navigator.pop(context); if (imported != null) { await _loadBooks(); @@ -70,16 +74,33 @@ class _EpubLibraryPageState extends State { final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('删除书籍', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - content: Text('确定删除《${book['title']}》?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + 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: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))), + child: const Text('取消'), ), - TextButton( + 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: Text('删除', style: TextStyle(color: colors.error)), + child: const Text('删除'), ), ], ), @@ -94,14 +115,17 @@ class _EpubLibraryPageState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => ReaderScreen( - bookId: book['id'], - filePath: book['file_path'], - title: book['title'], - coverPath: book['cover_path'], - ), + builder: (_) => EpubDetailPage(bookId: book['id'], book: book), ), - ).then((_) => _loadBooks()); // 返回时刷新进度 + ).then((_) => _loadBooks()); + } + + void _toggleViewMode() { + setState(() { + _viewMode = + _viewMode == ViewMode.relaxed ? ViewMode.compact : ViewMode.relaxed; + }); + UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0); } @override @@ -115,12 +139,22 @@ class _EpubLibraryPageState extends State { title: Text('EPUB 阅读', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)), leading: IconButton( - icon: Icon(Icons.arrow_back, color: colors.onSurface), + icon: const Icon(Icons.arrow_back, size: 20), onPressed: () => Navigator.pop(context), ), actions: [ IconButton( - icon: Icon(Icons.add_outlined, color: colors.onSurface.withValues(alpha: 0.7)), + 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.add_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), onPressed: _pickAndImport, ), const SizedBox(width: 4), @@ -147,19 +181,26 @@ class _EpubLibraryPageState extends State { color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), - child: Icon(Icons.auto_stories_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), + 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)), + 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))), + 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)), + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(24), + ), + child: Text('导入 EPUB', + style: TextStyle(fontSize: 15, color: colors.onPrimary, fontWeight: FontWeight.w500)), ), ), ], @@ -169,77 +210,29 @@ class _EpubLibraryPageState extends State { } Widget _buildGrid(ColorScheme colors) { + final bool isRelaxed = _viewMode == ViewMode.relaxed; + final maxExtent = isRelaxed ? 180.0 : 120.0; + final aspectRatio = isRelaxed ? 0.55 : 0.68; + final spacing = isRelaxed ? 16.0 : 8.0; + return GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 0.55, - crossAxisSpacing: 12, - mainAxisSpacing: 16, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxExtent, + childAspectRatio: aspectRatio, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, ), itemCount: _books.length, itemBuilder: (context, index) { final book = _books[index]; - return _buildBookItem(book, colors); + return BookGridItem( + book: book, + viewMode: _viewMode, + onTap: () => _openBook(book), + onLongPress: () => _deleteBook(book), + ); }, ); } - - Widget _buildBookItem(Map book, ColorScheme colors) { - final coverPath = book['cover_path'] as String?; - final title = book['title'] as String? ?? ''; - final author = book['author'] as String? ?? ''; - final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0; - - return GestureDetector( - onTap: () => _openBook(book), - onLongPress: () => _deleteBook(book), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 封面 - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: colors.shadow.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync() - ? Image.file(File(coverPath), fit: BoxFit.cover) - : Icon(Icons.auto_stories_outlined, size: 36, color: colors.onSurface.withValues(alpha: 0.2)), - ), - ), - const SizedBox(height: 8), - // 标题 - Text(title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface)), - if (author.isNotEmpty) ...[ - const SizedBox(height: 2), - Text(author, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), - ], - const SizedBox(height: 4), - // 进度条 - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: progress, - minHeight: 2, - backgroundColor: colors.surfaceContainerHighest, - valueColor: AlwaysStoppedAnimation(colors.primary.withValues(alpha: 0.5)), - ), - ), - ], - ), - ); - } } diff --git a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart index 95bfde5..b374c37 100644 --- a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart +++ b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart @@ -15,6 +15,8 @@ mixin _SpineNavigationMixin on State { int get currentPageInChapter; set currentPageInChapter(int v); + int get totalPagesInChapter; + // === Cross-mixin: _ProgressMixin === void updateProgressDebounced(); void saveProgress(); @@ -46,6 +48,7 @@ mixin _SpineNavigationMixin on State { double? restoreScrollRatio, }) async { if (bookSession.spine.isEmpty) return; + if (currentSpineItemIndex < 0) currentSpineItemIndex = 0; if (mounted) { setState(() { isWebViewLoading = true; @@ -144,7 +147,11 @@ mixin _SpineNavigationMixin on State { updateProgressDebounced(); await loadCarousel(anchor: anchor); - saveProgress(); + bookSession.flushProgress( + currentChapterIndex: currentSpineItemIndex, + currentPageInChapter: currentPageInChapter, + totalPagesInChapter: totalPagesInChapter, + ); } Future previousSpineItem() async { @@ -160,7 +167,11 @@ mixin _SpineNavigationMixin on State { }); preloadPreviousOf(currentSpineItemIndex); - saveProgress(); + bookSession.flushProgress( + currentChapterIndex: currentSpineItemIndex, + currentPageInChapter: currentPageInChapter, + totalPagesInChapter: totalPagesInChapter, + ); } Future previousSpineItemFirstPage() async { @@ -178,7 +189,11 @@ mixin _SpineNavigationMixin on State { updateProgressDebounced(); preloadPreviousOf(currentSpineItemIndex); - saveProgress(); + bookSession.flushProgress( + currentChapterIndex: currentSpineItemIndex, + currentPageInChapter: currentPageInChapter, + totalPagesInChapter: totalPagesInChapter, + ); } Future nextSpineItem() async { @@ -196,7 +211,11 @@ mixin _SpineNavigationMixin on State { updateProgressDebounced(); preloadNextOf(currentSpineItemIndex); - saveProgress(); + bookSession.flushProgress( + currentChapterIndex: currentSpineItemIndex, + currentPageInChapter: currentPageInChapter, + totalPagesInChapter: totalPagesInChapter, + ); } Future navigateToTocItem(TocEntry item) async { diff --git a/lib/pages/epub_reader/reader_screen.dart b/lib/pages/epub_reader/reader_screen.dart index d6bfbb4..595f5d6 100644 --- a/lib/pages/epub_reader/reader_screen.dart +++ b/lib/pages/epub_reader/reader_screen.dart @@ -33,6 +33,7 @@ class ReaderScreen extends StatefulWidget { final String filePath; final String title; final String? coverPath; + final Map? bookData; const ReaderScreen({ super.key, @@ -40,6 +41,7 @@ class ReaderScreen extends StatefulWidget { required this.filePath, required this.title, this.coverPath, + this.bookData, }); @override @@ -140,10 +142,11 @@ class _ReaderScreenState extends State // Create a placeholder BookSession; epubInfo will be replaced after parsing. bookSession = BookSession( fileHash: widget.bookId, - bookData: { + bookData: widget.bookData ?? { 'id': widget.bookId, 'file_path': widget.filePath, 'cover_path': widget.coverPath, + 'last_read_cfi': '', }, epubInfo: EpubBookInfo( title: widget.title, @@ -190,6 +193,12 @@ class _ReaderScreenState extends State restoreSystemUI(); volumeSubscription?.cancel(); VolumeControlService.disableInterception(); + // 立即保存进度(同步写入,不被 dispose 打断) + bookSession.flushProgress( + currentChapterIndex: currentSpineItemIndex, + currentPageInChapter: currentPageInChapter, + totalPagesInChapter: totalPagesInChapter, + ); bookSession.dispose(); _streamService.dispose(); super.dispose(); @@ -365,7 +374,11 @@ class _ReaderScreenState extends State if (didPop) return; if (footnoteOverlayEntry != null) { removeFootnoteOverlay(); + return; } + // 和 lumina 一样:先保存,再 pop + saveProgress(); + Navigator.of(context).pop(); }, child: AnnotatedRegion( value: overlayStyle, @@ -380,6 +393,9 @@ class _ReaderScreenState extends State totalChapters: bookSession.spine.length, toc: bookSession.toc, activeTocItems: activeItems, + currentSpineIndex: currentSpineItemIndex >= 0 && currentSpineItemIndex < bookSession.spine.length + ? bookSession.spine[currentSpineItemIndex].index + : -1, onTocItemSelected: navigateToTocItem, onCoverTap: navigateToFirstTocItemFirstPage, themeData: themeData, diff --git a/lib/pages/epub_reader/toc_drawer.dart b/lib/pages/epub_reader/toc_drawer.dart index 53518bc..64796cf 100644 --- a/lib/pages/epub_reader/toc_drawer.dart +++ b/lib/pages/epub_reader/toc_drawer.dart @@ -24,6 +24,7 @@ class TocDrawer extends StatefulWidget { final int totalChapters; final List toc; final Set activeTocItems; + final int currentSpineIndex; final Function(TocEntry) onTocItemSelected; final VoidCallback? onCoverTap; final ThemeData themeData; @@ -35,6 +36,7 @@ class TocDrawer extends StatefulWidget { required this.totalChapters, required this.toc, required this.activeTocItems, + this.currentSpineIndex = -1, required this.onTocItemSelected, this.onCoverTap, required this.themeData, @@ -200,7 +202,9 @@ class _TocDrawerState extends State { Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) { final item = row.item; - final isActive = widget.activeTocItems.contains(item); + // 用 activeTocItems 匹配,或者直接用 spineIndex 匹配当前章节 + final isActive = widget.activeTocItems.contains(item) || + (item.spineIndex >= 0 && item.spineIndex == widget.currentSpineIndex); final double paddingLeft = 16.0 + (row.depth * 16.0); diff --git a/lib/pages/epub_reader/widgets/book_grid_item.dart b/lib/pages/epub_reader/widgets/book_grid_item.dart new file mode 100644 index 0000000..19a2e3d --- /dev/null +++ b/lib/pages/epub_reader/widgets/book_grid_item.dart @@ -0,0 +1,212 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; + +/// Display mode for the book grid item. +enum ViewMode { relaxed, compact } + +/// Book grid item widget – displays a single book in the grid. +/// Appearance branches based on [ViewMode]. +class BookGridItem extends StatelessWidget { + final Map book; + final ViewMode viewMode; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + const BookGridItem({ + super.key, + required this.book, + required this.viewMode, + this.onTap, + this.onLongPress, + }); + + // ─── public build ──────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: switch (viewMode) { + ViewMode.relaxed => _buildRelaxed(context), + ViewMode.compact => _buildCompact(context), + }, + ); + } + + // ─── mode helpers ───────────────────────────────────────────────────────── + + /// Relaxed: cover + title + author + progress bar. + Widget _buildRelaxed(BuildContext context) { + final title = book['title'] as String? ?? ''; + final author = book['author'] as String? ?? ''; + final progress = _readingProgress; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildCoverStack(context, fit: StackFit.expand)), + const SizedBox(height: 12), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + if (author.isNotEmpty) + Text( + author, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + if (progress > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 3, + ), + ), + ), + ], + ); + } + + /// Compact: cover only, title gradient overlay + progress badge. + Widget _buildCompact(BuildContext context) { + final title = book['title'] as String? ?? ''; + + return _buildCoverStack( + context, + fit: StackFit.expand, + extras: [ + // Bottom gradient + title + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(6), + ), + child: Container( + padding: const EdgeInsets.fromLTRB(6, 32, 6, 6), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w500, + shadows: [ + const Shadow( + color: Colors.black54, + blurRadius: 2.0, + offset: Offset(0, 1.0), + ), + ], + ), + ), + ), + ), + ), + // Progress badge (top-right) + _buildProgressBadge(context), + ], + ); + } + + // ─── shared cover stack ─────────────────────────────────────────────────── + + Widget _buildCoverStack( + BuildContext context, { + List extras = const [], + StackFit fit = StackFit.loose, + }) { + final coverPath = book['cover_path'] as String?; + final hasCover = + coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync(); + + return Stack( + fit: fit, + children: [ + Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: hasCover + ? Image.file( + File(coverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => _buildPlaceholder(context), + ) + : _buildPlaceholder(context), + ), + ...extras, + ], + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center( + child: Icon( + Icons.auto_stories_outlined, + size: 36, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2), + ), + ), + ); + } + + // ─── badge helpers ──────────────────────────────────────────────────────── + + /// Progress badge (compact mode). + Widget _buildProgressBadge(BuildContext context) { + final progress = _readingProgress; + if (progress <= 0) return const SizedBox.shrink(); + + return Positioned( + top: 8, + right: 8, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.8), + child: Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + double get _readingProgress => + (book['reading_percentage'] as num?)?.toDouble() ?? 0.0; +} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index c96de2e..ed1ac25 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -39,7 +39,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 23, + version: 24, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -194,6 +194,13 @@ class DatabaseHelper { 'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)', ); } + if (oldVersion < 24) { + // reader_books 添加 book_id 列(关联 books 表) + final cols = await db.rawQuery('PRAGMA table_info(reader_books)'); + if (!cols.any((col) => col['name'] == 'book_id')) { + await db.execute("ALTER TABLE reader_books ADD COLUMN book_id TEXT DEFAULT ''"); + } + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -696,6 +703,7 @@ class DatabaseHelper { file_extension TEXT NOT NULL DEFAULT 'epub', last_read_cfi TEXT DEFAULT '', reading_percentage REAL DEFAULT 0.0, + book_id TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0 diff --git a/lib/utils/epub/epub_parser.dart b/lib/utils/epub/epub_parser.dart index 2b2eba5..132e361 100644 --- a/lib/utils/epub/epub_parser.dart +++ b/lib/utils/epub/epub_parser.dart @@ -11,15 +11,10 @@ class EpubParser { Future parseFromFile(String filePath, {String? fileName}) async { try { - debugPrint('[EpubParser] 开始解析: $filePath'); final bytes = await File(filePath).readAsBytes(); - debugPrint('[EpubParser] 读取 ${bytes.length} 字节'); final archive = ZipDecoder().decodeBytes(bytes); - debugPrint('[EpubParser] ZIP 解码成功, ${archive.files.length} 个文件'); return _parseFromArchive(archive, fileName: fileName); - } catch (e, stack) { - debugPrint('[EpubParser] 解析失败: $e'); - debugPrint('[EpubParser] $stack'); + } catch (e) { return null; } } diff --git a/lib/utils/epub/reader_dao.dart b/lib/utils/epub/reader_dao.dart index 17f2af0..546c060 100644 --- a/lib/utils/epub/reader_dao.dart +++ b/lib/utils/epub/reader_dao.dart @@ -72,6 +72,42 @@ class ReaderDao { ); } + // ─── 关联 books 表 ───────────────────────────────────────────── + + /// 关联 EPUB 到 books 表中的书籍 + Future linkToBook(String readerBookId, String bookId) async { + final db = await _db.database; + return db.update( + 'reader_books', + {'book_id': bookId, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [readerBookId], + ); + } + + /// 取消关联 + Future unlinkBook(String readerBookId) async { + final db = await _db.database; + return db.update( + 'reader_books', + {'book_id': '', 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [readerBookId], + ); + } + + /// 通过 books.id 查找关联的 reader_book + Future?> getReaderBookByBookId(String bookId) async { + final db = await _db.database; + final results = await db.query( + 'reader_books', + where: 'book_id = ? AND is_deleted = 0', + whereArgs: [bookId], + limit: 1, + ); + return results.isNotEmpty ? results.first : null; + } + // ─── book_annotations ───────────────────────────────────────────── /// 获取某本书的所有批注 diff --git a/lib/utils/epub/reader_scripts.dart b/lib/utils/epub/reader_scripts.dart index 1690cc8..6e9e6b3 100644 --- a/lib/utils/epub/reader_scripts.dart +++ b/lib/utils/epub/reader_scripts.dart @@ -75,9 +75,9 @@ String generateSkeletonHtml(
- - - + + +
diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index d66bcca..e367a63 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -207,4 +207,8 @@ class UserPrefs { /// EPUB 阅读器字体大小 double get epubFontSize => prefs.getDouble('epubFontSize') ?? 18.0; Future setEpubFontSize(double value) => prefs.setDouble('epubFontSize', value); + + /// EPUB 书架视图模式: 0=宽松, 1=紧凑 + int get epubViewMode => prefs.getInt('epubViewMode') ?? 0; + Future setEpubViewMode(int value) => prefs.setInt('epubViewMode', value); }