generated from dellevin/template
新增epub书摘,高亮文字,适配平板
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
else
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
TextButton(
|
||||||
)
|
onPressed: _saveExcerpt,
|
||||||
: Text(
|
child: Text('保存', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary)),
|
||||||
'保存',
|
),
|
||||||
style: TextStyle(
|
const SizedBox(width: 4),
|
||||||
color: colors.onSurface,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
||||||
children: [
|
child: Column(
|
||||||
// 章节
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
TextFormField(
|
children: [
|
||||||
controller: _chapterController,
|
// 所属书籍
|
||||||
decoration: InputDecoration(
|
if (_bookTitle.isNotEmpty) ...[
|
||||||
labelText: '章节(可选)',
|
_buildBookInfo(colors),
|
||||||
hintText: '例如:第一章、第3节等',
|
const SizedBox(height: 24),
|
||||||
border: const OutlineInputBorder(
|
],
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
),
|
// 章节
|
||||||
focusedBorder: OutlineInputBorder(
|
_buildLabel(colors, Icons.bookmark_outlined, '章节'),
|
||||||
borderRadius: BorderRadius.zero,
|
const SizedBox(height: 8),
|
||||||
borderSide: BorderSide(color: colors.primary),
|
_buildInput(
|
||||||
),
|
colors: colors,
|
||||||
|
controller: _chapterController,
|
||||||
|
hintText: '例如:第一章、第3节',
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
// 摘抄内容
|
||||||
|
_buildLabel(colors, Icons.format_quote, '摘抄内容', required: true),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildContentInput(colors),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 摘抄内容
|
// 我的感悟
|
||||||
TextFormField(
|
_buildLabel(colors, Icons.lightbulb_outline, '我的感悟'),
|
||||||
controller: _contentController,
|
const SizedBox(height: 8),
|
||||||
maxLines: 8,
|
_buildCommentInput(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),
|
|
||||||
|
|
||||||
// 评论/感悟
|
|
||||||
TextFormField(
|
|
||||||
controller: _commentController,
|
|
||||||
maxLines: 5,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_excerpts = excerpts;
|
setState(() {
|
||||||
_isLoading = false;
|
_excerpts = excerpts;
|
||||||
});
|
_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: Column(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.all(40),
|
||||||
children: [
|
child: Column(
|
||||||
Container(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 80,
|
children: [
|
||||||
height: 80,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 80,
|
||||||
color: colors.surfaceContainerHighest,
|
height: 80,
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.format_quote_outlined,
|
|
||||||
size: 40,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(
|
|
||||||
'暂无摘抄',
|
|
||||||
style: TextStyle(
|
|
||||||
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(
|
decoration: BoxDecoration(
|
||||||
color: colors.primary,
|
color: colors.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(24),
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'添加记录',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: colors.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Icon(Icons.format_quote_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
],
|
Text('暂无摘抄', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('记录书中触动人心的文字', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按章节分组摘抄数据
|
// ── 摘抄列表 ──
|
||||||
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(
|
Container(
|
||||||
fontSize: 14,
|
width: 3,
|
||||||
color: colors.onSurface,
|
height: 14,
|
||||||
height: 1.5,
|
decoration: BoxDecoration(
|
||||||
|
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(
|
||||||
|
'${excerpts.length}',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: colors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 摘抄卡片
|
||||||
// 评论/感悟
|
...excerpts.map((excerpt) => Padding(
|
||||||
if (excerpt.comment.isNotEmpty) ...[
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
const SizedBox(height: 8),
|
child: _buildExcerptCard(excerpt, colors),
|
||||||
Container(
|
)),
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
excerpt.comment,
|
|
||||||
maxLines: 1,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,19 +146,49 @@ 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, _) {
|
|
||||||
if (_initialized && provider.bookStatusIndex != _lastStatusIndex) {
|
// 用 GestureDetector 包裹,左右滑动切换状态
|
||||||
_lastStatusIndex = provider.bookStatusIndex;
|
return GestureDetector(
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
|
onHorizontalDragStart: (_) => _dragDelta = 0.0,
|
||||||
}
|
onHorizontalDragUpdate: (details) => setState(() => _dragDelta += details.primaryDelta ?? 0),
|
||||||
if (_items.isEmpty && _isLoading) return _buildSkeleton();
|
onHorizontalDragEnd: (details) {
|
||||||
if (_items.isEmpty) {
|
final velocity = details.primaryVelocity;
|
||||||
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
|
if ((velocity ?? 0).abs() < 80) {
|
||||||
child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context, provider.bookStatusIndex)]));
|
setState(() => _dragDelta = 0.0);
|
||||||
}
|
return;
|
||||||
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
|
}
|
||||||
child: _layoutStyle == 1 ? _buildListView() : _buildGridView());
|
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) {
|
||||||
|
_lastStatusIndex = provider.bookStatusIndex;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
|
||||||
|
}
|
||||||
|
final content = () {
|
||||||
|
if (_items.isEmpty && _isLoading) return _buildSkeleton();
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
|
||||||
|
child: ListView(physics: const AlwaysScrollableScrollPhysics(), children: [_buildEmptyState(context, provider.bookStatusIndex)]));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface,
|
||||||
|
child: _layoutStyle == 1 ? _buildListView() : _buildGridView());
|
||||||
|
}();
|
||||||
|
return content;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridView() {
|
Widget _buildGridView() {
|
||||||
|
|||||||
@@ -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,46 +109,217 @@ 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(
|
||||||
onTap: () => _selectBook(book),
|
padding: EdgeInsets.only(bottom: index < _filteredBooks.length - 1 ? 10 : 0),
|
||||||
child: Padding(
|
child: Material(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
color: colors.surfaceContainerHigh,
|
||||||
child: Row(children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
// 封面缩略图
|
clipBehavior: Clip.antiAlias,
|
||||||
Container(
|
child: InkWell(
|
||||||
width: 44, height: 62,
|
onTap: () => _selectBook(book),
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: colors.surfaceContainerHighest,
|
padding: const EdgeInsets.all(12),
|
||||||
borderRadius: BorderRadius.circular(6),
|
child: Row(
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: hasCover
|
|
||||||
? Image.asset(book.coverPath!, fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) =>
|
|
||||||
Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)))
|
|
||||||
: Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
// 书名 + 作者
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis,
|
// 封面
|
||||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)),
|
Container(
|
||||||
if (book.authors.isNotEmpty) ...[
|
width: 48,
|
||||||
const SizedBox(height: 3),
|
height: 68,
|
||||||
Text(book.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis,
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
|
color: colors.surfaceContainerHighest,
|
||||||
],
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.shadow.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: hasCover
|
||||||
|
? FadeInLocalImage(
|
||||||
|
path: book.coverPath,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Icon(Icons.menu_book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
// 书名 + 作者
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
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) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(Icons.chevron_right, size: 18, color: colors.onSurface.withValues(alpha: 0.25)),
|
),
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
291
lib/pages/epub_reader/epub_highlights_page.dart
Normal file
291
lib/pages/epub_reader/epub_highlights_page.dart
Normal 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, '已删除');
|
||||||
|
}
|
||||||
|
}
|
||||||
150
lib/pages/epub_reader/epub_selection_toolbar.dart
Normal file
150
lib/pages/epub_reader/epub_selection_toolbar.dart
Normal 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));
|
||||||
|
}
|
||||||
404
lib/pages/epub_reader/highlight_detail_sheet.dart
Normal file
404
lib/pages/epub_reader/highlight_detail_sheet.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
315
lib/pages/epub_reader/mixins/text_selection_mixin.dart
Normal file
315
lib/pages/epub_reader/mixins/text_selection_mixin.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 CSS(loadFrame 会替换 contentDocument,CSS 会丢失)
|
||||||
|
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 {
|
||||||
|
|||||||
110
lib/pages/epub_reader/selection_handles.dart
Normal file
110
lib/pages/epub_reader/selection_handles.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
lib/pages/epub_reader/selection_overlay.dart
Normal file
131
lib/pages/epub_reader/selection_overlay.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,25 +172,52 @@ class _MovieTabPageState extends State<MovieTabPage> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_items.isEmpty && _isLoading) return _buildSkeleton();
|
final content = () {
|
||||||
|
if (_items.isEmpty && _isLoading) return _buildSkeleton();
|
||||||
if (_items.isEmpty) {
|
if (_items.isEmpty) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _refresh,
|
||||||
|
color: colors.primary,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [_buildEmptyState(context, provider.movieStatusIndex)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _refresh,
|
onRefresh: _refresh,
|
||||||
color: colors.primary,
|
color: colors.primary,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
child: ListView(
|
child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(),
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
children: [_buildEmptyState(context, provider.movieStatusIndex)],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}();
|
||||||
|
|
||||||
return RefreshIndicator(
|
// 用 GestureDetector 包裹,左右滑动切换状态(用 Transform 而非 AnimatedContainer padding 避免负数崩溃)
|
||||||
onRefresh: _refresh,
|
return GestureDetector(
|
||||||
color: colors.primary,
|
onHorizontalDragStart: (_) => _swipeOffset = 0.0,
|
||||||
backgroundColor: colors.surface,
|
onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0),
|
||||||
child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(),
|
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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ 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;
|
||||||
|
|||||||
@@ -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_id(books.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 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 获取某本书的所有书签
|
/// 获取某本书的所有书签
|
||||||
|
|||||||
@@ -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;}
|
||||||
''';
|
''';
|
||||||
|
|||||||
@@ -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 到指定时间戳(ms),24 小时内不弹
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user