新增epub书摘,高亮文字,适配平板

This commit is contained in:
DelLevin-Home
2026-07-02 05:22:21 +08:00
parent a0a146854b
commit a7e4845e46
27 changed files with 3712 additions and 505 deletions

View File

@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
import 'utils/theme/app_theme.dart'; import 'utils/theme/app_theme.dart';
@@ -102,19 +103,21 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
var ctx = _navigatorKey.currentContext; var ctx = _navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return; if (ctx == null || !ctx.mounted) return;
try { try {
final hasUpdate = await ChangelogService.hasUpdate(); final items = await ChangelogService.fetchChangelog();
if (!hasUpdate) return; if (items.isEmpty) return;
final latestVersion = await ChangelogService.fetchLatestVersion(); final latest = items.first;
if (latestVersion == null) return; final info = await PackageInfo.fromPlatform();
final dismissed = UserPrefs().dismissedVersion; final localVersion = 'v${info.version}';
if (dismissed == latestVersion) return; if (ChangelogService.compareVersion(latest.version, localVersion) <= 0) return;
// 24 小时内 snooze 不弹
if (DateTime.now().millisecondsSinceEpoch < UserPrefs().dismissedUpdateUntil) return;
ctx = _navigatorKey.currentContext; ctx = _navigatorKey.currentContext;
if (ctx == null || !ctx.mounted) return; if (ctx == null || !ctx.mounted) return;
_showUpdateDialog(ctx, latestVersion); _showUpdateDialog(ctx, latest.version, latest.features, localVersion);
} catch (_) {} } catch (_) {}
} }
void _showUpdateDialog(BuildContext context, String version) { void _showUpdateDialog(BuildContext context, String version, List<String> features, String localVersion) {
if (!context.mounted) return; if (!context.mounted) return;
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
showDialog( showDialog(
@@ -122,17 +125,69 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), title: Row(
content: Text('新版本 $version 已发布,是否下载更新?', children: [
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), Icon(Icons.system_update, size: 22, color: colors.primary),
const SizedBox(width: 8),
Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
],
),
content: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.5),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前版本:$localVersion',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
const SizedBox(height: 4),
Text('最新版本:$version',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.primary)),
if (features.isNotEmpty) ...[
const SizedBox(height: 12),
Text('更新内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 8),
...features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
f,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7), height: 1.5),
),
),
],
),
)),
],
],
),
),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
UserPrefs().setDismissedVersion(version); final until = DateTime.now().add(const Duration(hours: 24)).millisecondsSinceEpoch;
UserPrefs().setDismissedUpdateUntil(until);
Navigator.pop(ctx); Navigator.pop(ctx);
}, },
child: Text('显示', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), child: Text('24小时内不显示', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {

View File

@@ -12,6 +12,7 @@ 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 '../../utils/epub/reader_dao.dart';
import '../epub_reader/epub_highlights_page.dart';
import '../epub_reader/reader_screen.dart'; import '../epub_reader/reader_screen.dart';
/// 书籍详情页 - 极简主义设计 /// 书籍详情页 - 极简主义设计
@@ -1007,6 +1008,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
unit: '条摘抄', unit: '条摘抄',
onTap: () => _navigateToExcerpts(book), onTap: () => _navigateToExcerpts(book),
), ),
const SizedBox(height: 12),
_buildExtraSectionItem(
icon: Icons.highlight_outlined,
title: '句读',
subtitleFuture: _getEpubHighlightCount(book.id),
emptyText: '暂无句读',
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
], ],
), ),
); );
@@ -1036,6 +1046,15 @@ class _BookDetailPageState extends State<BookDetailPage> {
unit: '条摘抄', unit: '条摘抄',
onTap: () => _navigateToExcerpts(book), onTap: () => _navigateToExcerpts(book),
), ),
const SizedBox(height: 12),
_buildFrostedExtraItem(
icon: Icons.highlight_outlined,
title: '句读',
subtitleFuture: _getEpubHighlightCount(book.id),
emptyText: '暂无句读',
unit: '条句读',
onTap: () => _navigateToEpubHighlights(book),
),
], ],
), ),
); );
@@ -1184,6 +1203,33 @@ class _BookDetailPageState extends State<BookDetailPage> {
); );
} }
/// 获取关联 EPUB 的句读(高亮)数量
Future<int> _getEpubHighlightCount(String bookId) async {
final readerBook = await ReaderDao().getReaderBookByBookId(bookId);
if (readerBook == null) return 0;
final highlights = await ReaderDao().getHighlightsByBookId(readerBook['id'] as String);
return highlights.where((h) => h['color'] != 'excerpt').length;
}
/// 跳转到 EPUB 句读管理页
void _navigateToEpubHighlights(Book book) async {
final readerBook = await ReaderDao().getReaderBookByBookId(book.id);
if (!mounted) return;
if (readerBook == null) {
ToastUtil.show(context, '该书籍尚未关联EPUB数据请关联后使用');
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EpubHighlightsPage(
bookId: readerBook['id'] as String,
book: readerBook,
),
),
);
}
String _formatDate(DateTime date) { String _formatDate(DateTime date) {
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
@@ -1191,6 +1237,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
void _navigateToEdit(BuildContext context) { void _navigateToEdit(BuildContext context) {
final provider = context.read<AppProvider>(); final provider = context.read<AppProvider>();
Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) { Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) {
provider.setEditRefresh();
provider.loadBooks(); provider.loadBooks();
}); });
} }

View File

@@ -27,8 +27,11 @@ class _BookExcerptFormPageState extends State<BookExcerptFormPage> {
final _commentController = TextEditingController(); final _commentController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
String _bookTitle = '';
bool get _isEditing => widget.excerpt != null; bool get _isEditing => widget.excerpt != null;
int get _contentChars => _contentController.text.trim().length;
int get _commentChars => _commentController.text.trim().length;
@override @override
void initState() { void initState() {
@@ -38,6 +41,30 @@ class _BookExcerptFormPageState extends State<BookExcerptFormPage> {
_contentController.text = widget.excerpt!.content; _contentController.text = widget.excerpt!.content;
_commentController.text = widget.excerpt!.comment; _commentController.text = widget.excerpt!.comment;
} }
_contentController.addListener(() => setState(() {}));
_commentController.addListener(() => setState(() {}));
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_loadBookTitle();
}
void _loadBookTitle() {
setState(() {
_bookTitle = _resolveBookTitle(widget.bookId);
});
}
String _resolveBookTitle(String bookId) {
try {
final provider = AppProvider();
for (final b in provider.books) {
if (b.id == bookId) return b.title;
}
} catch (_) {}
return '';
} }
@override @override
@@ -56,102 +83,197 @@ class _BookExcerptFormPageState extends State<BookExcerptFormPage> {
appBar: AppBar( appBar: AppBar(
title: Text(_isEditing ? '编辑摘抄' : '添加摘抄'), title: Text(_isEditing ? '编辑摘抄' : '添加摘抄'),
actions: [ actions: [
TextButton( if (_isLoading)
onPressed: _isLoading ? null : _saveExcerpt, const Padding(
child: _isLoading padding: EdgeInsets.all(14),
? const SizedBox( child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
) )
: Text( else
'保存', TextButton(
style: TextStyle( onPressed: _saveExcerpt,
color: colors.onSurface, child: Text('保存', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary)),
fontWeight: FontWeight.w600,
), ),
), const SizedBox(width: 4),
),
const SizedBox(width: 8),
], ],
), ),
body: Form( body: Form(
key: _formKey, key: _formKey,
child: ListView( child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.fromLTRB(20, 8, 20, 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 章节 // 所属书籍
TextFormField( if (_bookTitle.isNotEmpty) ...[
controller: _chapterController, _buildBookInfo(colors),
decoration: InputDecoration( const SizedBox(height: 24),
labelText: '章节(可选)', ],
hintText: '例如第一章、第3节等',
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: colors.primary),
),
),
),
// 章节
_buildLabel(colors, Icons.bookmark_outlined, '章节'),
const SizedBox(height: 8),
_buildInput(
colors: colors,
controller: _chapterController,
hintText: '例如第一章、第3节',
),
const SizedBox(height: 24), const SizedBox(height: 24),
// 摘抄内容 // 摘抄内容
TextFormField( _buildLabel(colors, Icons.format_quote, '摘抄内容', required: true),
controller: _contentController, const SizedBox(height: 8),
maxLines: 8, _buildContentInput(colors),
decoration: InputDecoration(
labelText: '摘抄内容',
hintText: '输入你想要摘抄的内容...',
alignLabelWithHint: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: colors.primary),
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入摘抄内容';
}
return null;
},
),
const SizedBox(height: 24), const SizedBox(height: 24),
// 评论/感悟 // 我的感悟
TextFormField( _buildLabel(colors, Icons.lightbulb_outline, '我的感悟'),
controller: _commentController, const SizedBox(height: 8),
maxLines: 5, _buildCommentInput(colors),
decoration: InputDecoration(
labelText: '我的感悟(可选)',
hintText: '记录你对这段内容的思考和感悟...',
alignLabelWithHint: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: colors.primary),
),
),
),
], ],
), ),
), ),
),
); );
} }
// ── 所属书籍 ──
Widget _buildBookInfo(ColorScheme colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(Icons.menu_book_rounded, size: 18, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_bookTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.7)),
),
),
],
),
);
}
// ── 标签 ──
Widget _buildLabel(ColorScheme colors, IconData icon, String title, {bool required = false}) {
return Row(
children: [
Icon(icon, size: 16, color: colors.onSurface.withValues(alpha: 0.4)),
const SizedBox(width: 6),
Text(title, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))),
if (required) ...[
const SizedBox(width: 2),
Text(' *', style: TextStyle(fontSize: 13, color: colors.error)),
],
],
);
}
// ── 章节输入框 ──
Widget _buildInput({
required ColorScheme colors,
required TextEditingController controller,
String? hintText,
}) {
return TextFormField(
controller: controller,
style: TextStyle(fontSize: 15, color: colors.onSurface),
decoration: _inputDecoration(colors, hintText: hintText),
);
}
// ── 摘抄内容输入框 ──
Widget _buildContentInput(ColorScheme colors) {
return TextFormField(
controller: _contentController,
maxLines: null,
minLines: 6,
style: TextStyle(fontSize: 15, height: 1.8, color: colors.onSurface),
textAlignVertical: TextAlignVertical.top,
decoration: _inputDecoration(
colors,
hintText: '在这里粘贴或输入书中的原文段落…',
charCount: _contentChars,
),
validator: (value) {
if (value == null || value.trim().isEmpty) return '请输入摘抄内容';
return null;
},
);
}
// ── 感悟输入框 ──
Widget _buildCommentInput(ColorScheme colors) {
return TextFormField(
controller: _commentController,
maxLines: null,
minLines: 3,
style: TextStyle(fontSize: 15, height: 1.8, color: colors.onSurface),
textAlignVertical: TextAlignVertical.top,
decoration: _inputDecoration(
colors,
hintText: '记录思考、联想或评论…',
charCount: _commentChars,
),
);
}
// ── 统一输入框样式 ──
InputDecoration _inputDecoration(ColorScheme colors, {String? hintText, int? charCount}) {
return InputDecoration(
hintText: hintText,
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.25)),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.primary, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: colors.error, width: 1.5),
),
counterText: '',
suffix: charCount != null
? Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('$charCount字', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
)
: null,
);
}
// ─── 保存逻辑 ─────────────────────────────────────────────
Future<void> _saveExcerpt() async { Future<void> _saveExcerpt() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final now = DateTime.now(); final now = DateTime.now();
final excerpt = BookExcerpt( final excerpt = BookExcerpt(
@@ -164,25 +286,19 @@ class _BookExcerptFormPageState extends State<BookExcerptFormPage> {
createdAt: _isEditing ? widget.excerpt!.createdAt : now, createdAt: _isEditing ? widget.excerpt!.createdAt : now,
updatedAt: now, updatedAt: now,
); );
if (_isEditing) { if (_isEditing) {
await context.read<AppProvider>().updateBookExcerpt(excerpt); await context.read<AppProvider>().updateBookExcerpt(excerpt);
} else { } else {
await context.read<AppProvider>().addBookExcerpt(excerpt); await context.read<AppProvider>().addBookExcerpt(excerpt);
} }
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context);
ToastUtil.show(context, _isEditing ? '摘抄已更新' : '摘抄已添加'); ToastUtil.show(context, _isEditing ? '摘抄已更新' : '摘抄已添加');
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) ToastUtil.show(context, '保存失败: $e');
ToastUtil.show(context, '保存失败: $e');
}
} finally { } finally {
if (mounted) { if (mounted) setState(() => _isLoading = false);
setState(() => _isLoading = false);
}
} }
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import '../../utils/epub/reader_dao.dart';
import 'book_excerpt_form_page.dart'; import 'book_excerpt_form_page.dart';
/// 书籍摘抄列表页面 /// 书籍摘抄列表页面
@@ -29,13 +30,15 @@ class _BookExcerptsPageState extends State<BookExcerptsPage> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final excerpts = await context.read<AppProvider>().getBookExcerpts(widget.book.id); final excerpts = await context.read<AppProvider>().getBookExcerpts(widget.book.id);
if (mounted) {
setState(() { setState(() {
_excerpts = excerpts; _excerpts = excerpts;
_isLoading = false; _isLoading = false;
}); });
}
} catch (e) { } catch (e) {
setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
ToastUtil.show(context, '加载失败: $e'); if (mounted) ToastUtil.show(context, '加载失败: $e');
} }
} }
@@ -45,282 +48,280 @@ class _BookExcerptsPageState extends State<BookExcerptsPage> {
return Scaffold( return Scaffold(
backgroundColor: colors.surface, backgroundColor: colors.surface,
appBar: AppBar( appBar: AppBar(
title: const Text('摘抄'), title: Column(
actions: [ crossAxisAlignment: CrossAxisAlignment.start,
IconButton( children: [
icon: const Icon(Icons.add), const Text('摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
onPressed: () => _navigateToAddExcerpt(), if (_excerpts.isNotEmpty)
), Text('${_excerpts.length}', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(width: 8),
], ],
), ),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _navigateToAddExcerpt,
icon: const Icon(Icons.add, size: 20),
label: const Text('添加摘抄'),
),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? Center(child: CircularProgressIndicator(color: colors.primary))
: _excerpts.isEmpty : _excerpts.isEmpty
? _buildEmptyState() ? _buildEmptyState(colors)
: _buildExcerptList(), : _buildExcerptList(colors),
); );
} }
Widget _buildEmptyState() { // ── 空状态 ──
final colors = Theme.of(context).colorScheme;
Widget _buildEmptyState(ColorScheme colors) {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.format_quote_outlined,
size: 40,
color: colors.onSurface.withValues(alpha: 0.25),
), ),
child: Icon(Icons.format_quote_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text('暂无摘抄', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
'暂无摘抄', const SizedBox(height: 8),
style: TextStyle( Text('记录书中触动人心的文字', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25))),
fontSize: 16,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
const SizedBox(height: 24),
InkWell(
onTap: () => _navigateToAddExcerpt(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: colors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'添加记录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onPrimary,
),
),
),
),
], ],
), ),
),
); );
} }
/// 按章节分组摘抄数据 // ── 摘抄列表 ──
Map<String, List<BookExcerpt>> _groupExcerptsByChapter() {
final Map<String, List<BookExcerpt>> groups = {};
for (final excerpt in _excerpts) {
final chapter = excerpt.chapter.isEmpty ? '未分类' : excerpt.chapter;
if (!groups.containsKey(chapter)) {
groups[chapter] = [];
}
groups[chapter]!.add(excerpt);
}
// 每个章节内的摘抄按时间排序(新的在前)
for (final chapter in groups.keys) {
groups[chapter]!.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
return groups;
}
Widget _buildExcerptList() {
final groups = _groupExcerptsByChapter();
final chapters = groups.keys.toList();
Widget _buildExcerptList(ColorScheme colors) {
final chapters = _groupExcerptsByChapter().keys.toList();
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 8, 16, 80),
itemCount: chapters.length, itemCount: chapters.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final chapter = chapters[index]; final chapter = chapters[index];
final excerpts = groups[chapter]!; final excerpts = _groupExcerptsByChapter()[chapter]!;
return _buildChapterSection(chapter, excerpts); return _buildChapterSection(chapter, excerpts, colors, index);
}, },
); );
} }
Widget _buildChapterSection(String chapter, List<BookExcerpt> excerpts) { /// 按章节分组
final colors = Theme.of(context).colorScheme; Map<String, List<BookExcerpt>> _groupExcerptsByChapter() {
return Column( final groups = <String, List<BookExcerpt>>{};
crossAxisAlignment: CrossAxisAlignment.start, for (final e in _excerpts) {
children: [ final chapter = e.chapter.isEmpty ? '未分类' : e.chapter;
// 章节标题 (groups[chapter] ??= []).add(e);
Container( }
width: double.infinity, for (final key in groups.keys) {
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), groups[key]!.sort((a, b) => b.createdAt.compareTo(a.createdAt));
decoration: BoxDecoration( }
color: colors.surfaceContainerHighest, return groups;
border: Border(
left: BorderSide(color: colors.primary, width: 4),
),
),
child: Text(
chapter,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
),
const SizedBox(height: 12),
// 该章节下的摘抄列表
...excerpts.map((excerpt) => _buildExcerptItem(excerpt)),
const SizedBox(height: 24),
],
);
} }
Widget _buildExcerptItem(BookExcerpt excerpt) { // ── 章节分组 ──
final colors = Theme.of(context).colorScheme;
return Container( Widget _buildChapterSection(String chapter, List<BookExcerpt> excerpts, ColorScheme colors, int chapterIndex) {
margin: const EdgeInsets.only(bottom: 8), return Padding(
padding: const EdgeInsets.all(12), padding: EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
border: Border.all(color: colors.outline),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 摘抄内容 // 章节标题
Text( Padding(
excerpt.content, padding: const EdgeInsets.only(left: 4, bottom: 12),
maxLines: 3, child: Row(
overflow: TextOverflow.ellipsis, children: [
style: TextStyle(
fontSize: 14,
color: colors.onSurface,
height: 1.5,
),
),
// 评论/感悟
if (excerpt.comment.isNotEmpty) ...[
const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: 3,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, color: colors.primary,
borderRadius: BorderRadius.circular(1.5),
),
),
const SizedBox(width: 8),
Text(chapter, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
), ),
child: Text( child: Text(
excerpt.comment, '${excerpts.length}',
maxLines: 1, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.primary),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.6),
),
),
),
],
const SizedBox(height: 8),
// 底部:时间 + 操作按钮
Row(
children: [
Text(
_formatDate(excerpt.createdAt),
style: TextStyle(
fontSize: 11,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
const Spacer(),
// 编辑按钮
GestureDetector(
onTap: () => _navigateToEditExcerpt(excerpt),
child: Icon(
Icons.edit_outlined,
size: 16,
color: colors.onSurface.withValues(alpha: 0.4),
),
),
const SizedBox(width: 12),
// 删除按钮
GestureDetector(
onTap: () => _showDeleteDialog(excerpt),
child: Icon(
Icons.delete_outline,
size: 16,
color: colors.error,
), ),
), ),
], ],
), ),
),
// 摘抄卡片
...excerpts.map((excerpt) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _buildExcerptCard(excerpt, colors),
)),
], ],
), ),
); );
} }
String _formatDate(DateTime date) { // ── 摘抄卡片 ──
return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
Widget _buildExcerptCard(BookExcerpt excerpt, ColorScheme colors) {
return Dismissible(
key: ValueKey(excerpt.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: colors.error,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (direction) => _showDeleteDialog(excerpt),
child: Material(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _navigateToEditExcerpt(excerpt),
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 14, 14, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 引号 + 摘抄内容
Stack(
children: [
Positioned(
left: -6,
top: -6,
child: Text(
'"',
style: TextStyle(
fontSize: 32,
color: colors.primary.withValues(alpha: 0.15),
fontWeight: FontWeight.bold,
height: 1,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
excerpt.content,
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.8,
),
),
),
],
),
// 感悟
if (excerpt.comment.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(color: colors.primary.withValues(alpha: 0.3), width: 2),
),
),
child: Text(
excerpt.comment,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.55),
height: 1.6,
),
),
),
],
// 底部:日期 + 操作
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.25)),
const SizedBox(width: 4),
Text(
_formatDate(excerpt.createdAt),
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
),
const Spacer(),
GestureDetector(
onTap: () => _navigateToEditExcerpt(excerpt),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Icon(Icons.edit_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.35)),
),
),
],
),
],
),
),
),
),
);
} }
String _formatDate(DateTime date) => '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}';
void _navigateToAddExcerpt() { void _navigateToAddExcerpt() {
Navigator.push( Navigator.push(context, MaterialPageRoute(builder: (ctx) => BookExcerptFormPage(bookId: widget.book.id))).then((_) => _loadExcerpts());
context,
MaterialPageRoute(
builder: (context) => BookExcerptFormPage(bookId: widget.book.id),
),
).then((_) => _loadExcerpts());
} }
void _navigateToEditExcerpt(BookExcerpt excerpt) { void _navigateToEditExcerpt(BookExcerpt excerpt) {
Navigator.push( Navigator.push(context, MaterialPageRoute(builder: (ctx) => BookExcerptFormPage(bookId: widget.book.id, excerpt: excerpt))).then((_) => _loadExcerpts());
context,
MaterialPageRoute(
builder: (context) => BookExcerptFormPage(
bookId: widget.book.id,
excerpt: excerpt,
),
),
).then((_) => _loadExcerpts());
} }
void _showDeleteDialog(BookExcerpt excerpt) { Future<bool?> _showDeleteDialog(BookExcerpt excerpt) {
showDialog( final colors = Theme.of(context).colorScheme;
return showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
final colors = Theme.of(context).colorScheme;
return AlertDialog( return AlertDialog(
backgroundColor: colors.surface, backgroundColor: colors.surface,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), title: const Text('删除摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条摘抄吗?删除后可在回收站恢复。', content: Text('确定删除这条摘抄吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
TextButton( TextButton(
onPressed: () => Navigator.pop(context),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
),
ElevatedButton(
onPressed: () async { onPressed: () async {
Navigator.pop(context, true);
final readerBook = await ReaderDao().getReaderBookByBookId(widget.book.id);
if (readerBook != null) {
await ReaderDao().deleteExcerptHighlightByContent(
readerBook['id'] as String,
excerpt.content,
);
}
await context.read<AppProvider>().removeBookExcerpt(excerpt.id); await context.read<AppProvider>().removeBookExcerpt(excerpt.id);
Navigator.pop(context);
_loadExcerpts(); _loadExcerpts();
ToastUtil.show(context, '已删除'); if (mounted) ToastUtil.show(context, '已删除');
}, },
style: ElevatedButton.styleFrom( child: Text('删除', style: TextStyle(color: colors.error, fontWeight: FontWeight.w600)),
backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('删除'),
), ),
], ],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
); );
}, },
); );

View File

@@ -32,7 +32,9 @@ class _BookTabPageState extends State<BookTabPage> {
late ScrollController _scrollController; late ScrollController _scrollController;
AppProvider? _provider; AppProvider? _provider;
int _lastScrollSignal = 0; int _lastScrollSignal = 0;
int _lastEditRefreshCounter = 0;
int _prevBookCount = -1; int _prevBookCount = -1;
double _dragDelta = 0.0; // 当前拖动偏移量
void _onBookTap(Book book) { void _onBookTap(Book book) {
if (Breakpoint.isWideContent(context)) { if (Breakpoint.isWideContent(context)) {
@@ -79,10 +81,14 @@ class _BookTabPageState extends State<BookTabPage> {
// 仅在数据实际变化时刷新列表避免底部导航栏显隐等UI变化误触发重载 // 仅在数据实际变化时刷新列表避免底部导航栏显隐等UI变化误触发重载
final statusChanged = provider.bookStatusIndex != _lastStatusIndex; final statusChanged = provider.bookStatusIndex != _lastStatusIndex;
final countChanged = provider.books.length != _prevBookCount; final countChanged = provider.books.length != _prevBookCount;
if (statusChanged || countChanged) { final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter;
if (statusChanged || countChanged || editRefreshed) {
_prevBookCount = provider.books.length; _prevBookCount = provider.books.length;
_loadFirst(); _loadFirst();
} }
if (editRefreshed) {
_lastEditRefreshCounter = provider.editRefreshCounter;
}
} }
void _onScroll() { void _onScroll() {
@@ -140,11 +146,37 @@ class _BookTabPageState extends State<BookTabPage> {
Widget _buildBody(BuildContext context) { Widget _buildBody(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
return Consumer<AppProvider>(builder: (context, provider, _) {
// 用 GestureDetector 包裹,左右滑动切换状态
return GestureDetector(
onHorizontalDragStart: (_) => _dragDelta = 0.0,
onHorizontalDragUpdate: (details) => setState(() => _dragDelta += details.primaryDelta ?? 0),
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity;
if ((velocity ?? 0).abs() < 80) {
setState(() => _dragDelta = 0.0);
return;
}
final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个
final provider = context.read<AppProvider>();
final currentIndex = provider.bookStatusIndex;
final newIndex = (currentIndex + direction + 3) % 3;
setState(() => _dragDelta = 0.0);
provider.setBookStatusIndex(newIndex);
},
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _dragDelta.clamp(-100.0, 100.0)),
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, value, child) {
return Transform.translate(offset: Offset(value, 0), child: child);
},
child: Consumer<AppProvider>(builder: (context, provider, _) {
if (_initialized && provider.bookStatusIndex != _lastStatusIndex) { if (_initialized && provider.bookStatusIndex != _lastStatusIndex) {
_lastStatusIndex = provider.bookStatusIndex; _lastStatusIndex = provider.bookStatusIndex;
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
} }
final content = () {
if (_items.isEmpty && _isLoading) return _buildSkeleton(); if (_items.isEmpty && _isLoading) return _buildSkeleton();
if (_items.isEmpty) { if (_items.isEmpty) {
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
@@ -152,7 +184,11 @@ class _BookTabPageState extends State<BookTabPage> {
} }
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
child: _layoutStyle == 1 ? _buildListView() : _buildGridView()); child: _layoutStyle == 1 ? _buildListView() : _buildGridView());
}); }();
return content;
}),
),
);
} }
Widget _buildGridView() { Widget _buildGridView() {

View File

@@ -3,6 +3,7 @@ import '../../utils/book/book_dao.dart';
import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../widgets/fade_in_local_image.dart';
/// 选择关联书籍页面(带搜索功能) /// 选择关联书籍页面(带搜索功能)
class BookLinkPage extends StatefulWidget { class BookLinkPage extends StatefulWidget {
@@ -18,6 +19,7 @@ class _BookLinkPageState extends State<BookLinkPage> {
final BookDao _bookDao = BookDao(); final BookDao _bookDao = BookDao();
final ReaderDao _readerDao = ReaderDao(); final ReaderDao _readerDao = ReaderDao();
final TextEditingController _searchCtrl = TextEditingController(); final TextEditingController _searchCtrl = TextEditingController();
final FocusNode _searchFocus = FocusNode();
List<Book> _allBooks = []; List<Book> _allBooks = [];
List<Book> _filteredBooks = []; List<Book> _filteredBooks = [];
@@ -33,6 +35,7 @@ class _BookLinkPageState extends State<BookLinkPage> {
@override @override
void dispose() { void dispose() {
_searchCtrl.dispose(); _searchCtrl.dispose();
_searchFocus.dispose();
super.dispose(); super.dispose();
} }
@@ -88,52 +91,17 @@ class _BookLinkPageState extends State<BookLinkPage> {
body: Column( body: Column(
children: [ children: [
// 搜索栏 // 搜索栏
Padding( _buildSearchBar(colors),
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( Expanded(
child: _isLoading child: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary)) ? Center(child: CircularProgressIndicator(color: colors.primary))
: _filteredBooks.isEmpty : _filteredBooks.isEmpty
? Center( ? _buildEmptyState(colors)
child: Text( : ListView.builder(
_query.isEmpty ? '暂无书籍' : '未找到匹配的书籍', padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
)
: ListView.separated(
itemCount: _filteredBooks.length, itemCount: _filteredBooks.length,
separatorBuilder: (_, __) => itemBuilder: (_, index) => _buildBookCard(_filteredBooks[index], colors, index),
Divider(height: 0.5, indent: 72, color: colors.outlineVariant),
itemBuilder: (_, index) => _buildBookTile(_filteredBooks[index], colors),
), ),
), ),
], ],
@@ -141,27 +109,153 @@ class _BookLinkPageState extends State<BookLinkPage> {
); );
} }
Widget _buildBookTile(Book book, ColorScheme colors) { // ── 搜索栏 ──
Widget _buildSearchBar(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Container(
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _searchCtrl,
focusNode: _searchFocus,
onChanged: _onSearch,
style: TextStyle(fontSize: 14, color: colors.onSurface),
decoration: InputDecoration(
hintText: '搜索书名或作者…',
hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 14, right: 10),
child: Icon(Icons.search_rounded, size: 20, color: colors.onSurface.withValues(alpha: 0.35)),
),
prefixIconConstraints: const BoxConstraints(minWidth: 44, minHeight: 44),
suffixIcon: _query.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
_searchCtrl.clear();
_onSearch('');
},
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(Icons.close_rounded, size: 14, color: colors.onSurface.withValues(alpha: 0.4)),
),
),
)
: null,
suffixIconConstraints: const BoxConstraints(minWidth: 32, minHeight: 32),
filled: true,
fillColor: colors.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colors.primary.withValues(alpha: 0.5), width: 1.5),
),
),
),
),
);
}
// ── 空状态 ──
Widget _buildEmptyState(ColorScheme colors) {
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
_query.isEmpty ? Icons.library_books_outlined : Icons.search_off_rounded,
size: 36,
color: colors.onSurface.withValues(alpha: 0.2),
),
),
const SizedBox(height: 16),
Text(
_query.isEmpty ? '暂无书籍' : '未找到匹配的书籍',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
),
);
}
// ── 书籍卡片 ──
Widget _buildBookCard(Book book, ColorScheme colors, int index) {
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty; final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
return InkWell( return Padding(
padding: EdgeInsets.only(bottom: index < _filteredBooks.length - 1 ? 10 : 0),
child: Material(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => _selectBook(book), onTap: () => _selectBook(book),
child: Padding( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(
// 封面缩略图 crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Container( Container(
width: 44, height: 62, width: 48,
height: 68,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHighest, color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: hasCover child: hasCover
? Image.asset(book.coverPath!, fit: BoxFit.cover, ? FadeInLocalImage(
errorBuilder: (_, __, ___) => path: book.coverPath,
Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2))) fit: BoxFit.cover,
: Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)), )
: Center(
child: Icon(Icons.menu_book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)),
),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
// 书名 + 作者 // 书名 + 作者
@@ -169,18 +263,63 @@ class _BookLinkPageState extends State<BookLinkPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis, const SizedBox(height: 2),
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)), Text(
book.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3),
),
if (book.authors.isNotEmpty) ...[ if (book.authors.isNotEmpty) ...[
const SizedBox(height: 3), const SizedBox(height: 4),
Text(book.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis, Text(
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), book.authors.join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4)),
),
],
if (book.genres.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
children: book.genres.take(2).map((g) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(
g,
style: TextStyle(fontSize: 10, color: colors.primary.withValues(alpha: 0.6)),
),
)).toList(),
),
], ],
], ],
), ),
), ),
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)), const SizedBox(width: 8),
]), // 关联图标
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.link_rounded, size: 14, color: colors.primary),
const SizedBox(width: 4),
Text('关联', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.primary)),
],
),
),
],
),
),
),
), ),
); );
} }

View File

@@ -139,10 +139,18 @@ class BookSession {
_tocItemFallback.clear(); _tocItemFallback.clear();
for (int i = 0; i < _spine.length; i++) { for (int i = 0; i < _spine.length; i++) {
_tocItemFallback.add(fallback); _tocItemFallback.add(fallback);
final anchors = _spineToAnchorsMap[_spine[i].href] ?? []; final spineHref = _spine[i].href;
final anchors = _spineToAnchorsMap[spineHref] ?? [];
if (anchors.isNotEmpty) { if (anchors.isNotEmpty) {
final lastHref = '${_spine[i].href}#${anchors.last}'; // 先查 spine href + anchor再回退到纯 spine href
final idx = _hrefToTocIndexMap[lastHref]; final lastHref = '$spineHref#${anchors.last}';
final idx = _hrefToTocIndexMap[lastHref] ?? _hrefToTocIndexMap[spineHref];
if (idx != null) {
fallback = _flatToc[idx];
}
} else {
// 没有锚点,直接用 spine href 查
final idx = _hrefToTocIndexMap[spineHref];
if (idx != null) { if (idx != null) {
fallback = _flatToc[idx]; fallback = _flatToc[idx];
} }
@@ -281,6 +289,38 @@ class BookSession {
return null; return null;
} }
/// 通过 spine 索引直接获取章节标题(不依赖 scroll anchors用文件名匹配
String getChapterTitleForSpine(int spineIndex) {
if (spineIndex < 0 || spineIndex >= _spine.length) return '';
final spineHref = _spine[spineIndex].href;
final spineFileName = spineHref.split('/').last.toLowerCase();
// 1. 精确匹配 href
for (final entry in _flatToc) {
final tocHref = entry.href.split('#')[0];
if (tocHref == spineHref) return entry.label;
}
// 2. 文件名匹配(防止路径前缀不一致)
for (final entry in _flatToc) {
final tocFileName = entry.href.split('#')[0].split('/').last.toLowerCase();
if (tocFileName == spineFileName && entry.label.isNotEmpty) {
return entry.label;
}
}
// 3. 回退到 _tocItemFallback
if (spineIndex < _tocItemFallback.length) {
final label = _tocItemFallback[spineIndex].label;
if (label.isNotEmpty && label != (bookData['title'] as String? ?? '')) {
return label;
}
}
return '';
}
/// Find spine index from a URL string (virtual epub:// or relative path). /// Find spine index from a URL string (virtual epub:// or relative path).
int? findSpineIndexByUrl(String url) { int? findSpineIndexByUrl(String url) {
String path; String path;

View File

@@ -1,16 +1,22 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/reader_dao.dart';
import '../../utils/epub/epub_parser.dart'; import '../../utils/epub/epub_parser.dart';
import '../../utils/epub/reader_models.dart'; import '../../utils/epub/reader_models.dart';
import '../../utils/book/book_dao.dart'; import '../../utils/book/book_dao.dart';
import '../../utils/book/book_excerpt_dao.dart'; import '../../utils/book/book_excerpt_dao.dart';
import '../../utils/toast_util.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../book/book_detail_page.dart'; import '../book/book_detail_page.dart';
import '../book/book_excerpts_page.dart';
import 'book_link_page.dart'; import 'book_link_page.dart';
import 'epub_edit_page.dart'; import 'epub_edit_page.dart';
import 'epub_highlights_page.dart';
import 'highlight_detail_sheet.dart';
import 'reader_screen.dart'; import 'reader_screen.dart';
/// EPUB 书籍详情页 /// EPUB 书籍详情页
@@ -37,6 +43,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
EpubBookInfo? _bookInfo; EpubBookInfo? _bookInfo;
bool _descriptionExpanded = false; bool _descriptionExpanded = false;
Future<List<BookExcerpt>>? _excerptsFuture; Future<List<BookExcerpt>>? _excerptsFuture;
Future<List<Map<String, dynamic>>>? _highlightsFuture;
@override @override
void initState() { void initState() {
@@ -47,6 +54,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
if (linkedBookId.isNotEmpty) { if (linkedBookId.isNotEmpty) {
_excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId); _excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId);
} }
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
} }
Future<void> _loadBookInfo() async { Future<void> _loadBookInfo() async {
@@ -58,7 +66,18 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
Future<void> _refreshBook() async { Future<void> _refreshBook() async {
final updated = await _dao.getReaderBookById(widget.bookId); final updated = await _dao.getReaderBookById(widget.bookId);
if (mounted && updated != null) setState(() => _book = updated); if (mounted && updated != null) {
final linkedBookId = updated['book_id'] as String? ?? '';
setState(() {
_book = updated;
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
if (linkedBookId.isNotEmpty) {
_excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId);
} else {
_excerptsFuture = null;
}
});
}
} }
void _navigateToReader() { void _navigateToReader() {
@@ -202,12 +221,20 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
child: _buildLinkedBookCard(colors), child: _buildLinkedBookCard(colors),
), ),
// ── 句读(高亮)──
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildHighlightsSectionHeader(colors),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
child: _buildHighlightsList(colors),
),
// ── 书籍摘抄(仅关联书籍时显示)── // ── 书籍摘抄(仅关联书籍时显示)──
if ((_book['book_id'] as String? ?? '').isNotEmpty) ...[ if ((_book['book_id'] as String? ?? '').isNotEmpty) ...[
Divider(height: 0.5, thickness: 0.5, color: colors.outline), Divider(height: 0.5, thickness: 0.5, color: colors.outline),
_buildSectionHeader('书籍摘抄', colors), _buildExcerptsSectionHeader(colors),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), padding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
child: _buildExcerptsList(colors), child: _buildExcerptsList(colors),
), ),
], ],
@@ -366,6 +393,7 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data!.isEmpty) { if (!snapshot.hasData || snapshot.data!.isEmpty) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surfaceContainerHigh, color: colors.surfaceContainerHigh,
@@ -382,71 +410,398 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
); );
} }
final excerpts = snapshot.data!; final excerpts = snapshot.data!;
final showCount = excerpts.length > 5 ? 5 : excerpts.length; return SizedBox(
return Column( height: 140,
children: [ child: ListView.separated(
for (int i = 0; i < showCount; i++) scrollDirection: Axis.horizontal,
Padding( padding: const EdgeInsets.symmetric(horizontal: 16),
padding: EdgeInsets.only(bottom: i < showCount - 1 ? 8 : 0), itemCount: excerpts.length,
child: Container( separatorBuilder: (_, __) => const SizedBox(width: 10),
width: double.infinity, itemBuilder: (context, i) => _buildExcerptCard(excerpts[i], colors),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
), ),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 3,
height: 16,
margin: const EdgeInsets.only(top: 2, right: 10),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(1.5),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
excerpts[i].content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75), height: 1.6),
),
if (excerpts[i].chapter.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
excerpts[i].chapter,
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
),
],
),
),
],
),
),
),
if (excerpts.length > 5)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${excerpts.length} 条摘抄',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
),
),
],
); );
}, },
); );
} }
Widget _buildExcerptCard(BookExcerpt excerpt, ColorScheme colors) {
final hasComment = excerpt.comment.isNotEmpty;
return GestureDetector(
onTap: () => _showExcerptDetail(excerpt, colors),
child: Container(
width: 160,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:图标 + 章节标签
Row(
children: [
Icon(Icons.menu_book_outlined, size: 14, color: colors.primary.withValues(alpha: 0.6)),
const Spacer(),
if (excerpt.chapter.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
),
child: Text(
excerpt.chapter,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 9, color: colors.primary.withValues(alpha: 0.7), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 8),
// 摘抄内容
Expanded(
child: Text(
excerpt.content,
maxLines: hasComment ? 3 : 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.8), height: 1.6),
),
),
// 底部:感悟标记 + 日期
Row(
children: [
if (hasComment) ...[
Icon(Icons.lightbulb_outline, size: 10, color: colors.primary.withValues(alpha: 0.4)),
const SizedBox(width: 3),
Text('有感悟', style: TextStyle(fontSize: 9, color: colors.primary.withValues(alpha: 0.4))),
],
const Spacer(),
Text(
_formatDate(excerpt.createdAt.toIso8601String()),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
],
),
),
);
}
// ── 句读(高亮)──
Widget _buildHighlightsSectionHeader(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 10),
child: Row(children: [
Container(width: 4, height: 14,
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('句读', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
TextButton(
onPressed: _navigateToHighlightsPage,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('详情', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
],
),
),
]),
);
}
// ── 书籍摘抄 ──
Widget _buildExcerptsSectionHeader(ColorScheme colors) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 8, 10),
child: Row(children: [
Container(width: 4, height: 14,
decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 8),
Text('书籍摘抄', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
TextButton(
onPressed: _navigateToExcerptsPage,
style: TextButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(horizontal: 12),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('详情', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.5)),
],
),
),
]),
);
}
Widget _buildHighlightsList(ColorScheme colors) {
return FutureBuilder<List<Map<String, dynamic>>>(
future: _highlightsFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
final highlights = snapshot.data!
.where((h) => h['color'] != 'excerpt')
.toList();
if (highlights.isEmpty) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.highlight_outlined, size: 18, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(width: 8),
Text('暂无句读', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
return SizedBox(
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: highlights.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, i) => _buildHighlightCard(highlights[i], colors),
),
);
},
);
}
Widget _buildHighlightCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showHighlightDetail(highlight, colors),
child: Container(
width: 160,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部:引号图标 + 章节标签
Row(
children: [
Icon(Icons.format_quote, size: 14, color: const Color(0xFFFFC107)),
const Spacer(),
if (chapterNum != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 9, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 8),
// 高亮文本
Expanded(
child: Text(
content,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.8), height: 1.6),
),
),
// 底部:日期
if (createdAt.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
),
],
),
),
);
}
/// 摘抄详情弹窗
void _showExcerptDetail(BookExcerpt excerpt, ColorScheme colors) {
showExcerptDetailSheet(
context,
content: excerpt.content,
chapter: excerpt.chapter,
comment: excerpt.comment,
createdAt: excerpt.createdAt,
bookTitle: _book['title'] as String? ?? '',
onDelete: () => _deleteExcerpt(excerpt, colors),
);
}
/// 删除摘抄(同步删除蓝色高亮)
Future<void> _deleteExcerpt(BookExcerpt excerpt, ColorScheme colors) async {
final confirmed = showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条摘抄吗?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('删除', style: TextStyle(color: colors.error))),
],
),
);
if (await confirmed != true) return;
// 同步删除蓝色高亮(用 books 表 ID 查找关联的 reader_book
final linkedBookId = _book['book_id'] as String? ?? '';
if (linkedBookId.isNotEmpty) {
final readerBook = await _dao.getReaderBookByBookId(linkedBookId);
if (readerBook != null) {
await _dao.deleteExcerptHighlightByContent(
readerBook['id'] as String,
excerpt.content,
);
}
}
// 删除摘抄记录
await BookExcerptDao().deleteExcerpt(excerpt.id);
if (mounted) {
setState(() {
_refreshBook();
});
ToastUtil.show(context, '已删除');
}
}
/// 句读详情弹窗(使用共享组件)
void _showHighlightDetail(Map<String, dynamic> highlight, ColorScheme colors) {
showHighlightDetailSheet(
context,
highlight: highlight,
book: _book,
onDelete: () => _deleteHighlight(highlight['id'] as int, colors),
);
}
Future<void> _deleteHighlight(int id, ColorScheme colors) async {
final confirmed = showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除句读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('删除', style: TextStyle(color: colors.error)),
),
],
),
);
if (await confirmed != true) return;
await _dao.deleteHighlight(id);
if (mounted) {
setState(() {
_highlightsFuture = _dao.getHighlightsByBookId(widget.bookId);
});
}
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
void _navigateToHighlight(Map<String, dynamic> highlight) {
final chapter = int.tryParse(highlight['chapter'] as String? ?? '') ?? 0;
final xpath = _extractStartXPath(highlight['cfi'] as String? ?? '');
final text = highlight['content'] as String? ?? '';
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,
initialSpineIndex: chapter,
scrollToXPath: xpath,
scrollToText: text,
),
),
).then((_) => _refreshBook());
}
/// 从 cfi JSON 中提取 startXPath
String? _extractStartXPath(String cfi) {
if (cfi.isEmpty) return null;
try {
final decoded = jsonDecode(cfi) as Map<String, dynamic>;
return decoded['startXPath'] as String?;
} catch (_) {
return null;
}
}
void _navigateToHighlightsPage() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EpubHighlightsPage(bookId: widget.bookId, book: _book),
),
).then((_) => _refreshBook());
}
void _navigateToExcerptsPage() async {
final linkedBookId = _book['book_id'] as String? ?? '';
if (linkedBookId.isEmpty) return;
final book = await _bookDao.getBookById(linkedBookId);
if (!mounted || book == null) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookExcerptsPage(book: book),
),
).then((_) => _refreshBook());
}
void _showLinkedBookActions(ColorScheme colors, String title) { void _showLinkedBookActions(ColorScheme colors, String title) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart';
import '../../utils/user_prefs.dart';
import 'highlight_detail_sheet.dart';
/// EPUB 句读(高亮)管理页面 —— 支持瀑布流/列表模式切换
class EpubHighlightsPage extends StatefulWidget {
final String bookId;
final Map<String, dynamic> book;
const EpubHighlightsPage({
super.key,
required this.bookId,
required this.book,
});
@override
State<EpubHighlightsPage> createState() => _EpubHighlightsPageState();
}
class _EpubHighlightsPageState extends State<EpubHighlightsPage> {
final ReaderDao _dao = ReaderDao();
List<Map<String, dynamic>> _highlights = [];
bool _isLoading = true;
bool _isListMode = UserPrefs().highlightsViewMode == 1;
@override
void initState() {
super.initState();
_loadHighlights();
}
Future<void> _loadHighlights() async {
setState(() => _isLoading = true);
try {
final all = await _dao.getHighlightsByBookId(widget.bookId);
setState(() {
_highlights = all.where((h) => h['color'] != 'excerpt').toList();
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
if (mounted) ToastUtil.show(context, '加载失败: $e');
}
}
void _toggleViewMode() {
setState(() => _isListMode = !_isListMode);
UserPrefs().setHighlightsViewMode(_isListMode ? 1 : 0);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: Text('句读', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
actions: [
IconButton(
icon: Icon(
_isListMode ? Icons.grid_view_rounded : Icons.view_agenda_outlined,
size: 22,
),
tooltip: _isListMode ? '瀑布流' : '列表',
onPressed: _toggleViewMode,
),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary))
: _highlights.isEmpty
? _buildEmpty(colors)
: _isListMode
? _buildListView(colors)
: _buildMasonryView(colors),
);
}
// ─── 瀑布流模式 ───
Widget _buildMasonryView(ColorScheme colors) {
return MasonryGridView.count(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
itemCount: _highlights.length,
itemBuilder: (context, i) => _buildMasonryCard(_highlights[i], colors),
);
}
Widget _buildMasonryCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showDetail(highlight, colors),
onLongPress: () => _showDeleteConfirm(highlight['id'] as int, colors),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 高亮文本
Text(
content,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.65,
),
),
const SizedBox(height: 8),
// 底部信息行
Row(
children: [
if (chapterNum != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 9, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
if (createdAt.isNotEmpty) ...[
const SizedBox(width: 6),
Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
],
),
],
),
),
);
}
// ─── 列表模式 ───
Widget _buildListView(ColorScheme colors) {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: _highlights.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) => _buildListCard(_highlights[i], colors),
);
}
Widget _buildListCard(Map<String, dynamic> highlight, ColorScheme colors) {
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
return GestureDetector(
onTap: () => _showDetail(highlight, colors),
onLongPress: () => _showDeleteConfirm(highlight['id'] as int, colors),
child: Container(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 章节 + 日期
Row(
children: [
Icon(Icons.highlight_outlined, size: 13, color: const Color(0xFFFFC107)),
if (chapterNum != null) ...[
const SizedBox(width: 5),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 10, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
const Spacer(),
if (createdAt.isNotEmpty)
Text(
_formatDateFull(createdAt),
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3)),
),
],
),
const SizedBox(height: 10),
// 高亮文本
Text(
content,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.75,
),
),
],
),
),
);
}
// ─── 公共 ───
Widget _buildEmpty(ColorScheme colors) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.highlight_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('暂无句读', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35))),
],
),
);
}
void _showDetail(Map<String, dynamic> highlight, ColorScheme colors) {
showHighlightDetailSheet(
context,
highlight: highlight,
book: widget.book,
onDelete: () => _deleteHighlight(highlight['id'] as int),
);
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
String _formatDateFull(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
void _showDeleteConfirm(int id, ColorScheme colors) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除句读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除这条句读?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))),
TextButton(
onPressed: () { Navigator.pop(ctx); _deleteHighlight(id); },
child: Text('删除', style: TextStyle(color: colors.error)),
),
],
),
);
}
Future<void> _deleteHighlight(int id) async {
await _dao.deleteHighlight(id);
_loadHighlights();
if (mounted) ToastUtil.show(context, '已删除');
}
}

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 文本选中后弹出的工具条
class EpubSelectionToolbar extends StatelessWidget {
/// 选中文字的屏幕坐标区域
final Rect selectionRect;
/// 点击复制按钮的回调
final VoidCallback onCopy;
/// 点击高亮按钮的回调
final VoidCallback onHighlight;
/// 点击摘抄到笔记的回调
final VoidCallback onExcerpt;
/// 点击外部区域关闭
final VoidCallback onDismiss;
const EpubSelectionToolbar({
super.key,
required this.selectionRect,
required this.onCopy,
required this.onHighlight,
required this.onExcerpt,
required this.onDismiss,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
// 计算工具条位置:优先在选区上方,空间不足时放下方
final mediaQuery = MediaQuery.of(context);
final selectionTop = selectionRect.top;
final selectionBottom = selectionRect.bottom;
const toolbarHeight = 44.0;
const margin = 8.0;
final showAbove = selectionTop > toolbarHeight + margin + 80;
final top = showAbove
? selectionTop - toolbarHeight - margin
: selectionBottom + margin;
// 水平居中于选区,但不超出屏幕
const toolbarWidth = 220.0;
double left = selectionRect.center.dx - toolbarWidth / 2;
left = left.clamp(12.0, mediaQuery.size.width - toolbarWidth - 12.0);
return Stack(
children: [
// 全屏遮罩,点击关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onDismiss,
child: const SizedBox.expand(),
),
),
Positioned(
left: left,
top: top < 0 ? margin : top,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(10),
color: colors.surface,
surfaceTintColor: colors.surfaceTint,
child: SizedBox(
height: toolbarHeight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildButton(
context,
icon: Icons.highlight_outlined,
label: '高亮',
onTap: onHighlight,
color: colors.primary,
),
_divider(colors),
_buildButton(
context,
icon: Icons.copy_outlined,
label: '复制',
onTap: onCopy,
color: colors.primary,
),
_divider(colors),
_buildButton(
context,
icon: Icons.edit_note_outlined,
label: '摘抄',
onTap: onExcerpt,
color: colors.primary,
),
],
),
),
),
),
],
);
}
Widget _divider(ColorScheme colors) {
return Container(
width: 0.5,
height: 22,
color: colors.outlineVariant,
);
}
Widget _buildButton(
BuildContext context, {
required IconData icon,
required String label,
required VoidCallback onTap,
required Color color,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
color: colors.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
/// 复制文字到剪贴板的辅助函数
Future<void> copyTextToClipboard(String text) async {
await Clipboard.setData(ClipboardData(text: text));
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../utils/toast_util.dart';
/// 句读详情弹窗 —— 类似分享卡片的样式
/// 从 epub_detail_page 和 epub_highlights_page 共用
void showHighlightDetailSheet(
BuildContext context, {
required Map<String, dynamic> highlight,
required Map<String, dynamic> book,
required VoidCallback onDelete,
VoidCallback? onNavigate,
}) {
final colors = Theme.of(context).colorScheme;
final content = highlight['content'] as String? ?? '';
final chapter = highlight['chapter'] as String? ?? '';
final chapterNum = int.tryParse(chapter);
final createdAt = highlight['created_at'] as String? ?? '';
final bookTitle = book['title'] as String? ?? '';
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部装饰条
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书名 + 章节标题行
Row(
children: [
Icon(Icons.book_outlined, size: 16, color: const Color(0xFFFFC107)),
const SizedBox(width: 6),
Expanded(
child: Text(
bookTitle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chapterNum != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFFFEB3B).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${chapterNum + 1}',
style: const TextStyle(fontSize: 11, color: Color(0xFF795548), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 20),
// 引号装饰 + 高亮文本
Stack(
children: [
Positioned(
left: -4,
top: -8,
child: Text(
'"',
style: TextStyle(
fontSize: 48,
color: const Color(0xFFFFEB3B).withValues(alpha: 0.4),
fontWeight: FontWeight.bold,
height: 1,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: SelectableText(
content,
style: TextStyle(
fontSize: 15,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.8,
),
),
),
],
),
const SizedBox(height: 16),
// 日期
if (createdAt.isNotEmpty)
Row(
children: [
Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text(
_formatDate(createdAt),
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
const SizedBox(height: 24),
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 操作按钮
Row(
children: [
_buildActionButton(
colors,
icon: Icons.content_copy_outlined,
label: '复制',
onTap: () {
Clipboard.setData(ClipboardData(text: content));
Navigator.pop(ctx);
ToastUtil.show(context, '已复制');
},
),
if (onNavigate != null)
_buildActionButton(
colors,
icon: Icons.menu_book_outlined,
label: '跳转',
onTap: () {
Navigator.pop(ctx);
onNavigate();
},
),
_buildActionButton(
colors,
icon: Icons.delete_outline,
label: '删除',
onTap: () {
Navigator.pop(ctx);
onDelete();
},
isDanger: true,
),
],
),
],
),
),
],
),
);
},
);
}
Widget _buildActionButton(
ColorScheme colors, {
required IconData icon,
required String label,
required VoidCallback onTap,
bool isDanger = false,
}) {
final color = isDanger ? colors.error : colors.onSurface.withValues(alpha: 0.6);
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, color: color, fontWeight: FontWeight.w500)),
],
),
),
),
);
}
String _formatDate(String isoString) {
try {
final dt = DateTime.parse(isoString);
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
String _formatDateFromDateTime(DateTime dt) {
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
}
/// 摘抄详情弹窗 —— 和句读弹窗风格一致
void showExcerptDetailSheet(
BuildContext context, {
required String content,
required String chapter,
required String comment,
required DateTime createdAt,
required String bookTitle,
required VoidCallback onDelete,
}) {
final colors = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部装饰条
Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 书名 + 章节
Row(
children: [
Icon(Icons.book_outlined, size: 16, color: const Color(0xFF2196F3)),
const SizedBox(width: 6),
Expanded(
child: Text(
bookTitle,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (chapter.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF2196F3).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Text(
chapter,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Color(0xFF1565C0), fontWeight: FontWeight.w500),
),
),
],
),
const SizedBox(height: 20),
// 引号 + 摘抄内容
Stack(
children: [
Positioned(
left: -4,
top: -8,
child: Text(
'"',
style: TextStyle(
fontSize: 48,
color: const Color(0xFF2196F3).withValues(alpha: 0.3),
fontWeight: FontWeight.bold,
height: 1,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20),
child: SelectableText(
content,
style: TextStyle(
fontSize: 15,
color: colors.onSurface.withValues(alpha: 0.85),
height: 1.8,
),
),
),
],
),
// 感悟
if (comment.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
border: Border(
left: BorderSide(color: colors.primary.withValues(alpha: 0.4), width: 2),
),
),
child: Text(
comment,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.6),
height: 1.6,
),
),
),
],
const SizedBox(height: 16),
// 日期
Row(
children: [
Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.3)),
const SizedBox(width: 4),
Text(
_formatDateFromDateTime(createdAt),
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35)),
),
],
),
const SizedBox(height: 24),
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 操作按钮
Row(
children: [
_buildActionButton(
colors,
icon: Icons.content_copy_outlined,
label: '复制',
onTap: () {
Clipboard.setData(ClipboardData(text: content));
Navigator.pop(ctx);
ToastUtil.show(context, '已复制');
},
),
_buildActionButton(
colors,
icon: Icons.delete_outline,
label: '删除',
onTap: () {
Navigator.pop(ctx);
onDelete();
},
isDanger: true,
),
],
),
],
),
),
),
],
),
);
},
);
}

View File

@@ -24,6 +24,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
// === Cross-mixin: _ThemeMixin === // === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme(); EpubTheme getEpubTheme();
// === Cross-mixin: _TextSelectionMixin ===
Future<void> restoreHighlightsForCurrentSpine();
List<String> getAnchorsForSpine(String spinePath) { List<String> getAnchorsForSpine(String spinePath) {
return bookSession.getAnchorsForSpine(spinePath); return bookSession.getAnchorsForSpine(spinePath);
} }
@@ -109,6 +112,9 @@ mixin _SpineNavigationMixin on State<ReaderScreen> {
setState(() { setState(() {
isWebViewLoading = false; isWebViewLoading = false;
}); });
// 恢复当前 spine 的高亮(跨 mixin: _TextSelectionMixin
await restoreHighlightsForCurrentSpine();
} }
Future<void> preloadNextOf(int currentIndex) async { Future<void> preloadNextOf(int currentIndex) async {

View File

@@ -0,0 +1,315 @@
part of '../reader_screen.dart';
/// 文本选中和高亮功能的 mixin
///
/// 提供:
/// - 选中文字后弹出工具条(高亮/复制/摘抄)
/// - 高亮持久化到 [book_annotations] 表
/// - spine 加载后恢复已保存的高亮
mixin _TextSelectionMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
ReaderDao get _readerDao;
BookSession get bookSession;
ReaderRendererController get rendererController;
int get currentSpineItemIndex;
// ─── 选中工具条状态 ──────────────────────────────────────────────
String? _selectionText;
Rect? _selectionRect;
int? _selectionSpineIndex;
Offset? _startHandle;
Offset? _endHandle;
Map<String, dynamic>? _selectionInfo;
String? _pendingScrollXPath;
String? _pendingScrollText;
bool get isSelectionToolbarVisible =>
_selectionText != null && _selectionRect != null;
ReaderWebViewController? get _webViewControllerMixin =>
rendererController.webViewController;
/// 文本选中回调 —— 由 ReaderWebView 触发
void handleTextSelection(
String selectedText,
Rect rect,
int spineIndex,
double scrollRatio,
Offset? startHandle,
Offset? endHandle,
Map<String, dynamic>? selectionInfo,
) {
if (selectedText.isEmpty) {
_dismissSelectionToolbar();
return;
}
setState(() {
_selectionText = selectedText;
_selectionRect = rect;
_selectionSpineIndex = currentSpineItemIndex;
_startHandle = startHandle;
_endHandle = endHandle;
_selectionInfo = selectionInfo;
});
}
/// 关闭工具条并清除 WebView 选区
void _dismissSelectionToolbar() {
if (!isSelectionToolbarVisible) return;
_webViewControllerMixin?.clearSelection();
setState(() {
_selectionText = null;
_selectionRect = null;
_selectionSpineIndex = null;
_startHandle = null;
_endHandle = null;
_selectionInfo = null;
});
}
// ─── 手柄拖动 ───────────────────────────────────────────────────
/// 拖动起点手柄
void onDragStartHandle(DragUpdateDetails details) {
final globalPos = details.globalPosition;
_webViewControllerMixin?.extendSelection(globalPos.dx, globalPos.dy, true);
}
/// 拖动终点手柄
void onDragEndHandle(DragUpdateDetails details) {
final globalPos = details.globalPosition;
_webViewControllerMixin?.extendSelection(globalPos.dx, globalPos.dy, false);
}
// ─── 工具条按钮处理 ──────────────────────────────────────────────
/// 高亮:获取选区 DOM 信息 → 保存到 DB → 应用 <mark> → 关闭工具条
Future<void> _onHighlightButton() async {
final text = _selectionText;
final spineIndex = _selectionSpineIndex;
final info = _selectionInfo;
if (text == null || spineIndex == null || info == null) {
ToastUtil.show(context, '选区信息已失效,请重新选择');
_dismissSelectionToolbar();
return;
}
final controller = _webViewControllerMixin;
if (controller == null) {
ToastUtil.show(context, '阅读器未就绪');
return;
}
// 重复检测:相同 content + chapter 已有黄色高亮
final existing = await _readerDao.getHighlightsByBookId(widget.bookId);
final dup = existing.any((h) =>
h['chapter'] == spineIndex.toString() &&
h['content'] == text &&
h['color'] != 'excerpt');
if (dup) {
if (!mounted) return;
ToastUtil.show(context, '该内容已高亮');
_dismissSelectionToolbar();
return;
}
final now = DateTime.now().toIso8601String();
final highlight = <String, dynamic>{
'book_id': widget.bookId,
'content': text,
'cfi': jsonEncode({
'startXPath': info['startXPath'],
'startOffset': info['startOffset'],
'endXPath': info['endXPath'],
'endOffset': info['endOffset'],
}),
'chapter': spineIndex.toString(),
'type': 'highlight',
'color': 'FFEB3B',
'reader_note': '',
'created_at': now,
'updated_at': now,
};
final id = await _readerDao.saveHighlight(highlight);
// 先清除浏览器选区,防止 splitText 时活跃选区干扰 DOM 渲染(精排版书籍尤其明显)
await controller.clearSelection();
await Future.delayed(const Duration(milliseconds: 50));
await controller.applyHighlight(info, id.toString(), color: 'highlight', text: text);
if (!mounted) return;
ToastUtil.show(context, '已高亮');
_dismissSelectionToolbar();
}
/// 复制:复制到剪贴板 → 关闭工具条
Future<void> _onCopyButton() async {
final text = _selectionText;
if (text == null) return;
await copyTextToClipboard(text);
if (!mounted) return;
ToastUtil.show(context, '已复制');
_dismissSelectionToolbar();
}
/// 摘抄:保存到 book_excerpts 表 + 用蓝色高亮标注 → 关闭工具条
Future<void> _onExcerptButton() async {
final text = _selectionText;
final spineIndex = _selectionSpineIndex;
final info = _selectionInfo;
if (text == null || info == null) {
ToastUtil.show(context, '选区信息已失效,请重新选择');
_dismissSelectionToolbar();
return;
}
final linkedBookId = bookSession.book['book_id'] as String? ?? '';
if (linkedBookId.isEmpty) {
ToastUtil.show(context, '请先在 EPUB 详情页关联书籍后再摘抄');
_dismissSelectionToolbar();
return;
}
// 重复检测:相同 content + chapter 已有摘抄
final existing = await _readerDao.getHighlightsByBookId(widget.bookId);
final dup = existing.any((h) =>
h['chapter'] == (spineIndex ?? 0).toString() &&
h['content'] == text &&
h['color'] == 'excerpt');
if (dup) {
if (!mounted) return;
ToastUtil.show(context, '该内容已摘抄');
_dismissSelectionToolbar();
return;
}
final now = DateTime.now().toIso8601String();
// 获取章节标题(用于摘抄显示)
final chapterTitle = bookSession.getChapterTitleForSpine(currentSpineItemIndex);
final displayChapter = chapterTitle.isNotEmpty
? chapterTitle
: '${(spineIndex ?? 0) + 1}';
// 1. 保存到 book_excerpts 表chapter 用章节标题)
await DatabaseHelper.instance.database.then((db) async {
await db.insert('book_excerpts', {
'id': 'excerpt_${DateTime.now().millisecondsSinceEpoch}',
'book_id': linkedBookId,
'chapter': displayChapter,
'content': text,
'comment': '',
'is_deleted': 0,
'created_at': now,
'updated_at': now,
});
});
// 2. 同时保存蓝色高亮标注到 book_annotations 表chapter 用 spine 索引,用于恢复高亮)
final controller = _webViewControllerMixin;
if (controller != null) {
final highlight = <String, dynamic>{
'book_id': widget.bookId,
'content': text,
'cfi': jsonEncode({
'startXPath': info['startXPath'],
'startOffset': info['startOffset'],
'endXPath': info['endXPath'],
'endOffset': info['endOffset'],
}),
'chapter': (spineIndex ?? 0).toString(),
'type': 'highlight',
'color': 'excerpt',
'reader_note': '',
'created_at': now,
'updated_at': now,
};
final id = await _readerDao.saveHighlight(highlight);
// 先清除浏览器选区,防止 splitText 时活跃选区干扰 DOM 渲染
await controller.clearSelection();
await Future.delayed(const Duration(milliseconds: 50));
await controller.applyHighlight(info, id.toString(), color: 'excerpt', text: text);
}
if (!mounted) return;
ToastUtil.show(context, '已保存到摘抄');
_dismissSelectionToolbar();
}
// ─── 高亮恢复 ───────────────────────────────────────────────────
/// spine 加载完成后调用:先跳转到高亮位置,再恢复高亮
Future<void> restoreHighlightsForCurrentSpine() async {
final controller = _webViewControllerMixin;
if (controller == null) return;
if (currentSpineItemIndex < 0) return;
// 1. 先跳转到高亮所在位置在应用高亮之前XPath 还指向原始文本节点)
if (_pendingScrollXPath != null) {
final xpath = _pendingScrollXPath!;
final text = _pendingScrollText ?? '';
_pendingScrollXPath = null;
_pendingScrollText = null;
await Future.delayed(const Duration(milliseconds: 300));
var pageIndex = await controller.getPageIndexForXPath(xpath);
debugPrint('[MN] scrollToXPath: $xpath → page $pageIndex');
// XPath 找不到时用文本搜索回退
if (pageIndex < 0 && text.isNotEmpty) {
debugPrint('[MN] XPath failed, trying text search...');
pageIndex = await controller.getPageIndexForText(text);
debugPrint('[MN] text search → page $pageIndex');
}
if (pageIndex >= 0) {
await controller.jumpToPage(pageIndex);
debugPrint('[MN] jumped to page $pageIndex');
}
}
// 2. 查询当前 spine 应有的高亮
final highlights =
await _readerDao.getHighlightsByBookId(widget.bookId);
final spineHighlights = highlights.where((h) {
final chapter = h['chapter'] as String? ?? '';
return chapter == currentSpineItemIndex.toString();
}).toList();
// 3. 先清除 DOM 中所有旧高亮,再从 DB 重新应用
// 这样外部删除摘抄/高亮后,返回阅读器时 DOM 能同步更新
await controller.clearAllHighlights();
if (spineHighlights.isEmpty) return;
final jsHighlights = <Map<String, dynamic>>[];
for (final h in spineHighlights) {
final cfi = h['cfi'] as String? ?? '';
if (cfi.isEmpty) continue;
try {
final decoded = jsonDecode(cfi) as Map<String, dynamic>;
jsHighlights.add({
'id': (h['id'] ?? '').toString(),
'color': h['color'] == 'excerpt' ? 'excerpt' : 'highlight',
'text': h['content'] as String? ?? '',
'info': {
'startXPath': decoded['startXPath'] ?? '',
'startOffset': decoded['startOffset'] ?? 0,
'endXPath': decoded['endXPath'] ?? '',
'endOffset': decoded['endOffset'] ?? 0,
},
});
} catch (_) {}
}
if (jsHighlights.isEmpty) return;
debugPrint('[MN] restoreHighlights: ${jsHighlights.length} highlights for spine $currentSpineItemIndex');
// 重试 3 次,确保 iframe DOM 就绪
for (int attempt = 0; attempt < 3; attempt++) {
await Future.delayed(const Duration(milliseconds: 300));
if (!mounted) return;
final applied = await controller.applyHighlights(jsHighlights);
debugPrint('[MN] restoreHighlights: attempt=$attempt applied=$applied/${jsHighlights.length}');
if (applied >= jsHighlights.length) break;
}
}
}

View File

@@ -158,6 +158,7 @@ class ReaderRenderer extends StatefulWidget {
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap; final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap; final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap; final bool Function(String url) shouldHandleLinkTap;
final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map<String, dynamic>? selectionInfo)? onTextSelection;
final bool shouldShowWebView; final bool shouldShowWebView;
final EpubTheme initializeTheme; final EpubTheme initializeTheme;
final String statusBarLeftContent; final String statusBarLeftContent;
@@ -186,6 +187,7 @@ class ReaderRenderer extends StatefulWidget {
required this.onFootnoteTap, required this.onFootnoteTap,
required this.onLinkTap, required this.onLinkTap,
required this.shouldHandleLinkTap, required this.shouldHandleLinkTap,
this.onTextSelection,
required this.shouldShowWebView, required this.shouldShowWebView,
required this.initializeTheme, required this.initializeTheme,
required this.statusBarLeftContent, required this.statusBarLeftContent,
@@ -360,6 +362,11 @@ class _ReaderRendererState extends State<ReaderRenderer>
details.localPosition.dx, details.localPosition.dx,
details.localPosition.dy, details.localPosition.dy,
); );
// 编程式选中最接近长按位置的文字(触发 onTextSelection 回调)
await _webViewController.selectWordAt(
details.localPosition.dx,
details.localPosition.dy,
);
} }
@override @override
@@ -495,6 +502,7 @@ class _ReaderRendererState extends State<ReaderRenderer>
onFootnoteTap: widget.onFootnoteTap, onFootnoteTap: widget.onFootnoteTap,
onLinkTap: widget.onLinkTap, onLinkTap: widget.onLinkTap,
shouldHandleLinkTap: widget.shouldHandleLinkTap, shouldHandleLinkTap: widget.shouldHandleLinkTap,
onTextSelection: widget.onTextSelection,
), ),
shouldShowWebView: widget.shouldShowWebView, shouldShowWebView: widget.shouldShowWebView,
coverRelativePath: widget.bookSession.book['cover_path'] as String?, coverRelativePath: widget.bookSession.book['cover_path'] as String?,

View File

@@ -1,9 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../utils/database_helper.dart';
import '../../utils/epub/epub_theme.dart'; import '../../utils/epub/epub_theme.dart';
import '../../utils/epub/epub_webview_handler.dart'; import '../../utils/epub/epub_webview_handler.dart';
import '../../utils/epub/epub_stream_service.dart'; import '../../utils/epub/epub_stream_service.dart';
@@ -14,11 +15,14 @@ import '../../utils/epub/reader_dao.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import 'book_session.dart'; import 'book_session.dart';
import 'reader_renderer.dart'; import 'reader_renderer.dart';
import 'reader_webview.dart';
import 'control_panel.dart'; import 'control_panel.dart';
import 'toc_drawer.dart'; import 'toc_drawer.dart';
import 'image_viewer.dart'; import 'image_viewer.dart';
import 'footnote_popup.dart'; import 'footnote_popup.dart';
import 'search_sheet.dart'; import 'search_sheet.dart';
import 'epub_selection_toolbar.dart';
import 'selection_handles.dart';
part 'mixins/spine_navigation_mixin.dart'; part 'mixins/spine_navigation_mixin.dart';
part 'mixins/page_navigation_mixin.dart'; part 'mixins/page_navigation_mixin.dart';
@@ -27,6 +31,7 @@ part 'mixins/theme_mixin.dart';
part 'mixins/link_handling_mixin.dart'; part 'mixins/link_handling_mixin.dart';
part 'mixins/image_viewer_mixin.dart'; part 'mixins/image_viewer_mixin.dart';
part 'mixins/footnote_mixin.dart'; part 'mixins/footnote_mixin.dart';
part 'mixins/text_selection_mixin.dart';
/// Reads EPUB directly from compressed file without extraction. /// Reads EPUB directly from compressed file without extraction.
class ReaderScreen extends StatefulWidget { class ReaderScreen extends StatefulWidget {
@@ -35,6 +40,9 @@ class ReaderScreen extends StatefulWidget {
final String title; final String title;
final String? coverPath; final String? coverPath;
final Map<String, dynamic>? bookData; final Map<String, dynamic>? bookData;
final int? initialSpineIndex;
final String? scrollToXPath;
final String? scrollToText;
const ReaderScreen({ const ReaderScreen({
super.key, super.key,
@@ -43,6 +51,9 @@ class ReaderScreen extends StatefulWidget {
required this.title, required this.title,
this.coverPath, this.coverPath,
this.bookData, this.bookData,
this.initialSpineIndex,
this.scrollToXPath,
this.scrollToText,
}); });
@override @override
@@ -58,7 +69,8 @@ class _ReaderScreenState extends State<ReaderScreen>
_ThemeMixin, _ThemeMixin,
_LinkHandlingMixin, _LinkHandlingMixin,
_ImageViewerMixin, _ImageViewerMixin,
_FootnoteMixin { _FootnoteMixin,
_TextSelectionMixin {
@override @override
late final EpubWebViewHandler webViewHandler; late final EpubWebViewHandler webViewHandler;
@@ -137,6 +149,7 @@ class _ReaderScreenState extends State<ReaderScreen>
// Services // Services
final EpubStreamService _streamService = EpubStreamService(); final EpubStreamService _streamService = EpubStreamService();
@override
final ReaderDao _readerDao = ReaderDao(); final ReaderDao _readerDao = ReaderDao();
final EpubParser _epubParser = EpubParser(); final EpubParser _epubParser = EpubParser();
@@ -169,6 +182,8 @@ class _ReaderScreenState extends State<ReaderScreen>
// Load settings first, then book // Load settings first, then book
ReaderSettings.load().then((settings) { ReaderSettings.load().then((settings) {
readerSettings = settings; readerSettings = settings;
_pendingScrollXPath = widget.scrollToXPath;
_pendingScrollText = widget.scrollToText;
if (mounted) { if (mounted) {
setupVolumeControl(); setupVolumeControl();
_loadBook(); _loadBook();
@@ -288,7 +303,7 @@ class _ReaderScreenState extends State<ReaderScreen>
if (mounted) { if (mounted) {
setState(() { setState(() {
currentSpineItemIndex = bookSession.initialChapterIndex; currentSpineItemIndex = widget.initialSpineIndex ?? bookSession.initialChapterIndex;
}); });
updateProgressDebounced(); updateProgressDebounced();
_loadBookmarks(); _loadBookmarks();
@@ -512,7 +527,10 @@ class _ReaderScreenState extends State<ReaderScreen>
onPerformPageTurn: handlePageTurn, onPerformPageTurn: handlePageTurn,
onToggleControls: toggleControls, onToggleControls: toggleControls,
onInitialized: () async { onInitialized: () async {
final ratio = bookSession.initialScrollPosition; // 从详情页跳转时不恢复滚动位置
final ratio = widget.initialSpineIndex != null
? null
: bookSession.initialScrollPosition;
await loadCarousel(restoreScrollRatio: ratio); await loadCarousel(restoreScrollRatio: ratio);
}, },
onPageCountReady: (totalPages) async { onPageCountReady: (totalPages) async {
@@ -536,6 +554,7 @@ class _ReaderScreenState extends State<ReaderScreen>
onFootnoteTap: handleFootnoteTap, onFootnoteTap: handleFootnoteTap,
onLinkTap: handleLinkTap, onLinkTap: handleLinkTap,
shouldHandleLinkTap: shouldHandleLinkTap, shouldHandleLinkTap: shouldHandleLinkTap,
onTextSelection: handleTextSelection,
shouldShowWebView: shouldShowWebView, shouldShowWebView: shouldShowWebView,
initializeTheme: epubTheme, initializeTheme: epubTheme,
statusBarLeftContent: activateTocTitle, statusBarLeftContent: activateTocTitle,
@@ -670,6 +689,23 @@ class _ReaderScreenState extends State<ReaderScreen>
), ),
), ),
), ),
// 文本选中工具条
if (isSelectionToolbarVisible)
EpubSelectionToolbar(
selectionRect: _selectionRect!,
onCopy: _onCopyButton,
onHighlight: _onHighlightButton,
onExcerpt: _onExcerptButton,
onDismiss: _dismissSelectionToolbar,
),
// 选区手柄(起点和终点)
if (isSelectionToolbarVisible)
SelectionHandles(
startPosition: _startHandle,
endPosition: _endHandle,
onDragStart: onDragStartHandle,
onDragEnd: onDragEndHandle,
),
], ],
), ),
), ),

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
@@ -59,6 +60,11 @@ class ReaderWebViewController {
await _webViewState?._checkTapElementAt(x, y); await _webViewState?._checkTapElementAt(x, y);
} }
/// 获取当前 iframe 中用户选中的文字(长按后调用)
Future<String?> getTextSelection() async {
return await _webViewState?._getTextSelection();
}
Future<ui.Image?> takeScreenshot() async { Future<ui.Image?> takeScreenshot() async {
return await _webViewState?._takeScreenshot(); return await _webViewState?._takeScreenshot();
} }
@@ -82,10 +88,121 @@ class ReaderWebViewController {
Future<dynamic> runJavaScriptReturningResult(String js) async { Future<dynamic> runJavaScriptReturningResult(String js) async {
return await _webViewState?._controller?.evaluateJavascript(source: js); return await _webViewState?._controller?.evaluateJavascript(source: js);
} }
/// 获取当前 iframe 中选区的详细信息(用于高亮持久化)
Future<Map<String, dynamic>?> getSelectionInfo() async {
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.getSelectionInfo()',
);
if (result == null) return null;
if (result is Map) return Map<String, dynamic>.from(result);
return null;
}
/// 应用单个高亮到当前 iframe
Future<bool> applyHighlight(Map<String, dynamic> info, String id, {String color = 'highlight', String text = ''}) async {
final infoJson = jsonEncode(info);
final textArg = text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n');
final result = await _webViewState?._controller?.evaluateJavascript(
source:
"window.__mooknoteHL && window.__mooknoteHL.applyHighlight($infoJson, \"$id\", \"$color\", '$textArg')",
);
return result == true;
}
/// 批量应用高亮(用于 spine 加载后恢复)
Future<int> applyHighlights(List<Map<String, dynamic>> highlights) async {
if (highlights.isEmpty) return 0;
final list = jsonEncode(highlights.map((h) {
// 优先使用嵌套的 info 对象(来自 restoreHighlightsForCurrentSpine
final rawInfo = h['info'];
Map<String, dynamic> info;
if (rawInfo is Map) {
info = Map<String, dynamic>.from(rawInfo);
} else {
// 兼容扁平字段
info = {
'startXPath': h['start_x_path'] ?? h['startXPath'] ?? '',
'startOffset': h['start_offset'] ?? h['startOffset'] ?? 0,
'endXPath': h['end_x_path'] ?? h['endXPath'] ?? '',
'endOffset': h['end_offset'] ?? h['endOffset'] ?? 0,
};
}
return {
'info': info,
'id': (h['id'] ?? '').toString(),
'color': h['color'] == 'excerpt' ? 'excerpt' : 'highlight',
'text': h['text'] ?? h['content'] ?? '',
};
}).toList());
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.applyHighlights($list)',
);
if (result is int) return result;
if (result is num) return result.toInt();
return 0;
}
/// 移除指定 id 的高亮
Future<void> removeHighlight(String id) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.removeHighlight("$id")',
);
}
/// 移除当前文档中所有高亮标记
Future<void> clearAllHighlights() async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.clearAllHighlights()',
);
}
/// 清除当前选区
Future<void> clearSelection() async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.clearSelection()',
);
}
/// 编程式选中长按位置的文字(用于 Flutter 长按手势触发文本选择)
Future<void> selectWordAt(double x, double y) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.selectWordAt($x, $y)',
);
}
/// 拖动手柄时扩展选区
Future<void> extendSelection(double x, double y, bool isStart) async {
await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.extendSelection($x, $y, ${isStart ? 'true' : 'false'})',
);
}
/// 根据 XPath 获取元素所在的页码(用于跳转到高亮位置)
Future<int> getPageIndexForXPath(String xpath) async {
final result = await _webViewState?._controller?.evaluateJavascript(
source: 'window.__mooknoteHL && window.__mooknoteHL.getPageIndexForXPath("$xpath")',
);
if (result is int) return result;
if (result is num) return result.toInt();
return -1;
}
/// 根据文本内容获取所在页码XPath 失败时的回退方案)
Future<int> getPageIndexForText(String text) async {
if (text.isEmpty) return -1;
final escaped = text.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n');
final result = await _webViewState?._controller?.evaluateJavascript(
source: "window.__mooknoteHL && window.__mooknoteHL.getPageIndexForText('$escaped')",
);
if (result is int) return result;
if (result is num) return result.toInt();
return -1;
}
} }
final InAppWebViewSettings defaultSettings = InAppWebViewSettings( final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
disableContextMenu: true, disableContextMenu: false,
disableLongPressContextMenuOnLinks: true, disableLongPressContextMenuOnLinks: true,
selectionGranularity: SelectionGranularity.CHARACTER, selectionGranularity: SelectionGranularity.CHARACTER,
transparentBackground: true, transparentBackground: true,
@@ -98,7 +215,7 @@ final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
disableHorizontalScroll: true, disableHorizontalScroll: true,
disableVerticalScroll: true, disableVerticalScroll: true,
supportZoom: false, supportZoom: false,
useHybridComposition: false, useHybridComposition: true,
resourceCustomSchemes: [EpubWebViewHandler.virtualScheme], resourceCustomSchemes: [EpubWebViewHandler.virtualScheme],
verticalScrollBarEnabled: false, verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false, horizontalScrollBarEnabled: false,
@@ -116,6 +233,7 @@ class ReaderWebViewCallbacks {
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap; final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap; final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap; final bool Function(String url) shouldHandleLinkTap;
final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map<String, dynamic>? selectionInfo)? onTextSelection;
const ReaderWebViewCallbacks({ const ReaderWebViewCallbacks({
required this.onInitialized, required this.onInitialized,
@@ -127,6 +245,7 @@ class ReaderWebViewCallbacks {
required this.onFootnoteTap, required this.onFootnoteTap,
required this.onLinkTap, required this.onLinkTap,
required this.shouldHandleLinkTap, required this.shouldHandleLinkTap,
this.onTextSelection,
}); });
} }
@@ -184,6 +303,16 @@ class _ReaderWebViewState extends State<ReaderWebView> {
widget.controller._attachState(this); widget.controller._attachState(this);
} }
@override
void dispose() {
widget.controller._attachState(null);
_bridge.detach();
_headlessWebView?.dispose();
_headlessWebView = null;
_controller = null;
super.dispose();
}
@override @override
void didUpdateWidget(covariant ReaderWebView oldWidget) { void didUpdateWidget(covariant ReaderWebView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -246,6 +375,21 @@ class _ReaderWebViewState extends State<ReaderWebView> {
Future<void> _checkTapElementAt(double x, double y) => Future<void> _checkTapElementAt(double x, double y) =>
_api.checkTapElementAt(x, y); _api.checkTapElementAt(x, y);
/// 获取当前 iframe 中用户选中的文字
Future<String?> _getTextSelection() async {
if (_controller == null) return null;
final result = await _controller!.evaluateJavascript(source: '''
(function(){
var f = document.getElementById('frame-curr');
if (!f || !f.contentWindow) return '';
var sel = f.contentWindow.getSelection();
return sel ? sel.toString() : '';
})()
''');
if (result == null) return null;
return result.toString();
}
InAppWebViewInitialData _generateInitialData(double width, double height) { InAppWebViewInitialData _generateInitialData(double width, double height) {
return InAppWebViewInitialData( return InAppWebViewInitialData(
data: generateSkeletonHtml( data: generateSkeletonHtml(
@@ -302,6 +446,642 @@ class _ReaderWebViewState extends State<ReaderWebView> {
} }
void _onLoadStop(InAppWebViewController controller, WebUri? url) { void _onLoadStop(InAppWebViewController controller, WebUri? url) {
// 注入高亮辅助函数库 + CSS + 文本选中检测(一次性注入,定时重新应用到新 contentDocument
controller.evaluateJavascript(source: r'''
(function(){
if (window.__mooknoteHL) {
console.log('[MN] already injected, re-running setup');
setupSelectionListener();
return;
}
console.log('[MN] injecting highlight + selection JS');
function getFrameDoc() {
var f = document.getElementById('frame-curr');
return f && f.contentDocument ? f.contentDocument : null;
}
function getFrameWin() {
var f = document.getElementById('frame-curr');
return f && f.contentWindow ? f.contentWindow : null;
}
function getXPath(node, root) {
if (node.nodeType === 3) {
var parent = node.parentNode;
var textIndex = 0;
for (var i = 0; i < parent.childNodes.length; i++) {
if (parent.childNodes[i] === node) break;
if (parent.childNodes[i].nodeType === 3) textIndex++;
}
return getXPath(parent, root) + '/text()[' + (textIndex + 1) + ']';
}
if (node.nodeType !== 1) return '';
if (node === root) return '';
var parts = [];
var current = node;
while (current && current.nodeType === 1 && current !== root) {
var parent = current.parentNode;
if (!parent) break;
var index = 1;
var siblings = parent.childNodes;
for (var i = 0; i < siblings.length; i++) {
var s = siblings[i];
if (s.nodeType === 1 && s.tagName === current.tagName) {
if (s === current) break;
index++;
}
}
parts.unshift(current.tagName.toLowerCase() + '[' + index + ']');
current = parent;
}
return '/' + parts.join('/');
}
function getNodeByXPath(doc, xpath) {
if (!doc.evaluate) return null;
try {
var result = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
} catch (e) {
return null;
}
}
/// 计算节点在分栏排版中的页码
function calcPageIndex(doc, node) {
if (!node) return -1;
var el = node.nodeType === 3 ? node.parentNode : node;
var f = document.getElementById('frame-curr');
if (!f) return -1;
var iframeWidth = f.clientWidth;
var iframeHeight = f.clientHeight;
// 获取元素相对于 iframe 内容的偏移
var rect = el.getBoundingClientRect();
var scrollLeft = doc.documentElement.scrollLeft || doc.body.scrollLeft || 0;
var scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop || 0;
// 水平分栏
var pageX = iframeWidth > 0 ? Math.floor((rect.left + scrollLeft) / iframeWidth) : 0;
// 垂直分栏
var pageY = iframeHeight > 0 ? Math.floor((rect.top + scrollTop) / iframeHeight) : 0;
// 判断是水平还是垂直分栏:看 scrollWidth 是否大于 clientWidth
var hasHorizontalPagination = doc.documentElement.scrollWidth > doc.documentElement.clientWidth + 10;
return hasHorizontalPagination ? pageX : pageY;
}
/// 文本搜索回退:遍历所有文本节点,拼接后搜索目标文本,定位并高亮
function applyHighlightByTextSearch(doc, text, id, color) {
if (!text || text.length === 0) return false;
try {
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
if (node.textContent.trim().length > 0) return NodeFilter.FILTER_ACCEPT;
return NodeFilter.FILTER_SKIP;
}
}, null);
var nodes = [];
var n;
while (n = walker.nextNode()) nodes.push(n);
if (nodes.length === 0) return false;
var fullText = '';
var ranges = [];
for (var i = 0; i < nodes.length; i++) {
var c = nodes[i].textContent;
ranges.push({ node: nodes[i], start: fullText.length, len: c.length });
fullText += c;
}
var idx = fullText.indexOf(text);
if (idx === -1) return false;
var endIdx = idx + text.length;
var startInfo = null, endInfo = null;
for (var i = 0; i < ranges.length; i++) {
var r = ranges[i];
if (!startInfo && idx >= r.start && idx < r.start + r.len) {
startInfo = { node: r.node, offset: idx - r.start };
}
if (endIdx > r.start && endIdx <= r.start + r.len) {
endInfo = { node: r.node, offset: endIdx - r.start };
break;
}
}
if (!startInfo || !endInfo) return false;
var range = doc.createRange();
range.setStart(startInfo.node, startInfo.offset);
range.setEnd(endInfo.node, endInfo.offset);
return wrapRangeMultiNode(doc, range, id, color);
} catch (e) {
console.log('[MN] applyHighlightByTextSearch error: ' + e);
return false;
}
}
/// 多节点包裹:遍历 range 内所有文本节点,逐个包裹在 <mooknote-mark> 中
/// 保留段落结构,不用 extractContents
function wrapRangeMultiNode(doc, range, id, color) {
var bg = color === 'excerpt' ? 'rgba(33, 150, 243, 0.35)' : 'rgba(255, 235, 59, 0.5)';
var markType = color === 'excerpt' ? 'excerpt' : 'highlight';
var ancestor = range.commonAncestorContainer;
var walkerRoot = ancestor.nodeType === 3 ? ancestor.parentNode : ancestor;
var walker = doc.createTreeWalker(walkerRoot, NodeFilter.SHOW_TEXT, null);
var nodesToWrap = [];
var n;
while (n = walker.nextNode()) {
if (range.intersectsNode(n)) {
var s = 0, e = n.length;
if (n === range.startContainer) s = range.startOffset;
if (n === range.endContainer) e = range.endOffset;
if (n === range.startContainer && n === range.endContainer) { s = range.startOffset; e = range.endOffset; }
if (s < e) nodesToWrap.push({ node: n, start: s, end: e });
}
}
if (nodesToWrap.length === 0) {
var mark = doc.createElement('mooknote-mark');
mark.setAttribute('data-hl-id', id);
mark.setAttribute('data-hl-type', markType);
mark.style.backgroundColor = bg;
mark.style.color = 'inherit';
mark.style.borderRadius = '2px';
try {
range.surroundContents(mark);
return true;
} catch (e) {
return false;
}
}
// 倒序包裹(防止 splitText 影响后面的节点索引)
for (var i = nodesToWrap.length - 1; i >= 0; i--) {
var item = nodesToWrap[i];
var tn = item.node;
var start = item.start;
var end = item.end;
var wrapNode = tn;
if (start > 0) wrapNode = tn.splitText(start);
if (end - start < wrapNode.length) wrapNode.splitText(end - start);
var mk = doc.createElement('mooknote-mark');
mk.setAttribute('data-hl-id', id);
mk.setAttribute('data-hl-type', markType);
mk.style.backgroundColor = bg;
mk.style.color = 'inherit';
mk.style.borderRadius = '2px';
wrapNode.parentNode.insertBefore(mk, wrapNode);
mk.appendChild(wrapNode);
}
return true;
}
window.__mooknoteHL = {
getSelectionInfo: function() {
var win = getFrameWin();
if (!win) return null;
var sel = win.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
var range = sel.getRangeAt(0);
var doc = getFrameDoc();
if (!doc) return null;
return {
text: sel.toString(),
startXPath: getXPath(range.startContainer, doc),
startOffset: range.startOffset,
endXPath: getXPath(range.endContainer, doc),
endOffset: range.endOffset
};
},
applyHighlight: function(info, id, color, text) {
var doc = getFrameDoc();
if (!doc || !info) {
return false;
}
var startNode = getNodeByXPath(doc, info.startXPath);
var endNode = getNodeByXPath(doc, info.endXPath);
if (!startNode || !endNode) {
if (text && text.length > 0) {
return applyHighlightByTextSearch(doc, text, id, color);
}
return false;
}
try {
var so = Math.min(info.startOffset, startNode.length || 0);
var eo = Math.min(info.endOffset, endNode.length || 0);
var range = doc.createRange();
range.setStart(startNode, so);
range.setEnd(endNode, eo);
return wrapRangeMultiNode(doc, range, id, color);
} catch (e) {
if (text) return applyHighlightByTextSearch(doc, text, id, color);
return false;
}
},
applyHighlights: function(list) {
if (!list || list.length === 0) return 0;
// 按文档位置倒序排序(从末尾往前应用),避免前面的高亮分裂文本节点影响后面的 XPath
// 用数字排序而非字符串排序(/p[10] 应排在 /p[2] 之后)
function docOrder(item) {
var xp = (item && item.info && item.info.startXPath) || '';
// 提取所有 [N] 中的数字,组成数组用于比较
var nums = [];
var re = /\[(\d+)\]/g;
var m;
while ((m = re.exec(xp)) !== null) nums.push(parseInt(m[1], 10));
return nums;
}
function compareNums(a, b) {
for (var i = 0; i < Math.max(a.length, b.length); i++) {
var av = a[i] || 0;
var bv = b[i] || 0;
if (av !== bv) return av - bv;
}
return 0;
}
var sorted = list.slice().sort(function(a, b) {
return compareNums(docOrder(b), docOrder(a)); // 倒序
});
var applied = 0;
for (var i = 0; i < sorted.length; i++) {
if (this.applyHighlight(sorted[i].info, sorted[i].id, sorted[i].color, sorted[i].text)) applied++;
}
return applied;
},
removeHighlight: function(id) {
var doc = getFrameDoc();
if (!doc) return;
var mark = doc.querySelector('[data-hl-id="' + id + '"]');
if (mark && mark.tagName === 'MOOKNOTE-MARK') {
var parent = mark.parentNode;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
parent.normalize();
}
},
clearAllHighlights: function() {
var doc = getFrameDoc();
if (!doc) return;
var marks = doc.querySelectorAll('mooknote-mark[data-hl-id]');
for (var i = 0; i < marks.length; i++) {
var mark = marks[i];
var parent = mark.parentNode;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
}
// normalize 合并相邻文本节点
if (doc.body) doc.body.normalize();
},
clearSelection: function() {
var win = getFrameWin();
if (!win) return;
var sel = win.getSelection();
if (sel) sel.removeAllRanges();
},
/// 根据 XPath 获取元素所在页码(用于跳转到高亮位置)
getPageIndexForXPath: function(xpath) {
var doc = getFrameDoc();
if (!doc || !xpath) return -1;
var node = getNodeByXPath(doc, xpath);
if (!node) return -1;
return calcPageIndex(doc, node);
},
/// 根据文本内容获取所在页码XPath 失败时的回退)
getPageIndexForText: function(text) {
var doc = getFrameDoc();
if (!doc || !text) return -1;
// 遍历所有文本节点,找到包含目标文本的节点
var walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null, null);
var n;
while (n = walker.nextNode()) {
if (n.textContent.indexOf(text) !== -1) {
return calcPageIndex(doc, n);
}
}
// 去掉空白再试
var cleanText = text.replace(/\s+/g, '');
walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null, null);
while (n = walker.nextNode()) {
if (n.textContent.replace(/\s+/g, '').indexOf(cleanText) !== -1) {
return calcPageIndex(doc, n);
}
}
return -1;
},
/// 编程式选中最接近 (x,y) 的文字(单词/句子),并触发 onTextSelection 回调
selectWordAt: function(x, y) {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument || !f.contentWindow) {
console.log('[MN] selectWordAt: no frame');
return false;
}
var doc = f.contentDocument;
var win = f.contentWindow;
var iframeRect = f.getBoundingClientRect();
var localX = x - iframeRect.left;
var localY = y - iframeRect.top;
// 用 caretRangeFromPoint 找到该位置的文本节点
var range = null;
if (doc.caretRangeFromPoint) {
range = doc.caretRangeFromPoint(localX, localY);
} else if (doc.caretPositionFromPoint) {
var pos = doc.caretPositionFromPoint(localX, localY);
if (pos) {
range = doc.createRange();
range.setStart(pos.offsetNode, pos.offset);
range.setEnd(pos.offsetNode, pos.offset);
}
}
if (!range || !range.startContainer || range.startContainer.nodeType !== 3) {
console.log('[MN] selectWordAt: no text node at (' + x + ',' + y + ')');
return false;
}
var node = range.startContainer;
var offset = range.startOffset;
var text = node.textContent;
// 向前找到词/句边界(支持中文和英文)
var start = offset;
while (start > 0) {
var ch = text[start - 1];
// 遇到空格、标点、换行就停
if (/[\s。、""''()【】《》\n\r]/.test(ch)) break;
start--;
}
// 向后找到词/句边界
var end = offset;
while (end < text.length) {
var ch = text[end];
if (/[\s。、""''()【】《》\n\r]/.test(ch)) break;
end++;
}
// 如果只选中了一个字,尝试扩展到整个句子(按句号分割)
if (end - start <= 1) {
start = 0;
end = text.length;
}
var selRange = doc.createRange();
selRange.setStart(node, start);
selRange.setEnd(node, end);
// 应用选区
var sel = win.getSelection();
sel.removeAllRanges();
sel.addRange(selRange);
var selectedText = sel.toString();
console.log('[MN] selectWordAt: selected "' + selectedText.substring(0, 30) + '"');
var selRect = selRange.getBoundingClientRect();
var rect = [iframeRect.left + selRect.left, iframeRect.top + selRect.top, selRect.width, selRect.height];
var handles = getHandlePositions(f, selRange, iframeRect);
var info = {
startXPath: getXPath(selRange.startContainer, doc),
startOffset: selRange.startOffset,
endXPath: getXPath(selRange.endContainer, doc),
endOffset: selRange.endOffset
};
// 触发 onTextSelection 回调
window.flutter_inappwebview.callHandler('onTextSelection',
selectedText,
rect,
0,
0,
handles.start,
handles.end,
info
);
return true;
},
/// 拖动手柄时扩展选区:将起点或终点移动到 (x, y) 处的文字位置
extendSelection: function(x, y, isStart) {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument || !f.contentWindow) return false;
var doc = f.contentDocument;
var win = f.contentWindow;
var iframeRect = f.getBoundingClientRect();
var localX = x - iframeRect.left;
var localY = y - iframeRect.top;
var pos = null;
if (doc.caretRangeFromPoint) {
var r = doc.caretRangeFromPoint(localX, localY);
if (r) pos = { node: r.startContainer, offset: r.startOffset };
} else if (doc.caretPositionFromPoint) {
var p = doc.caretPositionFromPoint(localX, localY);
if (p) pos = { node: p.offsetNode, offset: p.offset };
}
if (!pos) return false;
var sel = win.getSelection();
if (!sel || sel.rangeCount === 0) return false;
var currentRange = sel.getRangeAt(0);
var newRange = doc.createRange();
if (isStart) {
// 拖动起点手柄:新起点 = pos终点 = 原终点
newRange.setStart(pos.node, pos.offset);
newRange.setEnd(currentRange.endContainer, currentRange.endOffset);
} else {
// 拖动终点手柄:起点 = 原起点,新终点 = pos
newRange.setStart(currentRange.startContainer, currentRange.startOffset);
newRange.setEnd(pos.node, pos.offset);
}
// 确保起点在终点之前
if (newRange.collapsed || currentRange.startContainer === newRange.endContainer && newRange.startOffset > newRange.endOffset) {
// 反向了,交换
if (isStart) {
newRange.setStart(currentRange.endContainer, currentRange.endOffset);
newRange.setEnd(pos.node, pos.offset);
} else {
newRange.setStart(pos.node, pos.offset);
newRange.setEnd(currentRange.startContainer, currentRange.startOffset);
}
}
sel.removeAllRanges();
sel.addRange(newRange);
var text = sel.toString();
var selRect = newRange.getBoundingClientRect();
var rect = [iframeRect.left + selRect.left, iframeRect.top + selRect.top, selRect.width, selRect.height];
var handles = getHandlePositions(f, newRange, iframeRect);
var info = {
startXPath: getXPath(newRange.startContainer, doc),
startOffset: newRange.startOffset,
endXPath: getXPath(newRange.endContainer, doc),
endOffset: newRange.endOffset
};
window.flutter_inappwebview.callHandler('onTextSelection',
text,
rect,
0,
0,
handles.start,
handles.end,
info
);
return true;
}
};
// ─── 文本选中检测 ────────────────────────────────────
var lastText = '';
var debounceTimer = null;
function checkSelection() {
var f = document.getElementById('frame-curr');
if (!f || !f.contentWindow || !f.contentDocument) {
console.log('[MN] checkSelection: no frame');
return;
}
var win = f.contentWindow;
var doc = f.contentDocument;
var sel = win.getSelection();
if (!sel) {
console.log('[MN] checkSelection: no getSelection');
return;
}
if (sel.isCollapsed || sel.rangeCount === 0) {
lastText = '';
return;
}
var text = sel.toString();
if (!text || text === lastText) return;
lastText = text;
console.log('[MN] selection detected: ' + text.substring(0, 30));
var range = sel.getRangeAt(0);
var selRect = range.getBoundingClientRect();
var iframeRect = f.getBoundingClientRect();
var left = iframeRect.left + selRect.left;
var top = iframeRect.top + selRect.top;
var scrollRatio = 0;
if (doc.documentElement) {
var sh = doc.documentElement.scrollHeight - doc.documentElement.clientHeight;
if (sh > 0) scrollRatio = doc.documentElement.scrollTop / sh;
}
var handles = getHandlePositions(f, range, iframeRect);
var info = getSelectionInfoInternal();
window.flutter_inappwebview.callHandler('onTextSelection',
text,
[left, top, selRect.width, selRect.height],
0,
scrollRatio,
handles.start,
handles.end,
info
);
}
/// 获取选区 DOM 信息XPath + offset用于高亮持久化
function getSelectionInfoInternal() {
var win = getFrameWin();
if (!win) return null;
var sel = win.getSelection();
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null;
var range = sel.getRangeAt(0);
var doc = getFrameDoc();
if (!doc) return null;
return {
startXPath: getXPath(range.startContainer, doc),
startOffset: range.startOffset,
endXPath: getXPath(range.endContainer, doc),
endOffset: range.endOffset
};
}
/// 获取选区起点和终点的屏幕坐标
function getHandlePositions(f, range, iframeRect) {
var rects = range.getClientRects();
if (rects.length === 0) {
var r = range.getBoundingClientRect();
rects = [r];
}
var firstRect = rects[0];
var lastRect = rects[rects.length - 1];
// 起点手柄:第一行的左下角(手柄线覆盖文字行,圆点在上方)
var startX = iframeRect.left + firstRect.left;
var startY = iframeRect.top + firstRect.bottom;
// 终点手柄:最后一行的右上角(手柄线覆盖文字行,圆点在下方)
var endX = iframeRect.left + lastRect.right;
var endY = iframeRect.top + lastRect.top;
return { start: [startX, startY], end: [endX, endY] };
}
// 每次 iframe 内容变化后重新注入 CSS + 事件监听
function setupSelectionListener() {
var f = document.getElementById('frame-curr');
if (!f || !f.contentDocument) return;
var doc = f.contentDocument;
// 1. 重新注入 user-select CSSloadFrame 会替换 contentDocumentCSS 会丢失)
var s = doc.getElementById('_ts_css');
if (!s) {
s = doc.createElement('style');
s.id = '_ts_css';
doc.head.appendChild(s);
s.textContent = 'html,body,p,span,div,a,h1,h2,h3,h4,h5,h6,li,td,th,blockquote,figcaption{-webkit-user-select:text!important;-moz-user-select:text!important;user-select:text!important;-webkit-touch-callout:default!important}';
console.log('[MN] CSS injected into new contentDocument');
}
// 2. 附加事件监听器
if (doc.__mnSelListener) return;
doc.__mnSelListener = true;
console.log('[MN] attaching selection listeners');
doc.addEventListener('selectionchange', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 250);
});
doc.addEventListener('touchend', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 350);
}, true);
doc.addEventListener('mouseup', function() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(checkSelection, 350);
}, true);
}
// 定时重新注入 CSS + 重新附加监听器iframe 内容会在翻页/切换章节时变化)
setInterval(setupSelectionListener, 500);
setupSelectionListener();
console.log('[MN] injection complete');
})();
''');
widget.callbacks.onInitialized(); widget.callbacks.onInitialized();
} }
@@ -329,6 +1109,9 @@ class _ReaderWebViewState extends State<ReaderWebView> {
shouldOverrideUrlLoading: _shouldOverrideUrlLoading, shouldOverrideUrlLoading: _shouldOverrideUrlLoading,
onWebViewCreated: _onWebViewCreated, onWebViewCreated: _onWebViewCreated,
onLoadStop: _onLoadStop, onLoadStop: _onLoadStop,
onConsoleMessage: (controller, consoleMessage) {
debugPrint('[EPUB-JS] ${consoleMessage.message}');
},
) )
: Container(color: _currentTheme.surfaceColor), : Container(color: _currentTheme.surfaceColor),
), ),
@@ -497,6 +1280,65 @@ class _ReaderWebViewState extends State<ReaderWebView> {
} }
}, },
); );
// ─── 文本选择(划线功能) ────────────────
controller.addJavaScriptHandler(
handlerName: 'onTextSelection',
callback: (args) async {
debugPrint('[MN] onTextSelection handler called, args: ${args.length}');
if (args.isEmpty) return;
final selectedText = args[0] as String? ?? '';
final rectList = args[1] as List?;
final spineIndex = (args[2] as num?)?.toInt() ?? 0;
final scrollRatio = (args[3] as num?)?.toDouble() ?? 0.0;
Offset? startHandle;
Offset? endHandle;
Map<String, dynamic>? selectionInfo;
if (args.length >= 6) {
final startList = args[4] as List?;
final endList = args[5] as List?;
if (startList != null && startList.length >= 2) {
startHandle = Offset(
(startList[0] as num).toDouble(),
(startList[1] as num).toDouble(),
);
}
if (endList != null && endList.length >= 2) {
endHandle = Offset(
(endList[0] as num).toDouble(),
(endList[1] as num).toDouble(),
);
}
}
if (args.length >= 7) {
final rawInfo = args[6];
if (rawInfo is Map) {
selectionInfo = Map<String, dynamic>.from(rawInfo);
}
}
debugPrint('[MN] selectedText="$selectedText" info=$selectionInfo');
Rect? rect;
if (rectList != null && rectList.length >= 4) {
rect = Rect.fromLTWH(
(rectList[0] as num).toDouble(),
(rectList[1] as num).toDouble(),
(rectList[2] as num).toDouble(),
(rectList[3] as num).toDouble(),
);
}
if (selectedText.isNotEmpty && rect != null) {
widget.callbacks.onTextSelection?.call(
selectedText,
rect,
spineIndex,
scrollRatio,
startHandle,
endHandle,
selectionInfo,
);
}
},
);
} }
Future<ui.Image?> _takeScreenshot() async { Future<ui.Image?> _takeScreenshot() async {

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
/// 选区手柄(起点和终点各一个),可拖动调整选区范围
///
/// 样式:光标竖线 + 大圆点
/// - 起点手柄:圆点在上,竖线向下连接到选区起点
/// - 终点手柄:竖线从选区终点向下延伸,圆点在下
class SelectionHandles extends StatelessWidget {
final Offset? startPosition;
final Offset? endPosition;
final Function(DragUpdateDetails) onDragStart;
final Function(DragUpdateDetails) onDragEnd;
static const double _circleSize = 12.0;
static const double _lineHeight = 20.0;
static const double _lineWidth = 2.0;
static const Color _handleColor = Color(0xFF4A90D9);
const SelectionHandles({
super.key,
required this.startPosition,
required this.endPosition,
required this.onDragStart,
required this.onDragEnd,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
if (startPosition != null) _buildStartHandle(startPosition!),
if (endPosition != null) _buildEndHandle(endPosition!),
],
);
}
/// 起点手柄:圆点在上 + 竖线向下
/// startPosition 在文字行底部,手柄线覆盖文字行,圆点在文字上方
Widget _buildStartHandle(Offset pos) {
final totalHeight = _circleSize + _lineHeight;
return Positioned(
left: pos.dx - _circleSize / 2,
top: pos.dy - totalHeight,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: onDragStart,
child: SizedBox(
width: _circleSize,
height: totalHeight,
child: Column(
children: [
_buildCircle(),
_buildLine(),
],
),
),
),
);
}
/// 终点手柄:竖线向下 + 圆点在下
/// endPosition 在文字行顶部,手柄线覆盖文字行,圆点在文字下方
Widget _buildEndHandle(Offset pos) {
final totalHeight = _lineHeight + _circleSize;
return Positioned(
left: pos.dx - _circleSize / 2,
top: pos.dy,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onPanUpdate: onDragEnd,
child: SizedBox(
width: _circleSize,
height: totalHeight,
child: Column(
children: [
_buildLine(),
_buildCircle(),
],
),
),
),
);
}
Widget _buildCircle() {
return Container(
width: _circleSize,
height: _circleSize,
decoration: BoxDecoration(
color: _handleColor,
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
);
}
Widget _buildLine() {
return Container(
width: _lineWidth,
height: _lineHeight,
color: _handleColor,
);
}
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 浮动工具栏 — 参考微信阅读划线界面
class SelectionOverlay extends StatefulWidget {
final String selectedText;
final Rect position; // WebView 坐标系内的位置
final VoidCallback onCopy;
final VoidCallback onHighlight;
final VoidCallback? onExcerpt; // null = 不显示书摘按钮
final ColorScheme colorScheme;
final bool isDark;
const SelectionOverlay({
super.key,
required this.selectedText,
required this.position,
required this.onCopy,
required this.onHighlight,
this.onExcerpt,
required this.colorScheme,
required this.isDark,
});
@override
State<SelectionOverlay> createState() => _SelectionOverlayState();
}
class _SelectionOverlayState extends State<SelectionOverlay> {
static const int _visibleCount = 6;
void _copyToClipboard() async {
await Clipboard.setData(ClipboardData(text: widget.selectedText));
if (mounted) Navigator.of(context).pop();
widget.onCopy();
}
void _onItemTap(int index) {
switch (index) {
case 0:
_copyToClipboard();
break;
case 1:
Navigator.of(context).pop();
widget.onHighlight();
break;
case 2:
Navigator.of(context).pop();
break;
case 3:
if (widget.onExcerpt != null) {
Navigator.of(context).pop();
widget.onExcerpt!();
}
break;
case 4:
Navigator.of(context).pop();
break;
case 5:
Navigator.of(context).pop();
break;
}
}
@override
Widget build(BuildContext context) {
final bg = widget.isDark ? const Color(0xFF2C2C2C) : Colors.grey.shade900;
return Positioned(
left: clampDouble(widget.position.left, 8, MediaQuery.of(context).size.width - _visibleCount * 70),
top: clampDouble(widget.position.top - 48, 0, widget.position.top),
child: GestureDetector(
onTapUp: (_) => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
_ToolbarItem(icon: Icons.copy, label: '复制', onTap: () => _onItemTap(0), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.format_underlined, label: '划线', onTap: () => _onItemTap(1), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.edit_note_outlined, label: '写想法', onTap: () => _onItemTap(2), color: widget.colorScheme.primary),
if (widget.onExcerpt != null) ...[
const SizedBox(width: 12),
Container(width: 1, height: 28, color: Colors.white30),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.bookmark_border, label: '书摘', onTap: () => _onItemTap(3), color: widget.colorScheme.primary),
],
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.search, label: 'AI 问书', onTap: () => _onItemTap(4), color: widget.colorScheme.primary),
const SizedBox(width: 12),
_ToolbarItem(icon: Icons.headphones, label: '听当前', onTap: () => _onItemTap(5), color: widget.colorScheme.primary),
]),
),
),
);
}
}
class _ToolbarItem extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final Color color;
const _ToolbarItem({required this.icon, required this.label, required this.onTap, required this.color});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 22, color: color),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 10, color: Colors.white)),
]),
);
}
}
double clampDouble(double value, double min, double max) {
if (value < min) return min;
if (value > max) return max;
return value;
}

View File

@@ -423,6 +423,7 @@ class _MainContentPageState extends State<MainContentPage> {
return PageView( return PageView(
controller: _pageController, controller: _pageController,
physics: const NeverScrollableScrollPhysics(), // 仅点击标签栏切换,禁止滑动切换
onPageChanged: (index) { onPageChanged: (index) {
if (!_isTabTap && index < tabs.length) { if (!_isTabTap && index < tabs.length) {
provider.setMainTabIndex(tabs[index].originalIndex); provider.setMainTabIndex(tabs[index].originalIndex);

View File

@@ -1147,6 +1147,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
void _navigateToEdit(BuildContext context) { void _navigateToEdit(BuildContext context) {
final provider = context.read<AppProvider>(); final provider = context.read<AppProvider>();
Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) {
provider.setEditRefresh();
provider.loadMovies(); provider.loadMovies();
}); });
} }

View File

@@ -31,8 +31,10 @@ class _MovieTabPageState extends State<MovieTabPage> {
late ScrollController _scrollController; late ScrollController _scrollController;
AppProvider? _provider; AppProvider? _provider;
int _lastScrollSignal = 0; int _lastScrollSignal = 0;
int _lastEditRefreshCounter = 0;
int _prevMovieCount = -1; int _prevMovieCount = -1;
int _prevLayoutStyle = -1; int _prevLayoutStyle = -1;
double _swipeOffset = 0.0; // 当前拖动偏移量(用于左右滑动切换状态)
static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
@@ -71,11 +73,15 @@ class _MovieTabPageState extends State<MovieTabPage> {
final statusChanged = provider.movieStatusIndex != _lastStatusIndex; final statusChanged = provider.movieStatusIndex != _lastStatusIndex;
final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle; final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle;
final countChanged = provider.movies.length != _prevMovieCount; final countChanged = provider.movies.length != _prevMovieCount;
if (statusChanged || layoutChanged || countChanged) { final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter;
if (statusChanged || layoutChanged || countChanged || editRefreshed) {
_prevLayoutStyle = provider.movieLayoutStyle; _prevLayoutStyle = provider.movieLayoutStyle;
_prevMovieCount = provider.movies.length; _prevMovieCount = provider.movies.length;
_loadFirst(); _loadFirst();
} }
if (editRefreshed) {
_lastEditRefreshCounter = provider.editRefreshCounter;
}
} }
void _onScroll() { void _onScroll() {
@@ -166,8 +172,8 @@ class _MovieTabPageState extends State<MovieTabPage> {
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
} }
final content = () {
if (_items.isEmpty && _isLoading) return _buildSkeleton(); if (_items.isEmpty && _isLoading) return _buildSkeleton();
if (_items.isEmpty) { if (_items.isEmpty) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: _refresh, onRefresh: _refresh,
@@ -179,13 +185,40 @@ class _MovieTabPageState extends State<MovieTabPage> {
), ),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: _refresh, onRefresh: _refresh,
color: colors.primary, color: colors.primary,
backgroundColor: colors.surface, backgroundColor: colors.surface,
child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(),
); );
}();
// 用 GestureDetector 包裹,左右滑动切换状态(用 Transform 而非 AnimatedContainer padding 避免负数崩溃)
return GestureDetector(
onHorizontalDragStart: (_) => _swipeOffset = 0.0,
onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0),
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity;
if ((velocity ?? 0).abs() < 80) {
setState(() => _swipeOffset = 0.0);
return;
}
final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个
final currentIndex = provider.movieStatusIndex;
final newIndex = (currentIndex + direction + 3) % 3;
setState(() => _swipeOffset = 0.0);
provider.setMovieStatusIndex(newIndex);
},
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _swipeOffset.clamp(-100.0, 100.0)),
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, value, child) {
return Transform.translate(offset: Offset(value, 0), child: child);
},
child: content,
),
);
}, },
); );
} }

View File

@@ -362,6 +362,35 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
} }
Future<void> _restore(_DeletedItem item) async { Future<void> _restore(_DeletedItem item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('确认恢复', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface)),
content: Text('确定要恢复"${item.title}"到${item.typeLabel}里面吗?', style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('恢复'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
if (confirmed != true || !mounted) return;
final provider = context.read<AppProvider>(); final provider = context.read<AppProvider>();
switch (item.type) { switch (item.type) {
case _ItemType.movie: case _ItemType.movie:

View File

@@ -72,6 +72,10 @@ class AppProvider extends ChangeNotifier {
int _scrollToTopSignal = 0; int _scrollToTopSignal = 0;
int get scrollToTopSignal => _scrollToTopSignal; int get scrollToTopSignal => _scrollToTopSignal;
// 编辑后刷新信号(影视/书籍编辑返回时递增)
int _editRefreshCounter = 0;
int get editRefreshCounter => _editRefreshCounter;
// 初始化数据库 // 初始化数据库
Future<void> initDatabase() async { Future<void> initDatabase() async {
debugPrint('[AppProvider] initDatabase'); debugPrint('[AppProvider] initDatabase');
@@ -130,6 +134,12 @@ class AppProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 编辑返回后触发列表页重载
void setEditRefresh() {
_editRefreshCounter++;
notifyListeners();
}
// ─── 分页加载(供列表页触底加载使用)──────────────────────── // ─── 分页加载(供列表页触底加载使用)────────────────────────
static const int _pageSize = 20; static const int _pageSize = 20;

View File

@@ -145,6 +145,49 @@ class ReaderDao {
); );
} }
// ─── highlights / annotations (epub) ──────────────────────
/// 保存 EPUB 高亮批注
Future<int> saveHighlight(Map<String, dynamic> annotation) async {
final db = await _db.database;
return db.insert('book_annotations', {
...annotation,
'type': 'highlight',
});
}
/// 获取某 book_idbooks.id的所有 EPUB 高亮
Future<List<Map<String, dynamic>>> getHighlightsByBookId(
String bookId) async {
final db = await _db.database;
return db.query(
'book_annotations',
where: 'book_id = ? AND type = ?',
whereArgs: [bookId, 'highlight'],
orderBy: 'created_at DESC',
);
}
/// 删除高亮
Future<int> deleteHighlight(int id) async {
final db = await _db.database;
return db.delete(
'book_annotations',
where: 'id = ? AND type = ?',
whereArgs: [id, 'highlight'],
);
}
/// 删除摘抄对应的蓝色高亮标注(通过内容匹配)
Future<void> deleteExcerptHighlightByContent(String readerBookId, String content) async {
final db = await _db.database;
await db.delete(
'book_annotations',
where: 'book_id = ? AND content = ? AND color = ?',
whereArgs: [readerBookId, content, 'excerpt'],
);
}
// ─── bookmarks ────────────────────────────────────────────────── // ─── bookmarks ──────────────────────────────────────────────────
/// 获取某本书的所有书签 /// 获取某本书的所有书签

View File

@@ -98,5 +98,5 @@ body.lumina-force-override-font,body.lumina-force-override-font *{font-family:va
'''; ''';
const String kSkeletonCss = r''' const String kSkeletonCss = r'''
html,body{margin:0;padding:0;width:100vw;height:100vh;overflow:hidden;background-color:var(--lumina-surface-color, #FFFFFF)!important}#frame-container{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--lumina-surface-color, #FFFFFF)!important;overflow:hidden}iframe{position:absolute;top:0;left:0;width:100%;height:100%;border:none;background-color:var(--lumina-surface-color, #FFFFFF)!important;will-change:opacity;transition:none;pointer-events:none} html,body{margin:0;padding:0;width:100vw;height:100vh;overflow:hidden;background-color:var(--lumina-surface-color, #FFFFFF)!important}#frame-container{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--lumina-surface-color, #FFFFFF)!important;overflow:hidden}iframe{position:absolute;top:0;left:0;width:100%;height:100%;border:none;background-color:var(--lumina-surface-color, #FFFFFF)!important;will-change:opacity;transition:none;touch-action:none;}
'''; ''';

View File

@@ -242,6 +242,10 @@ class UserPrefs {
String get dismissedVersion => prefs.getString('dismissedVersion') ?? ''; String get dismissedVersion => prefs.getString('dismissedVersion') ?? '';
Future<bool> setDismissedVersion(String value) => prefs.setString('dismissedVersion', value); Future<bool> setDismissedVersion(String value) => prefs.setString('dismissedVersion', value);
/// 更新提醒 snooze 到指定时间戳ms24 小时内不弹
int get dismissedUpdateUntil => prefs.getInt('dismissedUpdateUntil') ?? 0;
Future<bool> setDismissedUpdateUntil(int value) => prefs.setInt('dismissedUpdateUntil', value);
// ========== EPUB 阅读器 ========== // ========== EPUB 阅读器 ==========
/// EPUB 阅读器字体大小 /// EPUB 阅读器字体大小
@@ -251,4 +255,8 @@ class UserPrefs {
/// EPUB 书架视图模式: 0=宽松, 1=紧凑 /// EPUB 书架视图模式: 0=宽松, 1=紧凑
int get epubViewMode => prefs.getInt('epubViewMode') ?? 0; int get epubViewMode => prefs.getInt('epubViewMode') ?? 0;
Future<bool> setEpubViewMode(int value) => prefs.setInt('epubViewMode', value); Future<bool> setEpubViewMode(int value) => prefs.setInt('epubViewMode', value);
/// EPUB 句读列表视图模式: 0=瀑布流, 1=列表
int get highlightsViewMode => prefs.getInt('highlightsViewMode') ?? 0;
Future<bool> setHighlightsViewMode(int value) => prefs.setInt('highlightsViewMode', value);
} }

View File

@@ -1,7 +1,7 @@
name: mooknote name: mooknote
description: "app for tracking movies, books, and notes" description: "app for tracking movies, books, and notes"
publish_to: 'none' publish_to: 'none'
version: 0.2.1 version: 0.2.2
environment: environment:
sdk: ^3.5.0 sdk: ^3.5.0