generated from dellevin/template
epub阅读+关联书籍
This commit is contained in:
@@ -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(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// 立即保存进度(不做 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]);
|
||||
}
|
||||
}
|
||||
|
||||
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: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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user