diff --git a/lib/main.dart b/lib/main.dart index 2a8870c..8f032c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; 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 'pages/home_page.dart'; import 'utils/theme/app_theme.dart'; @@ -102,19 +103,21 @@ class _MyAppState extends State with WidgetsBindingObserver { var ctx = _navigatorKey.currentContext; if (ctx == null || !ctx.mounted) return; try { - final hasUpdate = await ChangelogService.hasUpdate(); - if (!hasUpdate) return; - final latestVersion = await ChangelogService.fetchLatestVersion(); - if (latestVersion == null) return; - final dismissed = UserPrefs().dismissedVersion; - if (dismissed == latestVersion) return; + final items = await ChangelogService.fetchChangelog(); + if (items.isEmpty) return; + final latest = items.first; + final info = await PackageInfo.fromPlatform(); + final localVersion = 'v${info.version}'; + if (ChangelogService.compareVersion(latest.version, localVersion) <= 0) return; + // 24 小时内 snooze 不弹 + if (DateTime.now().millisecondsSinceEpoch < UserPrefs().dismissedUpdateUntil) return; ctx = _navigatorKey.currentContext; if (ctx == null || !ctx.mounted) return; - _showUpdateDialog(ctx, latestVersion); + _showUpdateDialog(ctx, latest.version, latest.features, localVersion); } catch (_) {} } - void _showUpdateDialog(BuildContext context, String version) { + void _showUpdateDialog(BuildContext context, String version, List features, String localVersion) { if (!context.mounted) return; final colors = Theme.of(context).colorScheme; showDialog( @@ -122,17 +125,69 @@ class _MyAppState extends State with WidgetsBindingObserver { builder: (ctx) => AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('发现新版本', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('新版本 $version 已发布,是否下载更新?', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + title: Row( + children: [ + 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: [ TextButton( onPressed: () { - UserPrefs().setDismissedVersion(version); + final until = DateTime.now().add(const Duration(hours: 24)).millisecondsSinceEpoch; + UserPrefs().setDismissedUpdateUntil(until); 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( onPressed: () async { diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 571eae6..a6086ec 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -12,6 +12,7 @@ import 'book_reviews_page.dart'; import 'book_excerpts_page.dart'; import 'book_share_page.dart'; import '../../utils/epub/reader_dao.dart'; +import '../epub_reader/epub_highlights_page.dart'; import '../epub_reader/reader_screen.dart'; /// 书籍详情页 - 极简主义设计 @@ -1007,6 +1008,15 @@ class _BookDetailPageState extends State { unit: '条摘抄', 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 { unit: '条摘抄', 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 { ); } + /// 获取关联 EPUB 的句读(高亮)数量 + Future _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) { return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } @@ -1191,6 +1237,7 @@ class _BookDetailPageState extends State { void _navigateToEdit(BuildContext context) { final provider = context.read(); Navigator.pushNamed(context, '/book-form', arguments: widget.book).then((_) { + provider.setEditRefresh(); provider.loadBooks(); }); } diff --git a/lib/pages/book/book_excerpt_form_page.dart b/lib/pages/book/book_excerpt_form_page.dart index b658fae..1dbe506 100644 --- a/lib/pages/book/book_excerpt_form_page.dart +++ b/lib/pages/book/book_excerpt_form_page.dart @@ -27,8 +27,11 @@ class _BookExcerptFormPageState extends State { final _commentController = TextEditingController(); bool _isLoading = false; + String _bookTitle = ''; bool get _isEditing => widget.excerpt != null; + int get _contentChars => _contentController.text.trim().length; + int get _commentChars => _commentController.text.trim().length; @override void initState() { @@ -38,6 +41,30 @@ class _BookExcerptFormPageState extends State { _contentController.text = widget.excerpt!.content; _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 @@ -56,102 +83,197 @@ class _BookExcerptFormPageState extends State { appBar: AppBar( title: Text(_isEditing ? '编辑摘抄' : '添加摘抄'), actions: [ - TextButton( - onPressed: _isLoading ? null : _saveExcerpt, - child: _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text( - '保存', - style: TextStyle( - color: colors.onSurface, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 8), + if (_isLoading) + const Padding( + padding: EdgeInsets.all(14), + child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + TextButton( + onPressed: _saveExcerpt, + child: Text('保存', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary)), + ), + const SizedBox(width: 4), ], ), body: Form( key: _formKey, - child: ListView( - padding: const EdgeInsets.all(24), - children: [ - // 章节 - TextFormField( - controller: _chapterController, - decoration: InputDecoration( - labelText: '章节(可选)', - hintText: '例如:第一章、第3节等', - border: const OutlineInputBorder( - borderRadius: BorderRadius.zero, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.zero, - borderSide: BorderSide(color: colors.primary), - ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 所属书籍 + if (_bookTitle.isNotEmpty) ...[ + _buildBookInfo(colors), + const SizedBox(height: 24), + ], + + // 章节 + _buildLabel(colors, Icons.bookmark_outlined, '章节'), + const SizedBox(height: 8), + _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( - controller: _contentController, - maxLines: 8, - 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), - ), - ), - ), - ], + // 我的感悟 + _buildLabel(colors, Icons.lightbulb_outline, '我的感悟'), + const SizedBox(height: 8), + _buildCommentInput(colors), + ], + ), ), ), ); } + // ── 所属书籍 ── + + 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 _saveExcerpt() async { if (!_formKey.currentState!.validate()) return; - setState(() => _isLoading = true); - try { final now = DateTime.now(); final excerpt = BookExcerpt( @@ -164,25 +286,19 @@ class _BookExcerptFormPageState extends State { createdAt: _isEditing ? widget.excerpt!.createdAt : now, updatedAt: now, ); - if (_isEditing) { await context.read().updateBookExcerpt(excerpt); } else { await context.read().addBookExcerpt(excerpt); } - if (mounted) { Navigator.pop(context); ToastUtil.show(context, _isEditing ? '摘抄已更新' : '摘抄已添加'); } } catch (e) { - if (mounted) { - ToastUtil.show(context, '保存失败: $e'); - } + if (mounted) ToastUtil.show(context, '保存失败: $e'); } finally { - if (mounted) { - setState(() => _isLoading = false); - } + if (mounted) setState(() => _isLoading = false); } } } diff --git a/lib/pages/book/book_excerpts_page.dart b/lib/pages/book/book_excerpts_page.dart index 1b960a1..b3656cb 100644 --- a/lib/pages/book/book_excerpts_page.dart +++ b/lib/pages/book/book_excerpts_page.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; +import '../../utils/epub/reader_dao.dart'; import 'book_excerpt_form_page.dart'; /// 书籍摘抄列表页面 @@ -29,13 +30,15 @@ class _BookExcerptsPageState extends State { setState(() => _isLoading = true); try { final excerpts = await context.read().getBookExcerpts(widget.book.id); - setState(() { - _excerpts = excerpts; - _isLoading = false; - }); + if (mounted) { + setState(() { + _excerpts = excerpts; + _isLoading = false; + }); + } } catch (e) { - setState(() => _isLoading = false); - ToastUtil.show(context, '加载失败: $e'); + if (mounted) setState(() => _isLoading = false); + if (mounted) ToastUtil.show(context, '加载失败: $e'); } } @@ -45,282 +48,280 @@ class _BookExcerptsPageState extends State { return Scaffold( backgroundColor: colors.surface, appBar: AppBar( - title: const Text('摘抄'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _navigateToAddExcerpt(), - ), - const SizedBox(width: 8), - ], + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('摘抄', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + if (_excerpts.isNotEmpty) + Text('共 ${_excerpts.length} 条', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _navigateToAddExcerpt, + icon: const Icon(Icons.add, size: 20), + label: const Text('添加摘抄'), ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? Center(child: CircularProgressIndicator(color: colors.primary)) : _excerpts.isEmpty - ? _buildEmptyState() - : _buildExcerptList(), + ? _buildEmptyState(colors) + : _buildExcerptList(colors), ); } - Widget _buildEmptyState() { - final colors = Theme.of(context).colorScheme; + // ── 空状态 ── + + Widget _buildEmptyState(ColorScheme colors) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - 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), + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '添加记录', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: colors.onPrimary, - ), + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), ), + 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> _groupExcerptsByChapter() { - final Map> 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( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 80), itemCount: chapters.length, itemBuilder: (context, index) { final chapter = chapters[index]; - final excerpts = groups[chapter]!; - return _buildChapterSection(chapter, excerpts); + final excerpts = _groupExcerptsByChapter()[chapter]!; + return _buildChapterSection(chapter, excerpts, colors, index); }, ); } - Widget _buildChapterSection(String chapter, List excerpts) { - final colors = Theme.of(context).colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 章节标题 - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - 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), - ], - ); + /// 按章节分组 + Map> _groupExcerptsByChapter() { + final groups = >{}; + for (final e in _excerpts) { + final chapter = e.chapter.isEmpty ? '未分类' : e.chapter; + (groups[chapter] ??= []).add(e); + } + for (final key in groups.keys) { + groups[key]!.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + return groups; } - Widget _buildExcerptItem(BookExcerpt excerpt) { - final colors = Theme.of(context).colorScheme; - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: colors.outline), - ), + // ── 章节分组 ── + + Widget _buildChapterSection(String chapter, List excerpts, ColorScheme colors, int chapterIndex) { + return Padding( + padding: EdgeInsets.only(bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 摘抄内容 - Text( - excerpt.content, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 14, - color: colors.onSurface, - height: 1.5, + // 章节标题 + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 12), + child: Row( + children: [ + Container( + width: 3, + height: 14, + 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), + ), + ), + ], ), ), - - // 评论/感悟 - if (excerpt.comment.isNotEmpty) ...[ - const SizedBox(height: 8), - 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, - ), - ), - ], - ), + // 摘抄卡片 + ...excerpts.map((excerpt) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _buildExcerptCard(excerpt, colors), + )), ], ), ); } - String _formatDate(DateTime date) { - return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + // ── 摘抄卡片 ── + + Widget _buildExcerptCard(BookExcerpt excerpt, ColorScheme colors) { + return Dismissible( + key: ValueKey(excerpt.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: colors.error, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (direction) => _showDeleteDialog(excerpt), + child: Material( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _navigateToEditExcerpt(excerpt), + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 14, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 引号 + 摘抄内容 + Stack( + children: [ + Positioned( + left: -6, + top: -6, + child: Text( + '"', + style: TextStyle( + fontSize: 32, + color: colors.primary.withValues(alpha: 0.15), + fontWeight: FontWeight.bold, + height: 1, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + excerpt.content, + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.85), + height: 1.8, + ), + ), + ), + ], + ), + // 感悟 + if (excerpt.comment.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide(color: colors.primary.withValues(alpha: 0.3), width: 2), + ), + ), + child: Text( + excerpt.comment, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.55), + height: 1.6, + ), + ), + ), + ], + // 底部:日期 + 操作 + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.access_time, size: 12, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(width: 4), + Text( + _formatDate(excerpt.createdAt), + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)), + ), + const Spacer(), + GestureDetector( + onTap: () => _navigateToEditExcerpt(excerpt), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Icon(Icons.edit_outlined, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); } + String _formatDate(DateTime date) => '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + void _navigateToAddExcerpt() { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BookExcerptFormPage(bookId: widget.book.id), - ), - ).then((_) => _loadExcerpts()); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => BookExcerptFormPage(bookId: widget.book.id))).then((_) => _loadExcerpts()); } void _navigateToEditExcerpt(BookExcerpt excerpt) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BookExcerptFormPage( - bookId: widget.book.id, - excerpt: excerpt, - ), - ), - ).then((_) => _loadExcerpts()); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => BookExcerptFormPage(bookId: widget.book.id, excerpt: excerpt))).then((_) => _loadExcerpts()); } - void _showDeleteDialog(BookExcerpt excerpt) { - showDialog( + Future _showDeleteDialog(BookExcerpt excerpt) { + final colors = Theme.of(context).colorScheme; + return showDialog( context: context, builder: (context) { - final colors = Theme.of(context).colorScheme; return AlertDialog( backgroundColor: colors.surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('确定要删除这条摘抄吗?删除后可在回收站恢复。', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + 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(context, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)))), TextButton( - onPressed: () => Navigator.pop(context), - child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), - ), - ElevatedButton( 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().removeBookExcerpt(excerpt.id); - Navigator.pop(context); _loadExcerpts(); - ToastUtil.show(context, '已删除'); + if (mounted) ToastUtil.show(context, '已删除'); }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('删除'), + child: Text('删除', style: TextStyle(color: colors.error, fontWeight: FontWeight.w600)), ), ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ); }, ); diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index d6c03be..b370642 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -32,7 +32,9 @@ class _BookTabPageState extends State { late ScrollController _scrollController; AppProvider? _provider; int _lastScrollSignal = 0; + int _lastEditRefreshCounter = 0; int _prevBookCount = -1; + double _dragDelta = 0.0; // 当前拖动偏移量 void _onBookTap(Book book) { if (Breakpoint.isWideContent(context)) { @@ -79,10 +81,14 @@ class _BookTabPageState extends State { // 仅在数据实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 final statusChanged = provider.bookStatusIndex != _lastStatusIndex; final countChanged = provider.books.length != _prevBookCount; - if (statusChanged || countChanged) { + final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; + if (statusChanged || countChanged || editRefreshed) { _prevBookCount = provider.books.length; _loadFirst(); } + if (editRefreshed) { + _lastEditRefreshCounter = provider.editRefreshCounter; + } } void _onScroll() { @@ -140,19 +146,49 @@ class _BookTabPageState extends State { Widget _buildBody(BuildContext context) { final colors = Theme.of(context).colorScheme; - return Consumer(builder: (context, provider, _) { - if (_initialized && provider.bookStatusIndex != _lastStatusIndex) { - _lastStatusIndex = provider.bookStatusIndex; - WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); - } - 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()); - }); + + // 用 GestureDetector 包裹,左右滑动切换状态 + return GestureDetector( + onHorizontalDragStart: (_) => _dragDelta = 0.0, + onHorizontalDragUpdate: (details) => setState(() => _dragDelta += details.primaryDelta ?? 0), + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity; + if ((velocity ?? 0).abs() < 80) { + setState(() => _dragDelta = 0.0); + return; + } + final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个 + final provider = context.read(); + final currentIndex = provider.bookStatusIndex; + final newIndex = (currentIndex + direction + 3) % 3; + setState(() => _dragDelta = 0.0); + provider.setBookStatusIndex(newIndex); + }, + child: TweenAnimationBuilder( + 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(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() { diff --git a/lib/pages/epub_reader/book_link_page.dart b/lib/pages/epub_reader/book_link_page.dart index d2fd4b2..f3fe03a 100644 --- a/lib/pages/epub_reader/book_link_page.dart +++ b/lib/pages/epub_reader/book_link_page.dart @@ -3,6 +3,7 @@ import '../../utils/book/book_dao.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/toast_util.dart'; import '../../models/data_models.dart'; +import '../../widgets/fade_in_local_image.dart'; /// 选择关联书籍页面(带搜索功能) class BookLinkPage extends StatefulWidget { @@ -18,6 +19,7 @@ class _BookLinkPageState extends State { final BookDao _bookDao = BookDao(); final ReaderDao _readerDao = ReaderDao(); final TextEditingController _searchCtrl = TextEditingController(); + final FocusNode _searchFocus = FocusNode(); List _allBooks = []; List _filteredBooks = []; @@ -33,6 +35,7 @@ class _BookLinkPageState extends State { @override void dispose() { _searchCtrl.dispose(); + _searchFocus.dispose(); super.dispose(); } @@ -88,52 +91,17 @@ class _BookLinkPageState extends State { body: Column( children: [ // 搜索栏 - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: TextField( - controller: _searchCtrl, - onChanged: _onSearch, - decoration: InputDecoration( - hintText: '搜索书名或作者...', - hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35)), - prefixIcon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.4)), - suffixIcon: _query.isNotEmpty - ? IconButton( - icon: Icon(Icons.close, size: 18, color: colors.onSurface.withValues(alpha: 0.4)), - onPressed: () { - _searchCtrl.clear(); - _onSearch(''); - }, - ) - : null, - filled: true, - fillColor: colors.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - style: const TextStyle(fontSize: 14), - ), - ), - + _buildSearchBar(colors), // 书籍列表 Expanded( child: _isLoading ? Center(child: CircularProgressIndicator(color: colors.primary)) : _filteredBooks.isEmpty - ? Center( - child: Text( - _query.isEmpty ? '暂无书籍' : '未找到匹配的书籍', - style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)), - ), - ) - : ListView.separated( + ? _buildEmptyState(colors) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), itemCount: _filteredBooks.length, - separatorBuilder: (_, __) => - Divider(height: 0.5, indent: 72, color: colors.outlineVariant), - itemBuilder: (_, index) => _buildBookTile(_filteredBooks[index], colors), + itemBuilder: (_, index) => _buildBookCard(_filteredBooks[index], colors, index), ), ), ], @@ -141,46 +109,217 @@ class _BookLinkPageState extends State { ); } - 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; - return InkWell( - onTap: () => _selectBook(book), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row(children: [ - // 封面缩略图 - Container( - width: 44, height: 62, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: hasCover - ? Image.asset(book.coverPath!, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2))) - : Icon(Icons.book_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.2)), - ), - const SizedBox(width: 14), - // 书名 + 作者 - Expanded( - child: Column( + return Padding( + padding: EdgeInsets.only(bottom: index < _filteredBooks.length - 1 ? 10 : 0), + child: Material( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _selectBook(book), + child: Container( + padding: const EdgeInsets.all(12), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)), - if (book.authors.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(book.authors.join(', '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), - ], + // 封面 + Container( + width: 48, + height: 68, + decoration: BoxDecoration( + 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)), - ]), + ), ), ); } diff --git a/lib/pages/epub_reader/book_session.dart b/lib/pages/epub_reader/book_session.dart index 86b2041..3daa832 100644 --- a/lib/pages/epub_reader/book_session.dart +++ b/lib/pages/epub_reader/book_session.dart @@ -139,10 +139,18 @@ class BookSession { _tocItemFallback.clear(); for (int i = 0; i < _spine.length; i++) { _tocItemFallback.add(fallback); - final anchors = _spineToAnchorsMap[_spine[i].href] ?? []; + final spineHref = _spine[i].href; + final anchors = _spineToAnchorsMap[spineHref] ?? []; if (anchors.isNotEmpty) { - final lastHref = '${_spine[i].href}#${anchors.last}'; - final idx = _hrefToTocIndexMap[lastHref]; + // 先查 spine href + anchor,再回退到纯 spine href + 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) { fallback = _flatToc[idx]; } @@ -281,6 +289,38 @@ class BookSession { 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). int? findSpineIndexByUrl(String url) { String path; diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 816de51..3217c1a 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -1,16 +1,22 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../utils/epub/reader_dao.dart'; import '../../utils/epub/epub_parser.dart'; import '../../utils/epub/reader_models.dart'; import '../../utils/book/book_dao.dart'; import '../../utils/book/book_excerpt_dao.dart'; +import '../../utils/toast_util.dart'; import '../../models/data_models.dart'; import '../book/book_detail_page.dart'; +import '../book/book_excerpts_page.dart'; import 'book_link_page.dart'; import 'epub_edit_page.dart'; +import 'epub_highlights_page.dart'; +import 'highlight_detail_sheet.dart'; import 'reader_screen.dart'; /// EPUB 书籍详情页 @@ -37,6 +43,7 @@ class _EpubDetailPageState extends State { EpubBookInfo? _bookInfo; bool _descriptionExpanded = false; Future>? _excerptsFuture; + Future>>? _highlightsFuture; @override void initState() { @@ -47,6 +54,7 @@ class _EpubDetailPageState extends State { if (linkedBookId.isNotEmpty) { _excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId); } + _highlightsFuture = _dao.getHighlightsByBookId(widget.bookId); } Future _loadBookInfo() async { @@ -58,7 +66,18 @@ class _EpubDetailPageState extends State { Future _refreshBook() async { 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() { @@ -202,12 +221,20 @@ class _EpubDetailPageState extends State { 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) ...[ Divider(height: 0.5, thickness: 0.5, color: colors.outline), - _buildSectionHeader('书籍摘抄', colors), + _buildExcerptsSectionHeader(colors), Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + padding: const EdgeInsets.fromLTRB(0, 0, 0, 24), child: _buildExcerptsList(colors), ), ], @@ -366,6 +393,7 @@ class _EpubDetailPageState extends State { builder: (context, snapshot) { if (!snapshot.hasData || snapshot.data!.isEmpty) { return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), decoration: BoxDecoration( color: colors.surfaceContainerHigh, @@ -382,71 +410,398 @@ class _EpubDetailPageState extends State { ); } final excerpts = snapshot.data!; - final showCount = excerpts.length > 5 ? 5 : excerpts.length; - return Column( - children: [ - for (int i = 0; i < showCount; i++) - Padding( - padding: EdgeInsets.only(bottom: i < showCount - 1 ? 8 : 0), - child: Container( - width: double.infinity, - 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)), - ), - ), - ], + return SizedBox( + height: 140, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: excerpts.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (context, i) => _buildExcerptCard(excerpts[i], colors), + ), ); }, ); } + 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>>( + 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 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 _deleteExcerpt(BookExcerpt excerpt, ColorScheme colors) async { + final confirmed = 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, 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 highlight, ColorScheme colors) { + showHighlightDetailSheet( + context, + highlight: highlight, + book: _book, + onDelete: () => _deleteHighlight(highlight['id'] as int, colors), + ); + } + + Future _deleteHighlight(int id, ColorScheme colors) async { + final confirmed = 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, 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 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; + 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) { showModalBottomSheet( context: context, diff --git a/lib/pages/epub_reader/epub_highlights_page.dart b/lib/pages/epub_reader/epub_highlights_page.dart new file mode 100644 index 0000000..d6727aa --- /dev/null +++ b/lib/pages/epub_reader/epub_highlights_page.dart @@ -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 book; + + const EpubHighlightsPage({ + super.key, + required this.bookId, + required this.book, + }); + + @override + State createState() => _EpubHighlightsPageState(); +} + +class _EpubHighlightsPageState extends State { + final ReaderDao _dao = ReaderDao(); + List> _highlights = []; + bool _isLoading = true; + bool _isListMode = UserPrefs().highlightsViewMode == 1; + + @override + void initState() { + super.initState(); + _loadHighlights(); + } + + Future _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 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 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 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 _deleteHighlight(int id) async { + await _dao.deleteHighlight(id); + _loadHighlights(); + if (mounted) ToastUtil.show(context, '已删除'); + } +} diff --git a/lib/pages/epub_reader/epub_selection_toolbar.dart b/lib/pages/epub_reader/epub_selection_toolbar.dart new file mode 100644 index 0000000..50b6698 --- /dev/null +++ b/lib/pages/epub_reader/epub_selection_toolbar.dart @@ -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 copyTextToClipboard(String text) async { + await Clipboard.setData(ClipboardData(text: text)); +} diff --git a/lib/pages/epub_reader/highlight_detail_sheet.dart b/lib/pages/epub_reader/highlight_detail_sheet.dart new file mode 100644 index 0000000..ecf0b33 --- /dev/null +++ b/lib/pages/epub_reader/highlight_detail_sheet.dart @@ -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 highlight, + required Map 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, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); +} + diff --git a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart index b374c37..025bf05 100644 --- a/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart +++ b/lib/pages/epub_reader/mixins/spine_navigation_mixin.dart @@ -24,6 +24,9 @@ mixin _SpineNavigationMixin on State { // === Cross-mixin: _ThemeMixin === EpubTheme getEpubTheme(); + // === Cross-mixin: _TextSelectionMixin === + Future restoreHighlightsForCurrentSpine(); + List getAnchorsForSpine(String spinePath) { return bookSession.getAnchorsForSpine(spinePath); } @@ -109,6 +112,9 @@ mixin _SpineNavigationMixin on State { setState(() { isWebViewLoading = false; }); + + // 恢复当前 spine 的高亮(跨 mixin: _TextSelectionMixin) + await restoreHighlightsForCurrentSpine(); } Future preloadNextOf(int currentIndex) async { diff --git a/lib/pages/epub_reader/mixins/text_selection_mixin.dart b/lib/pages/epub_reader/mixins/text_selection_mixin.dart new file mode 100644 index 0000000..afff700 --- /dev/null +++ b/lib/pages/epub_reader/mixins/text_selection_mixin.dart @@ -0,0 +1,315 @@ +part of '../reader_screen.dart'; + +/// 文本选中和高亮功能的 mixin +/// +/// 提供: +/// - 选中文字后弹出工具条(高亮/复制/摘抄) +/// - 高亮持久化到 [book_annotations] 表 +/// - spine 加载后恢复已保存的高亮 +mixin _TextSelectionMixin on State { + // === 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? _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? 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 → 应用 → 关闭工具条 + Future _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 = { + '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 _onCopyButton() async { + final text = _selectionText; + if (text == null) return; + await copyTextToClipboard(text); + if (!mounted) return; + ToastUtil.show(context, '已复制'); + _dismissSelectionToolbar(); + } + + /// 摘抄:保存到 book_excerpts 表 + 用蓝色高亮标注 → 关闭工具条 + Future _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 = { + '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 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 = >[]; + for (final h in spineHighlights) { + final cfi = h['cfi'] as String? ?? ''; + if (cfi.isEmpty) continue; + try { + final decoded = jsonDecode(cfi) as Map; + 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; + } + } +} diff --git a/lib/pages/epub_reader/reader_renderer.dart b/lib/pages/epub_reader/reader_renderer.dart index 48b62a2..6fc656f 100644 --- a/lib/pages/epub_reader/reader_renderer.dart +++ b/lib/pages/epub_reader/reader_renderer.dart @@ -158,6 +158,7 @@ class ReaderRenderer extends StatefulWidget { final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap; final Function(String url) onLinkTap; final bool Function(String url) shouldHandleLinkTap; + final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map? selectionInfo)? onTextSelection; final bool shouldShowWebView; final EpubTheme initializeTheme; final String statusBarLeftContent; @@ -186,6 +187,7 @@ class ReaderRenderer extends StatefulWidget { required this.onFootnoteTap, required this.onLinkTap, required this.shouldHandleLinkTap, + this.onTextSelection, required this.shouldShowWebView, required this.initializeTheme, required this.statusBarLeftContent, @@ -360,6 +362,11 @@ class _ReaderRendererState extends State details.localPosition.dx, details.localPosition.dy, ); + // 编程式选中最接近长按位置的文字(触发 onTextSelection 回调) + await _webViewController.selectWordAt( + details.localPosition.dx, + details.localPosition.dy, + ); } @override @@ -495,6 +502,7 @@ class _ReaderRendererState extends State onFootnoteTap: widget.onFootnoteTap, onLinkTap: widget.onLinkTap, shouldHandleLinkTap: widget.shouldHandleLinkTap, + onTextSelection: widget.onTextSelection, ), shouldShowWebView: widget.shouldShowWebView, coverRelativePath: widget.bookSession.book['cover_path'] as String?, diff --git a/lib/pages/epub_reader/reader_screen.dart b/lib/pages/epub_reader/reader_screen.dart index 850f516..9441d86 100644 --- a/lib/pages/epub_reader/reader_screen.dart +++ b/lib/pages/epub_reader/reader_screen.dart @@ -1,9 +1,10 @@ import 'dart:async'; - +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../utils/database_helper.dart'; import '../../utils/epub/epub_theme.dart'; import '../../utils/epub/epub_webview_handler.dart'; import '../../utils/epub/epub_stream_service.dart'; @@ -14,11 +15,14 @@ import '../../utils/epub/reader_dao.dart'; import '../../utils/toast_util.dart'; import 'book_session.dart'; import 'reader_renderer.dart'; +import 'reader_webview.dart'; import 'control_panel.dart'; import 'toc_drawer.dart'; import 'image_viewer.dart'; import 'footnote_popup.dart'; import 'search_sheet.dart'; +import 'epub_selection_toolbar.dart'; +import 'selection_handles.dart'; part 'mixins/spine_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/image_viewer_mixin.dart'; part 'mixins/footnote_mixin.dart'; +part 'mixins/text_selection_mixin.dart'; /// Reads EPUB directly from compressed file without extraction. class ReaderScreen extends StatefulWidget { @@ -35,6 +40,9 @@ class ReaderScreen extends StatefulWidget { final String title; final String? coverPath; final Map? bookData; + final int? initialSpineIndex; + final String? scrollToXPath; + final String? scrollToText; const ReaderScreen({ super.key, @@ -43,6 +51,9 @@ class ReaderScreen extends StatefulWidget { required this.title, this.coverPath, this.bookData, + this.initialSpineIndex, + this.scrollToXPath, + this.scrollToText, }); @override @@ -58,7 +69,8 @@ class _ReaderScreenState extends State _ThemeMixin, _LinkHandlingMixin, _ImageViewerMixin, - _FootnoteMixin { + _FootnoteMixin, + _TextSelectionMixin { @override late final EpubWebViewHandler webViewHandler; @@ -137,6 +149,7 @@ class _ReaderScreenState extends State // Services final EpubStreamService _streamService = EpubStreamService(); + @override final ReaderDao _readerDao = ReaderDao(); final EpubParser _epubParser = EpubParser(); @@ -169,6 +182,8 @@ class _ReaderScreenState extends State // Load settings first, then book ReaderSettings.load().then((settings) { readerSettings = settings; + _pendingScrollXPath = widget.scrollToXPath; + _pendingScrollText = widget.scrollToText; if (mounted) { setupVolumeControl(); _loadBook(); @@ -288,7 +303,7 @@ class _ReaderScreenState extends State if (mounted) { setState(() { - currentSpineItemIndex = bookSession.initialChapterIndex; + currentSpineItemIndex = widget.initialSpineIndex ?? bookSession.initialChapterIndex; }); updateProgressDebounced(); _loadBookmarks(); @@ -512,7 +527,10 @@ class _ReaderScreenState extends State onPerformPageTurn: handlePageTurn, onToggleControls: toggleControls, onInitialized: () async { - final ratio = bookSession.initialScrollPosition; + // 从详情页跳转时不恢复滚动位置 + final ratio = widget.initialSpineIndex != null + ? null + : bookSession.initialScrollPosition; await loadCarousel(restoreScrollRatio: ratio); }, onPageCountReady: (totalPages) async { @@ -536,6 +554,7 @@ class _ReaderScreenState extends State onFootnoteTap: handleFootnoteTap, onLinkTap: handleLinkTap, shouldHandleLinkTap: shouldHandleLinkTap, + onTextSelection: handleTextSelection, shouldShowWebView: shouldShowWebView, initializeTheme: epubTheme, statusBarLeftContent: activateTocTitle, @@ -670,6 +689,23 @@ class _ReaderScreenState extends State ), ), ), + // 文本选中工具条 + if (isSelectionToolbarVisible) + EpubSelectionToolbar( + selectionRect: _selectionRect!, + onCopy: _onCopyButton, + onHighlight: _onHighlightButton, + onExcerpt: _onExcerptButton, + onDismiss: _dismissSelectionToolbar, + ), + // 选区手柄(起点和终点) + if (isSelectionToolbarVisible) + SelectionHandles( + startPosition: _startHandle, + endPosition: _endHandle, + onDragStart: onDragStartHandle, + onDragEnd: onDragEndHandle, + ), ], ), ), diff --git a/lib/pages/epub_reader/reader_webview.dart b/lib/pages/epub_reader/reader_webview.dart index e183265..47754bf 100644 --- a/lib/pages/epub_reader/reader_webview.dart +++ b/lib/pages/epub_reader/reader_webview.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; @@ -59,6 +60,11 @@ class ReaderWebViewController { await _webViewState?._checkTapElementAt(x, y); } + /// 获取当前 iframe 中用户选中的文字(长按后调用) + Future getTextSelection() async { + return await _webViewState?._getTextSelection(); + } + Future takeScreenshot() async { return await _webViewState?._takeScreenshot(); } @@ -82,10 +88,121 @@ class ReaderWebViewController { Future runJavaScriptReturningResult(String js) async { return await _webViewState?._controller?.evaluateJavascript(source: js); } + + /// 获取当前 iframe 中选区的详细信息(用于高亮持久化) + Future?> 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.from(result); + return null; + } + + /// 应用单个高亮到当前 iframe + Future applyHighlight(Map 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 applyHighlights(List> highlights) async { + if (highlights.isEmpty) return 0; + final list = jsonEncode(highlights.map((h) { + // 优先使用嵌套的 info 对象(来自 restoreHighlightsForCurrentSpine) + final rawInfo = h['info']; + Map info; + if (rawInfo is Map) { + info = Map.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 removeHighlight(String id) async { + await _webViewState?._controller?.evaluateJavascript( + source: 'window.__mooknoteHL && window.__mooknoteHL.removeHighlight("$id")', + ); + } + + /// 移除当前文档中所有高亮标记 + Future clearAllHighlights() async { + await _webViewState?._controller?.evaluateJavascript( + source: 'window.__mooknoteHL && window.__mooknoteHL.clearAllHighlights()', + ); + } + + /// 清除当前选区 + Future clearSelection() async { + await _webViewState?._controller?.evaluateJavascript( + source: 'window.__mooknoteHL && window.__mooknoteHL.clearSelection()', + ); + } + + /// 编程式选中长按位置的文字(用于 Flutter 长按手势触发文本选择) + Future selectWordAt(double x, double y) async { + await _webViewState?._controller?.evaluateJavascript( + source: 'window.__mooknoteHL && window.__mooknoteHL.selectWordAt($x, $y)', + ); + } + + /// 拖动手柄时扩展选区 + Future 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 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 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( - disableContextMenu: true, + disableContextMenu: false, disableLongPressContextMenuOnLinks: true, selectionGranularity: SelectionGranularity.CHARACTER, transparentBackground: true, @@ -98,7 +215,7 @@ final InAppWebViewSettings defaultSettings = InAppWebViewSettings( disableHorizontalScroll: true, disableVerticalScroll: true, supportZoom: false, - useHybridComposition: false, + useHybridComposition: true, resourceCustomSchemes: [EpubWebViewHandler.virtualScheme], verticalScrollBarEnabled: false, horizontalScrollBarEnabled: false, @@ -116,6 +233,7 @@ class ReaderWebViewCallbacks { final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap; final Function(String url) onLinkTap; final bool Function(String url) shouldHandleLinkTap; + final Function(String selectedText, Rect rect, int spineIndex, double scrollRatio, Offset? startHandle, Offset? endHandle, Map? selectionInfo)? onTextSelection; const ReaderWebViewCallbacks({ required this.onInitialized, @@ -127,6 +245,7 @@ class ReaderWebViewCallbacks { required this.onFootnoteTap, required this.onLinkTap, required this.shouldHandleLinkTap, + this.onTextSelection, }); } @@ -184,6 +303,16 @@ class _ReaderWebViewState extends State { widget.controller._attachState(this); } + @override + void dispose() { + widget.controller._attachState(null); + _bridge.detach(); + _headlessWebView?.dispose(); + _headlessWebView = null; + _controller = null; + super.dispose(); + } + @override void didUpdateWidget(covariant ReaderWebView oldWidget) { super.didUpdateWidget(oldWidget); @@ -246,6 +375,21 @@ class _ReaderWebViewState extends State { Future _checkTapElementAt(double x, double y) => _api.checkTapElementAt(x, y); + /// 获取当前 iframe 中用户选中的文字 + Future _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) { return InAppWebViewInitialData( data: generateSkeletonHtml( @@ -302,6 +446,642 @@ class _ReaderWebViewState extends State { } 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 内所有文本节点,逐个包裹在 中 + /// 保留段落结构,不用 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(); } @@ -329,6 +1109,9 @@ class _ReaderWebViewState extends State { shouldOverrideUrlLoading: _shouldOverrideUrlLoading, onWebViewCreated: _onWebViewCreated, onLoadStop: _onLoadStop, + onConsoleMessage: (controller, consoleMessage) { + debugPrint('[EPUB-JS] ${consoleMessage.message}'); + }, ) : Container(color: _currentTheme.surfaceColor), ), @@ -497,6 +1280,65 @@ class _ReaderWebViewState extends State { } }, ); + + // ─── 文本选择(划线功能) ──────────────── + 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? 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.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 _takeScreenshot() async { diff --git a/lib/pages/epub_reader/selection_handles.dart b/lib/pages/epub_reader/selection_handles.dart new file mode 100644 index 0000000..13cbea4 --- /dev/null +++ b/lib/pages/epub_reader/selection_handles.dart @@ -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, + ); + } +} diff --git a/lib/pages/epub_reader/selection_overlay.dart b/lib/pages/epub_reader/selection_overlay.dart new file mode 100644 index 0000000..2f17057 --- /dev/null +++ b/lib/pages/epub_reader/selection_overlay.dart @@ -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 createState() => _SelectionOverlayState(); +} + +class _SelectionOverlayState extends State { + 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; +} diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index 0641883..be3e68f 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -423,6 +423,7 @@ class _MainContentPageState extends State { return PageView( controller: _pageController, + physics: const NeverScrollableScrollPhysics(), // 仅点击标签栏切换,禁止滑动切换 onPageChanged: (index) { if (!_isTabTap && index < tabs.length) { provider.setMainTabIndex(tabs[index].originalIndex); diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 2228df5..76cf1b6 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -1147,6 +1147,7 @@ class _MovieDetailPageState extends State { void _navigateToEdit(BuildContext context) { final provider = context.read(); Navigator.pushNamed(context, '/movie-form', arguments: widget.movie).then((_) { + provider.setEditRefresh(); provider.loadMovies(); }); } diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 3a55174..b2f05c6 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -31,8 +31,10 @@ class _MovieTabPageState extends State { late ScrollController _scrollController; AppProvider? _provider; int _lastScrollSignal = 0; + int _lastEditRefreshCounter = 0; int _prevMovieCount = -1; int _prevLayoutStyle = -1; + double _swipeOffset = 0.0; // 当前拖动偏移量(用于左右滑动切换状态) static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; @@ -71,11 +73,15 @@ class _MovieTabPageState extends State { final statusChanged = provider.movieStatusIndex != _lastStatusIndex; final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle; final countChanged = provider.movies.length != _prevMovieCount; - if (statusChanged || layoutChanged || countChanged) { + final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; + if (statusChanged || layoutChanged || countChanged || editRefreshed) { _prevLayoutStyle = provider.movieLayoutStyle; _prevMovieCount = provider.movies.length; _loadFirst(); } + if (editRefreshed) { + _lastEditRefreshCounter = provider.editRefreshCounter; + } } void _onScroll() { @@ -166,25 +172,52 @@ class _MovieTabPageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); } - if (_items.isEmpty && _isLoading) return _buildSkeleton(); - - if (_items.isEmpty) { + 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.movieStatusIndex)], + ), + ); + } return RefreshIndicator( onRefresh: _refresh, color: colors.primary, backgroundColor: colors.surface, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [_buildEmptyState(context, provider.movieStatusIndex)], - ), + child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), ); - } + }(); - return RefreshIndicator( - onRefresh: _refresh, - color: colors.primary, - backgroundColor: colors.surface, - child: provider.movieLayoutStyle == 1 ? _buildListView() : provider.movieLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), + // 用 GestureDetector 包裹,左右滑动切换状态(用 Transform 而非 AnimatedContainer padding 避免负数崩溃) + return GestureDetector( + onHorizontalDragStart: (_) => _swipeOffset = 0.0, + onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0), + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity; + if ((velocity ?? 0).abs() < 80) { + setState(() => _swipeOffset = 0.0); + return; + } + final direction = (velocity ?? 0) > 0 ? -1 : 1; // 右滑→上一个,左滑→下一个 + final currentIndex = provider.movieStatusIndex; + final newIndex = (currentIndex + direction + 3) % 3; + setState(() => _swipeOffset = 0.0); + provider.setMovieStatusIndex(newIndex); + }, + child: TweenAnimationBuilder( + 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, + ), ); }, ); diff --git a/lib/pages/recycle_bin_page.dart b/lib/pages/recycle_bin_page.dart index 2141ea1..bdf50ac 100644 --- a/lib/pages/recycle_bin_page.dart +++ b/lib/pages/recycle_bin_page.dart @@ -362,6 +362,35 @@ class _RecycleBinPageState extends State { } Future _restore(_DeletedItem item) async { + final confirmed = await showDialog( + 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(); switch (item.type) { case _ItemType.movie: diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 582c8c5..d15c6bd 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -71,7 +71,11 @@ class AppProvider extends ChangeNotifier { // 回到顶部信号(点击首页图标时递增) int _scrollToTopSignal = 0; int get scrollToTopSignal => _scrollToTopSignal; - + + // 编辑后刷新信号(影视/书籍编辑返回时递增) + int _editRefreshCounter = 0; + int get editRefreshCounter => _editRefreshCounter; + // 初始化数据库 Future initDatabase() async { debugPrint('[AppProvider] initDatabase'); @@ -130,6 +134,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + /// 编辑返回后触发列表页重载 + void setEditRefresh() { + _editRefreshCounter++; + notifyListeners(); + } + // ─── 分页加载(供列表页触底加载使用)──────────────────────── static const int _pageSize = 20; diff --git a/lib/utils/epub/reader_dao.dart b/lib/utils/epub/reader_dao.dart index ad102e7..92f69a3 100644 --- a/lib/utils/epub/reader_dao.dart +++ b/lib/utils/epub/reader_dao.dart @@ -145,6 +145,49 @@ class ReaderDao { ); } + // ─── highlights / annotations (epub) ────────────────────── + + /// 保存 EPUB 高亮批注 + Future saveHighlight(Map annotation) async { + final db = await _db.database; + return db.insert('book_annotations', { + ...annotation, + 'type': 'highlight', + }); + } + + /// 获取某 book_id(books.id)的所有 EPUB 高亮 + Future>> 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 deleteHighlight(int id) async { + final db = await _db.database; + return db.delete( + 'book_annotations', + where: 'id = ? AND type = ?', + whereArgs: [id, 'highlight'], + ); + } + + /// 删除摘抄对应的蓝色高亮标注(通过内容匹配) + Future 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 ────────────────────────────────────────────────── /// 获取某本书的所有书签 diff --git a/lib/utils/epub/reader_scripts.dart b/lib/utils/epub/reader_scripts.dart index 6e9e6b3..50daed4 100644 --- a/lib/utils/epub/reader_scripts.dart +++ b/lib/utils/epub/reader_scripts.dart @@ -98,5 +98,5 @@ body.lumina-force-override-font,body.lumina-force-override-font *{font-family:va '''; 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;} '''; diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index dfe63e0..3bde8ba 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -242,6 +242,10 @@ class UserPrefs { String get dismissedVersion => prefs.getString('dismissedVersion') ?? ''; Future setDismissedVersion(String value) => prefs.setString('dismissedVersion', value); + /// 更新提醒 snooze 到指定时间戳(ms),24 小时内不弹 + int get dismissedUpdateUntil => prefs.getInt('dismissedUpdateUntil') ?? 0; + Future setDismissedUpdateUntil(int value) => prefs.setInt('dismissedUpdateUntil', value); + // ========== EPUB 阅读器 ========== /// EPUB 阅读器字体大小 @@ -251,4 +255,8 @@ class UserPrefs { /// EPUB 书架视图模式: 0=宽松, 1=紧凑 int get epubViewMode => prefs.getInt('epubViewMode') ?? 0; Future setEpubViewMode(int value) => prefs.setInt('epubViewMode', value); + + /// EPUB 句读列表视图模式: 0=瀑布流, 1=列表 + int get highlightsViewMode => prefs.getInt('highlightsViewMode') ?? 0; + Future setHighlightsViewMode(int value) => prefs.setInt('highlightsViewMode', value); } diff --git a/pubspec.yaml b/pubspec.yaml index 3f4ea0e..5a49eea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "app for tracking movies, books, and notes" publish_to: 'none' -version: 0.2.1 +version: 0.2.2 environment: sdk: ^3.5.0