epub阅读+关联书籍

This commit is contained in:
DelLevin-Home
2026-06-27 18:13:34 +08:00
parent d54e33e4cf
commit bcff4f6c01
15 changed files with 1143 additions and 124 deletions

View File

@@ -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<BookDetailPage> {
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<BookDetailPage> {
);
}
/// EPUB 阅读悬浮按钮(仅有关联 EPUB 时显示)
Widget _buildEpubReadButton(Book book) {
return FutureBuilder<Map<String, dynamic>?>(
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<BookDetailPage> {
);
}
/// EPUB 阅读进度条(仅有关联 EPUB 时显示)
Widget _buildEpubProgressBar(Book book) {
final colors = Theme.of(context).colorScheme;
return FutureBuilder<Map<String, dynamic>?>(
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<BookDetailPage> {
),
),
],
// EPUB 阅读进度条
_buildEpubProgressBar(book),
const SizedBox(height: 16),
Row(
children: [
@@ -946,6 +1011,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
);
}
/// EPUB 阅读入口(通过关联的 reader_books
/// 叠层模式:书评、摘抄各自独立毛玻璃卡片
Widget _buildExtraSectionsOverlay(Book book) {
return Padding(

View File

@@ -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<BookLinkPage> createState() => _BookLinkPageState();
}
class _BookLinkPageState extends State<BookLinkPage> {
final BookDao _bookDao = BookDao();
final ReaderDao _readerDao = ReaderDao();
final TextEditingController _searchCtrl = TextEditingController();
List<Book> _allBooks = [];
List<Book> _filteredBooks = [];
bool _isLoading = true;
String _query = '';
@override
void initState() {
super.initState();
_loadBooks();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _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<void> _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)),
]),
),
);
}
}

View File

@@ -37,6 +37,38 @@ class BookSession {
_debounceTimer = null;
}
/// 立即保存进度(不做 debounce用于退出时调用
Future<void> 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]);
}
}

View File

@@ -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<String, dynamic> book;
const EpubDetailPage({
super.key,
required this.bookId,
required this.book,
});
@override
State<EpubDetailPage> createState() => _EpubDetailPageState();
}
class _EpubDetailPageState extends State<EpubDetailPage> {
final ReaderDao _dao = ReaderDao();
final BookDao _bookDao = BookDao();
final EpubParser _parser = EpubParser();
late Map<String, dynamic> _book;
EpubBookInfo? _bookInfo;
bool _descriptionExpanded = false;
@override
void initState() {
super.initState();
_book = widget.book;
_loadBookInfo();
}
Future<void> _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<void> _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<Book?>(
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<void> _navigateToLinkPage() async {
final linked = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => BookLinkPage(readerBookId: widget.bookId)),
);
if (linked == true && mounted) await _refreshBook();
}
Future<void> _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'<br\s*/?>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</(p|div|h[1-6]|tr|blockquote)>', caseSensitive: false), '\n\n');
text = text.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '');
text = text.replaceAll(RegExp(r'</li>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
text = text
.replaceAll('&nbsp;', ' ').replaceAll('&lt;', '<').replaceAll('&gt;', '>')
.replaceAll('&amp;', '&').replaceAll('&quot;', '"').replaceAll('&#39;', "'")
.replaceAll('&apos;', "'").replaceAll('&mdash;', '').replaceAll('&ndash;', '');
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
text = text.replaceAll(RegExp(r'\n\s*\n+'), '\n\n');
return text.trim();
}
}

View File

@@ -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<EpubLibraryPage> {
final EpubService _service = EpubService();
List<Map<String, dynamic>> _books = [];
bool _isLoading = true;
ViewMode _viewMode = UserPrefs().epubViewMode == 1
? ViewMode.compact
: ViewMode.relaxed;
@override
void initState() {
@@ -26,7 +30,7 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
}
Future<void> _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<EpubLibraryPage> {
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<EpubLibraryPage> {
final confirm = await showDialog<bool>(
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<EpubLibraryPage> {
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<EpubLibraryPage> {
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<EpubLibraryPage> {
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<EpubLibraryPage> {
}
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<String, dynamic> 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)),
),
),
],
),
);
}
}

View File

@@ -15,6 +15,8 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
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<ReaderScreen> {
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<ReaderScreen> {
updateProgressDebounced();
await loadCarousel(anchor: anchor);
saveProgress();
bookSession.flushProgress(
currentChapterIndex: currentSpineItemIndex,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
}
Future<void> previousSpineItem() async {
@@ -160,7 +167,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
});
preloadPreviousOf(currentSpineItemIndex);
saveProgress();
bookSession.flushProgress(
currentChapterIndex: currentSpineItemIndex,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
}
Future<void> previousSpineItemFirstPage() async {
@@ -178,7 +189,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
updateProgressDebounced();
preloadPreviousOf(currentSpineItemIndex);
saveProgress();
bookSession.flushProgress(
currentChapterIndex: currentSpineItemIndex,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
}
Future<void> nextSpineItem() async {
@@ -196,7 +211,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
updateProgressDebounced();
preloadNextOf(currentSpineItemIndex);
saveProgress();
bookSession.flushProgress(
currentChapterIndex: currentSpineItemIndex,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
}
Future<void> navigateToTocItem(TocEntry item) async {

View File

@@ -33,6 +33,7 @@ class ReaderScreen extends StatefulWidget {
final String filePath;
final String title;
final String? coverPath;
final Map<String, dynamic>? 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<ReaderScreen>
// 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<ReaderScreen>
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<ReaderScreen>
if (didPop) return;
if (footnoteOverlayEntry != null) {
removeFootnoteOverlay();
return;
}
// 和 lumina 一样:先保存,再 pop
saveProgress();
Navigator.of(context).pop();
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
@@ -380,6 +393,9 @@ class _ReaderScreenState extends State<ReaderScreen>
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,

View File

@@ -24,6 +24,7 @@ class TocDrawer extends StatefulWidget {
final int totalChapters;
final List<TocEntry> toc;
final Set<TocEntry> 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<TocDrawer> {
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);

View File

@@ -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<String, dynamic> 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<Widget> 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;
}