generated from dellevin/template
epub阅读+关联书籍
This commit is contained in:
19
README.md
19
README.md
@@ -9,7 +9,7 @@ http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC
|
|||||||
## 应用预览
|
## 应用预览
|
||||||
|
|
||||||
| 影视列表 | 书籍列表 | 笔记界面 |
|
| 影视列表 | 书籍列表 | 笔记界面 |
|
||||||
| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: |
|
| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
更多预览图片请到**应用功能****详情预览**里面查看
|
更多预览图片请到**应用功能****详情预览**里面查看
|
||||||
@@ -32,6 +32,7 @@ http://docmost.iletter.top/share/ropwljpyvn/p/mook-note-lHmPTswdDC
|
|||||||
- 书摘 / 摘录记录
|
- 书摘 / 摘录记录
|
||||||
- 书籍分享卡片
|
- 书籍分享卡片
|
||||||
- 书籍状态筛选
|
- 书籍状态筛选
|
||||||
|
- Epub阅读功能
|
||||||
|
|
||||||
### 笔记管理
|
### 笔记管理
|
||||||
|
|
||||||
@@ -149,21 +150,27 @@ flutter build appbundle --release
|
|||||||
## 应用功能详情预览
|
## 应用功能详情预览
|
||||||
|
|
||||||
| 数据统计 | 侧边栏 | 我的界面 |
|
| 数据统计 | 侧边栏 | 我的界面 |
|
||||||
| :--------------------------------------------: | :---------------------------------------------: | :--------------------------------------------: |
|
| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
| 详情界面1 | 详情界面2 | 编辑笔记 |
|
| 详情界面1 | 详情界面2 | 编辑笔记 |
|
||||||
| :--------------------------------------------: | :--------------------------------------------: | :---------------------------------------------: |
|
| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
| 分享界面1 | 分享界面2 | 预览笔记 |
|
| 分享界面1 | 分享界面2 | 预览笔记 |
|
||||||
| :---------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
|
| :--------------------------------------------: | :--------------------------------------------: | :--------------------------------------------: |
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
## 数据对接
|
## 影视书籍资源数据对接
|
||||||
|
|
||||||
数据已对接6w+影视基础数据,以及300w+书籍基础信息,如需对接接口,或技术交流请联系作者。
|
数据已对接6w+影视基础数据,以及300w+书籍基础信息,如需对接接口,或技术交流请联系作者。所有影视书籍来源皆为网络资源收集,部分数据可能会有偏差,如数据有问题,也请联系开发者及时修复
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
本项目采用 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) 开源协议。
|
本项目采用 [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) 开源协议。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
**[lumina](https://github.com/MilkFeng/lumina)**
|
||||||
|
|
||||||
|
**[NLCISBNPlugin](https://github.com/DoiiarX/NLCISBNPlugin)**
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import '../../utils/user_prefs.dart';
|
|||||||
import 'book_reviews_page.dart';
|
import 'book_reviews_page.dart';
|
||||||
import 'book_excerpts_page.dart';
|
import 'book_excerpts_page.dart';
|
||||||
import 'book_share_page.dart';
|
import 'book_share_page.dart';
|
||||||
|
import '../../utils/epub/reader_dao.dart';
|
||||||
|
import '../epub_reader/reader_screen.dart';
|
||||||
|
|
||||||
/// 书籍详情页 - 极简主义设计
|
/// 书籍详情页 - 极简主义设计
|
||||||
class BookDetailPage extends StatefulWidget {
|
class BookDetailPage extends StatefulWidget {
|
||||||
@@ -280,6 +282,8 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
foregroundColor: colors.onError,
|
foregroundColor: colors.onError,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildEpubReadButton(book),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_buildFloatingButton(
|
_buildFloatingButton(
|
||||||
icon: Icons.share_outlined,
|
icon: Icons.share_outlined,
|
||||||
onPressed: () => _showSharePoster(book),
|
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({
|
Widget _buildFloatingButton({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required VoidCallback onPressed,
|
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) {
|
Widget _buildBasicInfo(Book book) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -576,6 +639,8 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
// EPUB 阅读进度条
|
||||||
|
_buildEpubProgressBar(book),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -946,6 +1011,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// EPUB 阅读入口(通过关联的 reader_books)
|
||||||
/// 叠层模式:书评、摘抄各自独立毛玻璃卡片
|
/// 叠层模式:书评、摘抄各自独立毛玻璃卡片
|
||||||
Widget _buildExtraSectionsOverlay(Book book) {
|
Widget _buildExtraSectionsOverlay(Book book) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
188
lib/pages/epub_reader/book_link_page.dart
Normal file
188
lib/pages/epub_reader/book_link_page.dart
Normal 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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,38 @@ class BookSession {
|
|||||||
_debounceTimer = null;
|
_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)
|
/// Update EPUB info after parsing (called when session is created before parse)
|
||||||
void updateEpubInfo(EpubBookInfo info) {
|
void updateEpubInfo(EpubBookInfo info) {
|
||||||
epubInfo = info;
|
epubInfo = info;
|
||||||
@@ -98,13 +130,18 @@ class BookSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build fallback: for each spine item, pick the nearest preceding TOC entry
|
// 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();
|
_tocItemFallback.clear();
|
||||||
for (final spineItem in _spine) {
|
for (int i = 0; i < _spine.length; i++) {
|
||||||
if (fallback != null) _tocItemFallback.add(fallback);
|
_tocItemFallback.add(fallback);
|
||||||
final anchors = _spineToAnchorsMap[spineItem.href] ?? [];
|
final anchors = _spineToAnchorsMap[_spine[i].href] ?? [];
|
||||||
if (anchors.isNotEmpty) {
|
if (anchors.isNotEmpty) {
|
||||||
final lastHref = '${spineItem.href}#${anchors.last}';
|
final lastHref = '${_spine[i].href}#${anchors.last}';
|
||||||
final idx = _hrefToTocIndexMap[lastHref];
|
final idx = _hrefToTocIndexMap[lastHref];
|
||||||
if (idx != null) {
|
if (idx != null) {
|
||||||
fallback = _flatToc[idx];
|
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(
|
await _readerDao.updateReadingProgress(
|
||||||
fileHash,
|
fileHash,
|
||||||
'$currentChapterIndex',
|
cfi,
|
||||||
progress,
|
progress,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -255,12 +298,20 @@ class BookSession {
|
|||||||
// ─── Initial position (from saved state) ──────────────────────────
|
// ─── Initial position (from saved state) ──────────────────────────
|
||||||
|
|
||||||
/// The last-read chapter index stored in the book record.
|
/// The last-read chapter index stored in the book record.
|
||||||
|
/// last_read_cfi 格式: "chapterIndex" 或 "chapterIndex:pageRatio"
|
||||||
int get initialChapterIndex {
|
int get initialChapterIndex {
|
||||||
final cfi = bookData['last_read_cfi'] as String? ?? '';
|
final cfi = bookData['last_read_cfi'] as String? ?? '';
|
||||||
if (cfi.isEmpty) return 0;
|
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.
|
/// 页内滚动位置比例 (0.0~1.0),从 last_read_cfi 解析
|
||||||
double? get initialScrollPosition => null;
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
420
lib/pages/epub_reader/epub_detail_page.dart
Normal file
420
lib/pages/epub_reader/epub_detail_page.dart
Normal 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(' ', ' ').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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import '../../utils/epub/reader_dao.dart';
|
import '../../utils/epub/reader_dao.dart';
|
||||||
import '../../utils/epub/epub_service.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 书架页面
|
/// EPUB 书架页面
|
||||||
class EpubLibraryPage extends StatefulWidget {
|
class EpubLibraryPage extends StatefulWidget {
|
||||||
@@ -18,6 +19,9 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
final EpubService _service = EpubService();
|
final EpubService _service = EpubService();
|
||||||
List<Map<String, dynamic>> _books = [];
|
List<Map<String, dynamic>> _books = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
ViewMode _viewMode = UserPrefs().epubViewMode == 1
|
||||||
|
? ViewMode.compact
|
||||||
|
: ViewMode.relaxed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -26,7 +30,7 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadBooks() async {
|
Future<void> _loadBooks() async {
|
||||||
setState(() => _isLoading = true);
|
if (mounted) setState(() => _isLoading = true);
|
||||||
final books = await _dao.getAllReaderBooks();
|
final books = await _dao.getAllReaderBooks();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -54,7 +58,7 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
|
|
||||||
final imported = await _service.importBook(path);
|
final imported = await _service.importBook(path);
|
||||||
|
|
||||||
if (mounted) Navigator.pop(context); // 关闭 loading
|
if (mounted) Navigator.pop(context);
|
||||||
|
|
||||||
if (imported != null) {
|
if (imported != null) {
|
||||||
await _loadBooks();
|
await _loadBooks();
|
||||||
@@ -70,16 +74,33 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('删除书籍', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
backgroundColor: colors.surface,
|
||||||
content: Text('确定删除《${book['title']}》?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
style: TextButton.styleFrom(
|
||||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))),
|
foregroundColor: colors.onSurface.withValues(alpha: 0.6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
foregroundColor: colors.onError,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
),
|
),
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ReaderScreen(
|
builder: (_) => EpubDetailPage(bookId: book['id'], book: book),
|
||||||
bookId: book['id'],
|
|
||||||
filePath: book['file_path'],
|
|
||||||
title: book['title'],
|
|
||||||
coverPath: book['cover_path'],
|
|
||||||
),
|
),
|
||||||
),
|
).then((_) => _loadBooks());
|
||||||
).then((_) => _loadBooks()); // 返回时刷新进度
|
}
|
||||||
|
|
||||||
|
void _toggleViewMode() {
|
||||||
|
setState(() {
|
||||||
|
_viewMode =
|
||||||
|
_viewMode == ViewMode.relaxed ? ViewMode.compact : ViewMode.relaxed;
|
||||||
|
});
|
||||||
|
UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -115,12 +139,22 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
title: Text('EPUB 阅读',
|
title: Text('EPUB 阅读',
|
||||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Icon(Icons.arrow_back, color: colors.onSurface),
|
icon: const Icon(Icons.arrow_back, size: 20),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
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,
|
onPressed: _pickAndImport,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -147,19 +181,26 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
|||||||
color: colors.surfaceContainerHighest,
|
color: colors.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(20),
|
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),
|
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),
|
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),
|
const SizedBox(height: 32),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _pickAndImport,
|
onTap: _pickAndImport,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||||
decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(24)),
|
decoration: BoxDecoration(
|
||||||
child: Text('导入 EPUB', style: TextStyle(fontSize: 15, color: colors.onPrimary, fontWeight: FontWeight.w500)),
|
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) {
|
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(
|
return GridView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
crossAxisCount: 3,
|
maxCrossAxisExtent: maxExtent,
|
||||||
childAspectRatio: 0.55,
|
childAspectRatio: aspectRatio,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: spacing,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: spacing,
|
||||||
),
|
),
|
||||||
itemCount: _books.length,
|
itemCount: _books.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final book = _books[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)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
int get currentPageInChapter;
|
int get currentPageInChapter;
|
||||||
set currentPageInChapter(int v);
|
set currentPageInChapter(int v);
|
||||||
|
|
||||||
|
int get totalPagesInChapter;
|
||||||
|
|
||||||
// === Cross-mixin: _ProgressMixin ===
|
// === Cross-mixin: _ProgressMixin ===
|
||||||
void updateProgressDebounced();
|
void updateProgressDebounced();
|
||||||
void saveProgress();
|
void saveProgress();
|
||||||
@@ -46,6 +48,7 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
double? restoreScrollRatio,
|
double? restoreScrollRatio,
|
||||||
}) async {
|
}) async {
|
||||||
if (bookSession.spine.isEmpty) return;
|
if (bookSession.spine.isEmpty) return;
|
||||||
|
if (currentSpineItemIndex < 0) currentSpineItemIndex = 0;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isWebViewLoading = true;
|
isWebViewLoading = true;
|
||||||
@@ -144,7 +147,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
updateProgressDebounced();
|
updateProgressDebounced();
|
||||||
|
|
||||||
await loadCarousel(anchor: anchor);
|
await loadCarousel(anchor: anchor);
|
||||||
saveProgress();
|
bookSession.flushProgress(
|
||||||
|
currentChapterIndex: currentSpineItemIndex,
|
||||||
|
currentPageInChapter: currentPageInChapter,
|
||||||
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> previousSpineItem() async {
|
Future<void> previousSpineItem() async {
|
||||||
@@ -160,7 +167,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
preloadPreviousOf(currentSpineItemIndex);
|
preloadPreviousOf(currentSpineItemIndex);
|
||||||
saveProgress();
|
bookSession.flushProgress(
|
||||||
|
currentChapterIndex: currentSpineItemIndex,
|
||||||
|
currentPageInChapter: currentPageInChapter,
|
||||||
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> previousSpineItemFirstPage() async {
|
Future<void> previousSpineItemFirstPage() async {
|
||||||
@@ -178,7 +189,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
updateProgressDebounced();
|
updateProgressDebounced();
|
||||||
|
|
||||||
preloadPreviousOf(currentSpineItemIndex);
|
preloadPreviousOf(currentSpineItemIndex);
|
||||||
saveProgress();
|
bookSession.flushProgress(
|
||||||
|
currentChapterIndex: currentSpineItemIndex,
|
||||||
|
currentPageInChapter: currentPageInChapter,
|
||||||
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> nextSpineItem() async {
|
Future<void> nextSpineItem() async {
|
||||||
@@ -196,7 +211,11 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
|
|||||||
updateProgressDebounced();
|
updateProgressDebounced();
|
||||||
|
|
||||||
preloadNextOf(currentSpineItemIndex);
|
preloadNextOf(currentSpineItemIndex);
|
||||||
saveProgress();
|
bookSession.flushProgress(
|
||||||
|
currentChapterIndex: currentSpineItemIndex,
|
||||||
|
currentPageInChapter: currentPageInChapter,
|
||||||
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> navigateToTocItem(TocEntry item) async {
|
Future<void> navigateToTocItem(TocEntry item) async {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class ReaderScreen extends StatefulWidget {
|
|||||||
final String filePath;
|
final String filePath;
|
||||||
final String title;
|
final String title;
|
||||||
final String? coverPath;
|
final String? coverPath;
|
||||||
|
final Map<String, dynamic>? bookData;
|
||||||
|
|
||||||
const ReaderScreen({
|
const ReaderScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -40,6 +41,7 @@ class ReaderScreen extends StatefulWidget {
|
|||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.coverPath,
|
this.coverPath,
|
||||||
|
this.bookData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -140,10 +142,11 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
// Create a placeholder BookSession; epubInfo will be replaced after parsing.
|
// Create a placeholder BookSession; epubInfo will be replaced after parsing.
|
||||||
bookSession = BookSession(
|
bookSession = BookSession(
|
||||||
fileHash: widget.bookId,
|
fileHash: widget.bookId,
|
||||||
bookData: {
|
bookData: widget.bookData ?? {
|
||||||
'id': widget.bookId,
|
'id': widget.bookId,
|
||||||
'file_path': widget.filePath,
|
'file_path': widget.filePath,
|
||||||
'cover_path': widget.coverPath,
|
'cover_path': widget.coverPath,
|
||||||
|
'last_read_cfi': '',
|
||||||
},
|
},
|
||||||
epubInfo: EpubBookInfo(
|
epubInfo: EpubBookInfo(
|
||||||
title: widget.title,
|
title: widget.title,
|
||||||
@@ -190,6 +193,12 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
restoreSystemUI();
|
restoreSystemUI();
|
||||||
volumeSubscription?.cancel();
|
volumeSubscription?.cancel();
|
||||||
VolumeControlService.disableInterception();
|
VolumeControlService.disableInterception();
|
||||||
|
// 立即保存进度(同步写入,不被 dispose 打断)
|
||||||
|
bookSession.flushProgress(
|
||||||
|
currentChapterIndex: currentSpineItemIndex,
|
||||||
|
currentPageInChapter: currentPageInChapter,
|
||||||
|
totalPagesInChapter: totalPagesInChapter,
|
||||||
|
);
|
||||||
bookSession.dispose();
|
bookSession.dispose();
|
||||||
_streamService.dispose();
|
_streamService.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -365,7 +374,11 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
if (didPop) return;
|
if (didPop) return;
|
||||||
if (footnoteOverlayEntry != null) {
|
if (footnoteOverlayEntry != null) {
|
||||||
removeFootnoteOverlay();
|
removeFootnoteOverlay();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 和 lumina 一样:先保存,再 pop
|
||||||
|
saveProgress();
|
||||||
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: overlayStyle,
|
value: overlayStyle,
|
||||||
@@ -380,6 +393,9 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
totalChapters: bookSession.spine.length,
|
totalChapters: bookSession.spine.length,
|
||||||
toc: bookSession.toc,
|
toc: bookSession.toc,
|
||||||
activeTocItems: activeItems,
|
activeTocItems: activeItems,
|
||||||
|
currentSpineIndex: currentSpineItemIndex >= 0 && currentSpineItemIndex < bookSession.spine.length
|
||||||
|
? bookSession.spine[currentSpineItemIndex].index
|
||||||
|
: -1,
|
||||||
onTocItemSelected: navigateToTocItem,
|
onTocItemSelected: navigateToTocItem,
|
||||||
onCoverTap: navigateToFirstTocItemFirstPage,
|
onCoverTap: navigateToFirstTocItemFirstPage,
|
||||||
themeData: themeData,
|
themeData: themeData,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class TocDrawer extends StatefulWidget {
|
|||||||
final int totalChapters;
|
final int totalChapters;
|
||||||
final List<TocEntry> toc;
|
final List<TocEntry> toc;
|
||||||
final Set<TocEntry> activeTocItems;
|
final Set<TocEntry> activeTocItems;
|
||||||
|
final int currentSpineIndex;
|
||||||
final Function(TocEntry) onTocItemSelected;
|
final Function(TocEntry) onTocItemSelected;
|
||||||
final VoidCallback? onCoverTap;
|
final VoidCallback? onCoverTap;
|
||||||
final ThemeData themeData;
|
final ThemeData themeData;
|
||||||
@@ -35,6 +36,7 @@ class TocDrawer extends StatefulWidget {
|
|||||||
required this.totalChapters,
|
required this.totalChapters,
|
||||||
required this.toc,
|
required this.toc,
|
||||||
required this.activeTocItems,
|
required this.activeTocItems,
|
||||||
|
this.currentSpineIndex = -1,
|
||||||
required this.onTocItemSelected,
|
required this.onTocItemSelected,
|
||||||
this.onCoverTap,
|
this.onCoverTap,
|
||||||
required this.themeData,
|
required this.themeData,
|
||||||
@@ -200,7 +202,9 @@ class _TocDrawerState extends State<TocDrawer> {
|
|||||||
|
|
||||||
Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) {
|
Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) {
|
||||||
final item = row.item;
|
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);
|
final double paddingLeft = 16.0 + (row.depth * 16.0);
|
||||||
|
|
||||||
|
|||||||
212
lib/pages/epub_reader/widgets/book_grid_item.dart
Normal file
212
lib/pages/epub_reader/widgets/book_grid_item.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 23,
|
version: 24,
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -194,6 +194,13 @@ class DatabaseHelper {
|
|||||||
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
|
'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和出版时间字段)
|
/// 升级books表到V11(添加ISBN和出版时间字段)
|
||||||
@@ -696,6 +703,7 @@ class DatabaseHelper {
|
|||||||
file_extension TEXT NOT NULL DEFAULT 'epub',
|
file_extension TEXT NOT NULL DEFAULT 'epub',
|
||||||
last_read_cfi TEXT DEFAULT '',
|
last_read_cfi TEXT DEFAULT '',
|
||||||
reading_percentage REAL DEFAULT 0.0,
|
reading_percentage REAL DEFAULT 0.0,
|
||||||
|
book_id TEXT DEFAULT '',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
is_deleted INTEGER DEFAULT 0
|
is_deleted INTEGER DEFAULT 0
|
||||||
|
|||||||
@@ -11,15 +11,10 @@ class EpubParser {
|
|||||||
Future<EpubBookInfo?> parseFromFile(String filePath,
|
Future<EpubBookInfo?> parseFromFile(String filePath,
|
||||||
{String? fileName}) async {
|
{String? fileName}) async {
|
||||||
try {
|
try {
|
||||||
debugPrint('[EpubParser] 开始解析: $filePath');
|
|
||||||
final bytes = await File(filePath).readAsBytes();
|
final bytes = await File(filePath).readAsBytes();
|
||||||
debugPrint('[EpubParser] 读取 ${bytes.length} 字节');
|
|
||||||
final archive = ZipDecoder().decodeBytes(bytes);
|
final archive = ZipDecoder().decodeBytes(bytes);
|
||||||
debugPrint('[EpubParser] ZIP 解码成功, ${archive.files.length} 个文件');
|
|
||||||
return _parseFromArchive(archive, fileName: fileName);
|
return _parseFromArchive(archive, fileName: fileName);
|
||||||
} catch (e, stack) {
|
} catch (e) {
|
||||||
debugPrint('[EpubParser] 解析失败: $e');
|
|
||||||
debugPrint('[EpubParser] $stack');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,42 @@ class ReaderDao {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 关联 books 表 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 关联 EPUB 到 books 表中的书籍
|
||||||
|
Future<int> 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<int> 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<Map<String, dynamic>?> 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 ─────────────────────────────────────────────
|
// ─── book_annotations ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// 获取某本书的所有批注
|
/// 获取某本书的所有批注
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ String generateSkeletonHtml(
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="frame-container">
|
<div id="frame-container">
|
||||||
<iframe id="frame-prev" sandbox="allow-same-origin" scrolling="no" style="z-index: 1; opacity: 0;"></iframe>
|
<iframe id="frame-prev" sandbox="allow-same-origin allow-scripts" scrolling="no" style="z-index: 1; opacity: 0;"></iframe>
|
||||||
<iframe id="frame-curr" sandbox="allow-same-origin" scrolling="no" style="z-index: 2; opacity: 1;"></iframe>
|
<iframe id="frame-curr" sandbox="allow-same-origin allow-scripts" scrolling="no" style="z-index: 2; opacity: 1;"></iframe>
|
||||||
<iframe id="frame-next" sandbox="allow-same-origin" scrolling="no" style="z-index: 1; opacity: 0;"></iframe>
|
<iframe id="frame-next" sandbox="allow-same-origin allow-scripts" scrolling="no" style="z-index: 1; opacity: 0;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -207,4 +207,8 @@ class UserPrefs {
|
|||||||
/// EPUB 阅读器字体大小
|
/// EPUB 阅读器字体大小
|
||||||
double get epubFontSize => prefs.getDouble('epubFontSize') ?? 18.0;
|
double get epubFontSize => prefs.getDouble('epubFontSize') ?? 18.0;
|
||||||
Future<bool> setEpubFontSize(double value) => prefs.setDouble('epubFontSize', value);
|
Future<bool> setEpubFontSize(double value) => prefs.setDouble('epubFontSize', value);
|
||||||
|
|
||||||
|
/// EPUB 书架视图模式: 0=宽松, 1=紧凑
|
||||||
|
int get epubViewMode => prefs.getInt('epubViewMode') ?? 0;
|
||||||
|
Future<bool> setEpubViewMode(int value) => prefs.setInt('epubViewMode', value);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user