diff --git a/lib/pages/epub_reader/control_panel.dart b/lib/pages/epub_reader/control_panel.dart index 554161b..6397c7f 100644 --- a/lib/pages/epub_reader/control_panel.dart +++ b/lib/pages/epub_reader/control_panel.dart @@ -40,6 +40,7 @@ class ControlPanel extends StatefulWidget { final void Function(int bgColor, int textColor) onCustomColorChanged; final bool currentPageHasBookmark; final VoidCallback onBookmarkToggle; + final VoidCallback onSearchTap; const ControlPanel({ super.key, @@ -78,6 +79,7 @@ class ControlPanel extends StatefulWidget { required this.onCustomColorChanged, required this.currentPageHasBookmark, required this.onBookmarkToggle, + required this.onSearchTap, }); bool get isVertical => direction == 1; @@ -313,6 +315,10 @@ class _ControlPanelState extends State { overflow: TextOverflow.ellipsis, ), actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: widget.onSearchTap, + ), IconButton( icon: Icon( widget.currentPageHasBookmark diff --git a/lib/pages/epub_reader/epub_detail_page.dart b/lib/pages/epub_reader/epub_detail_page.dart index 5dcf8bb..816de51 100644 --- a/lib/pages/epub_reader/epub_detail_page.dart +++ b/lib/pages/epub_reader/epub_detail_page.dart @@ -6,6 +6,7 @@ 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 '../../models/data_models.dart'; import '../book/book_detail_page.dart'; import 'book_link_page.dart'; @@ -35,12 +36,17 @@ class _EpubDetailPageState extends State { late Map _book; EpubBookInfo? _bookInfo; bool _descriptionExpanded = false; + Future>? _excerptsFuture; @override void initState() { super.initState(); _book = widget.book; _loadBookInfo(); + final linkedBookId = _book['book_id'] as String? ?? ''; + if (linkedBookId.isNotEmpty) { + _excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId); + } } Future _loadBookInfo() async { @@ -196,6 +202,16 @@ class _EpubDetailPageState extends State { child: _buildLinkedBookCard(colors), ), + // ── 书籍摘抄(仅关联书籍时显示)── + if ((_book['book_id'] as String? ?? '').isNotEmpty) ...[ + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + _buildSectionHeader('书籍摘抄', colors), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: _buildExcerptsList(colors), + ), + ], + // ── 其他作品(同作者)── if (author.isNotEmpty) ...[ Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -343,6 +359,94 @@ class _EpubDetailPageState extends State { ); } + Widget _buildExcerptsList(ColorScheme colors) { + if (_excerptsFuture == null) return const SizedBox.shrink(); + return FutureBuilder>( + future: _excerptsFuture, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.format_quote, 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))), + ], + ), + ); + } + 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)), + ), + ), + ], + ); + }, + ); + } + void _showLinkedBookActions(ColorScheme colors, String title) { showModalBottomSheet( context: context, diff --git a/lib/pages/epub_reader/mixins/progress_mixin.dart b/lib/pages/epub_reader/mixins/progress_mixin.dart index 9446b68..56ea56f 100644 --- a/lib/pages/epub_reader/mixins/progress_mixin.dart +++ b/lib/pages/epub_reader/mixins/progress_mixin.dart @@ -18,18 +18,31 @@ mixin _ProgressMixin on State { Timer? get progressDebouncer; set progressDebouncer(Timer? v); + Map get chapterPageCounts; + void updateProgressDebounced() { progressDebouncer?.cancel(); progressDebouncer = Timer(const Duration(milliseconds: 150), () { if (!mounted) return; if (isWebViewLoading) return; - final pageInChapterStr = - '${currentPageInChapter + 1}/$totalPagesInChapter'; + final totalChapters = bookSession.spine.length; - if (displayProgress != pageInChapterStr) { + // 用当前章节页数估算全书页数 + final avgPages = totalPagesInChapter > 0 ? totalPagesInChapter : 1; + final estimatedTotalPages = avgPages * totalChapters; + + // 估算绝对页码 = 已读章节数 * 平均每章页数 + 当前页 + final estimatedAbsolutePage = + currentSpineItemIndex * avgPages + currentPageInChapter + 1; + + final pageStr = totalChapters > 0 + ? '$estimatedAbsolutePage/$estimatedTotalPages' + : '${currentPageInChapter + 1}/$totalPagesInChapter'; + + if (displayProgress != pageStr) { setState(() { - displayProgress = pageInChapterStr; + displayProgress = pageStr; }); } }); diff --git a/lib/pages/epub_reader/reader_screen.dart b/lib/pages/epub_reader/reader_screen.dart index f028244..5d60337 100644 --- a/lib/pages/epub_reader/reader_screen.dart +++ b/lib/pages/epub_reader/reader_screen.dart @@ -18,6 +18,7 @@ import 'control_panel.dart'; import 'toc_drawer.dart'; import 'image_viewer.dart'; import 'footnote_popup.dart'; +import 'search_sheet.dart'; part 'mixins/spine_navigation_mixin.dart'; part 'mixins/page_navigation_mixin.dart'; @@ -94,6 +95,9 @@ class _ReaderScreenState extends State String displayProgress = ''; @override Timer? progressDebouncer; + final Map _chapterPageCounts = {}; + @override + Map get chapterPageCounts => _chapterPageCounts; // Theme state (used by _ThemeMixin) @override @@ -322,18 +326,21 @@ class _ReaderScreenState extends State if (mounted) ToastUtil.show(context, '已移除书签'); } else { // 添加书签 - final chapterTitle = bookSession.spine.isNotEmpty && - currentSpineItemIndex < bookSession.spine.length - ? bookSession.spine[currentSpineItemIndex].href - : ''; // 尝试从 TOC 找更友好的标题 - String title = chapterTitle; + String title = ''; for (final toc in bookSession.toc) { if (toc.spineIndex == currentSpineItemIndex) { title = toc.label; break; } } + // 如果 TOC 没有标题(或者标题是原始文件路径),用页内文字内容 + if (title.isEmpty || title.contains('.htm') || title.contains('.xhtml')) { + title = await _getPageTextPreview(); + if (title.isEmpty) { + title = '第${currentSpineItemIndex + 1}章'; + } + } await _readerDao.insertBookmark({ 'book_id': widget.bookId, @@ -348,6 +355,21 @@ class _ReaderScreenState extends State await _loadBookmarks(); } + /// 获取当前页的文字预览(前 20 个字符) + Future _getPageTextPreview() async { + try { + final result = await rendererController.webViewController?.runJavaScriptReturningResult( + "(function(){var f=document.getElementById('frame-curr');if(!f||!f.contentDocument)return '';var t=f.contentDocument.body.textContent||'';return t.trim().substring(0,20)})();" + ); + if (result != null) { + final s = result.toString(); + return (s.startsWith('"') && s.endsWith('"') && s.length >= 2 + ? s.substring(1, s.length - 1) : s).trim(); + } + } catch (_) {} + return ''; + } + void _jumpToBookmark(Map bookmark) { final cfi = bookmark['cfi'] as String? ?? ''; if (cfi.isEmpty) return; @@ -384,6 +406,23 @@ class _ReaderScreenState extends State scaffoldKey.currentState?.openDrawer(); } + void _openSearch() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => SearchSheet( + bookSession: bookSession, + streamService: _streamService, + onNavigate: (spineIndex, keyword, scrollRatio) { + Navigator.pop(ctx); + setState(() => currentSpineItemIndex = spineIndex); + loadCarousel(restoreScrollRatio: scrollRatio); + }, + ), + ); + } + @override Widget build(BuildContext context) { if (!bookSession.isLoaded) { @@ -602,6 +641,7 @@ class _ReaderScreenState extends State }, currentPageHasBookmark: _currentPageHasBookmark, onBookmarkToggle: _toggleBookmark, + onSearchTap: _openSearch, ), ], ), diff --git a/lib/pages/epub_reader/reader_webview.dart b/lib/pages/epub_reader/reader_webview.dart index 3495e69..e183265 100644 --- a/lib/pages/epub_reader/reader_webview.dart +++ b/lib/pages/epub_reader/reader_webview.dart @@ -78,6 +78,10 @@ class ReaderWebViewController { Future waitForEvents(List tokens, [int timeoutMs = 10000]) async { await _webViewState?._bridge.waitForEvents(tokens, timeoutMs); } + + Future runJavaScriptReturningResult(String js) async { + return await _webViewState?._controller?.evaluateJavascript(source: js); + } } final InAppWebViewSettings defaultSettings = InAppWebViewSettings( diff --git a/lib/pages/epub_reader/search_sheet.dart b/lib/pages/epub_reader/search_sheet.dart new file mode 100644 index 0000000..99fad0d --- /dev/null +++ b/lib/pages/epub_reader/search_sheet.dart @@ -0,0 +1,386 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:xml/xml.dart'; + +import '../../utils/epub/epub_stream_service.dart'; +import 'book_session.dart'; + +class SearchSheet extends StatefulWidget { + final BookSession bookSession; + final EpubStreamService streamService; + final void Function(int spineIndex, String keyword, double scrollRatio) onNavigate; + + const SearchSheet({ + super.key, + required this.bookSession, + required this.streamService, + required this.onNavigate, + }); + + @override + State createState() => _SearchSheetState(); +} + +class _SearchSheetState extends State { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + List<_SearchResult> _allResults = []; + List<_SearchResult> _displayResults = []; + bool _searching = false; + bool _searched = false; + bool _loadingMore = false; + String _currentQuery = ''; + static const int _pageSize = 20; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 50 && + !_loadingMore && + _displayResults.length < _allResults.length) { + _loadMore(); + } + } + + void _loadMore() { + if (_loadingMore) return; + setState(() => _loadingMore = true); + + Future.delayed(const Duration(milliseconds: 100), () { + if (!mounted) return; + final nextBatch = _allResults + .skip(_displayResults.length) + .take(_pageSize) + .toList(); + setState(() { + _displayResults.addAll(nextBatch); + _loadingMore = false; + }); + }); + } + + Future _performSearch(String query) async { + if (query.trim().isEmpty) return; + // 收起键盘 + FocusScope.of(context).unfocus(); + setState(() { + _searching = true; + _searched = true; + _currentQuery = query; + }); + + final results = <_SearchResult>[]; + final spine = widget.bookSession.spine; + + for (int i = 0; i < spine.length; i++) { + final href = spine[i].href; + try { + final bytes = await widget.streamService.readFileFromEpub( + targetFilePath: href, + ); + if (bytes == null) continue; + + final text = _extractBodyText(bytes); + if (text.isEmpty) continue; + + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + + int startIndex = 0; + while (true) { + final idx = lowerText.indexOf(lowerQuery, startIndex); + if (idx == -1) break; + + final start = (idx - 40).clamp(0, text.length); + final end = (idx + query.length + 40).clamp(0, text.length); + final contextStr = text.substring(start, end); + + results.add(_SearchResult( + chapterIndex: i, + chapterTitle: _getChapterTitle(i), + context: contextStr, + matchIndex: idx, + scrollRatio: idx / text.length, + )); + + startIndex = idx + query.length; + } + } catch (_) {} + } + + if (mounted) { + setState(() { + _allResults = results; + _displayResults = results.take(_pageSize).toList(); + _searching = false; + }); + } + } + + String _extractBodyText(List bytes) { + try { + final content = utf8.decode(bytes, allowMalformed: true); + final doc = XmlDocument.parse(content); + // 只提取 内容 + final body = doc.findAllElements('body').firstOrNull; + if (body == null) return ''; + final buffer = StringBuffer(); + _extractTextFromBody(body, buffer); + return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + } catch (_) { + return ''; + } + } + + void _extractTextFromBody(XmlNode node, StringBuffer buffer) { + // 跳过 script、style、svg 等非内容标签 + if (node is XmlElement) { + final name = node.name.local.toLowerCase(); + if (name == 'script' || name == 'style' || name == 'svg' || + name == 'head' || name == 'nav') { + return; + } + } + if (node is XmlText) { + buffer.write(node.value); + } + for (final child in node.children) { + _extractTextFromBody(child, buffer); + } + } + + String _getChapterTitle(int index) { + final toc = widget.bookSession.toc; + for (final entry in toc) { + if (entry.spineIndex == index) return entry.label; + } + return '第${index + 1}章'; + } + + List _buildHighlightedText(String text, String query, ColorScheme colors) { + if (query.isEmpty) { + return [TextSpan(text: text, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7)))]; + } + + final spans = []; + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + int lastEnd = 0; + + int idx = lowerText.indexOf(lowerQuery); + while (idx != -1) { + if (idx > lastEnd) { + spans.add(TextSpan( + text: text.substring(lastEnd, idx), + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7)), + )); + } + spans.add(TextSpan( + text: text.substring(idx, idx + query.length), + style: TextStyle( + fontSize: 13, + color: colors.error, + fontWeight: FontWeight.w600, + ), + )); + lastEnd = idx + query.length; + idx = lowerText.indexOf(lowerQuery, lastEnd); + } + + if (lastEnd < text.length) { + spans.add(TextSpan( + text: text.substring(lastEnd), + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7)), + )); + } + + return spans; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + // 拖拽条 + Container( + width: 36, height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2), + ), + ), + // 搜索框 + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + autofocus: true, + textInputAction: TextInputAction.search, + onSubmitted: _performSearch, + 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)), + filled: true, + fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + style: TextStyle(fontSize: 14, color: colors.onSurface), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _performSearch(_controller.text), + child: Text('搜索', + style: TextStyle( + fontSize: 14, + color: colors.primary, + fontWeight: FontWeight.w500)), + ), + ], + ), + ), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + // 结果统计 + if (_searched && !_searching && _allResults.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text('共找到 ${_allResults.length} 条结果', + style: TextStyle(fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + ), + ), + // 结果列表 + Expanded( + child: _searching + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(strokeWidth: 2, color: colors.primary), + const SizedBox(height: 12), + Text('搜索中...', + style: TextStyle(fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.5))), + ], + ), + ) + : !_searched + ? Center( + child: Text('输入关键词搜索书籍内容', + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.35)))) + : _allResults.isEmpty + ? Center( + child: Text('未找到相关内容', + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.35)))) + : ListView.separated( + controller: _scrollController, + padding: EdgeInsets.only(bottom: bottomPadding + 16), + itemCount: _displayResults.length + (_loadingMore ? 1 : 0), + separatorBuilder: (_, __) => + Divider(height: 0.5, indent: 16, endIndent: 16, + color: colors.outline), + itemBuilder: (context, index) { + if (index >= _displayResults.length) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ), + ); + } + final r = _displayResults[index]; + return ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16), + title: Text(r.chapterTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colors.primary, + fontWeight: FontWeight.w500)), + subtitle: RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + children: _buildHighlightedText( + '...${r.context}...', _currentQuery, colors), + ), + ), + onTap: () => widget.onNavigate( + r.chapterIndex, _currentQuery, r.scrollRatio), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _SearchResult { + final int chapterIndex; + final String chapterTitle; + final String context; + final int matchIndex; + final double scrollRatio; // 匹配位置在章节中的比例 (0.0~1.0) + + _SearchResult({ + required this.chapterIndex, + required this.chapterTitle, + required this.context, + required this.matchIndex, + required this.scrollRatio, + }); +} diff --git a/lib/utils/epub/reader_dao.dart b/lib/utils/epub/reader_dao.dart index f11e42b..ad102e7 100644 --- a/lib/utils/epub/reader_dao.dart +++ b/lib/utils/epub/reader_dao.dart @@ -134,6 +134,17 @@ class ReaderDao { return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]); } + /// 更新批注感悟 + Future updateAnnotationNote(int id, String note) async { + final db = await _db.database; + return db.update( + 'book_annotations', + {'reader_note': note, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + } + // ─── bookmarks ────────────────────────────────────────────────── /// 获取某本书的所有书签