generated from dellevin/template
epub阅读添加搜索
This commit is contained in:
@@ -40,6 +40,7 @@ class ControlPanel extends StatefulWidget {
|
|||||||
final void Function(int bgColor, int textColor) onCustomColorChanged;
|
final void Function(int bgColor, int textColor) onCustomColorChanged;
|
||||||
final bool currentPageHasBookmark;
|
final bool currentPageHasBookmark;
|
||||||
final VoidCallback onBookmarkToggle;
|
final VoidCallback onBookmarkToggle;
|
||||||
|
final VoidCallback onSearchTap;
|
||||||
|
|
||||||
const ControlPanel({
|
const ControlPanel({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -78,6 +79,7 @@ class ControlPanel extends StatefulWidget {
|
|||||||
required this.onCustomColorChanged,
|
required this.onCustomColorChanged,
|
||||||
required this.currentPageHasBookmark,
|
required this.currentPageHasBookmark,
|
||||||
required this.onBookmarkToggle,
|
required this.onBookmarkToggle,
|
||||||
|
required this.onSearchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isVertical => direction == 1;
|
bool get isVertical => direction == 1;
|
||||||
@@ -313,6 +315,10 @@ class _ControlPanelState extends State<ControlPanel> {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: widget.onSearchTap,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
widget.currentPageHasBookmark
|
widget.currentPageHasBookmark
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../utils/epub/reader_dao.dart';
|
|||||||
import '../../utils/epub/epub_parser.dart';
|
import '../../utils/epub/epub_parser.dart';
|
||||||
import '../../utils/epub/reader_models.dart';
|
import '../../utils/epub/reader_models.dart';
|
||||||
import '../../utils/book/book_dao.dart';
|
import '../../utils/book/book_dao.dart';
|
||||||
|
import '../../utils/book/book_excerpt_dao.dart';
|
||||||
import '../../models/data_models.dart';
|
import '../../models/data_models.dart';
|
||||||
import '../book/book_detail_page.dart';
|
import '../book/book_detail_page.dart';
|
||||||
import 'book_link_page.dart';
|
import 'book_link_page.dart';
|
||||||
@@ -35,12 +36,17 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
|
|||||||
late Map<String, dynamic> _book;
|
late Map<String, dynamic> _book;
|
||||||
EpubBookInfo? _bookInfo;
|
EpubBookInfo? _bookInfo;
|
||||||
bool _descriptionExpanded = false;
|
bool _descriptionExpanded = false;
|
||||||
|
Future<List<BookExcerpt>>? _excerptsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_book = widget.book;
|
_book = widget.book;
|
||||||
_loadBookInfo();
|
_loadBookInfo();
|
||||||
|
final linkedBookId = _book['book_id'] as String? ?? '';
|
||||||
|
if (linkedBookId.isNotEmpty) {
|
||||||
|
_excerptsFuture = BookExcerptDao().getExcerptsByBookId(linkedBookId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadBookInfo() async {
|
Future<void> _loadBookInfo() async {
|
||||||
@@ -196,6 +202,16 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
|
|||||||
child: _buildLinkedBookCard(colors),
|
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) ...[
|
if (author.isNotEmpty) ...[
|
||||||
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
|
Divider(height: 0.5, thickness: 0.5, color: colors.outline),
|
||||||
@@ -343,6 +359,94 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildExcerptsList(ColorScheme colors) {
|
||||||
|
if (_excerptsFuture == null) return const SizedBox.shrink();
|
||||||
|
return FutureBuilder<List<BookExcerpt>>(
|
||||||
|
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) {
|
void _showLinkedBookActions(ColorScheme colors, String title) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -18,18 +18,31 @@ mixin _ProgressMixin on State<ReaderScreen> {
|
|||||||
Timer? get progressDebouncer;
|
Timer? get progressDebouncer;
|
||||||
set progressDebouncer(Timer? v);
|
set progressDebouncer(Timer? v);
|
||||||
|
|
||||||
|
Map<int, int> get chapterPageCounts;
|
||||||
|
|
||||||
void updateProgressDebounced() {
|
void updateProgressDebounced() {
|
||||||
progressDebouncer?.cancel();
|
progressDebouncer?.cancel();
|
||||||
progressDebouncer = Timer(const Duration(milliseconds: 150), () {
|
progressDebouncer = Timer(const Duration(milliseconds: 150), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (isWebViewLoading) return;
|
if (isWebViewLoading) return;
|
||||||
|
|
||||||
final pageInChapterStr =
|
final totalChapters = bookSession.spine.length;
|
||||||
'${currentPageInChapter + 1}/$totalPagesInChapter';
|
|
||||||
|
|
||||||
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(() {
|
setState(() {
|
||||||
displayProgress = pageInChapterStr;
|
displayProgress = pageStr;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'control_panel.dart';
|
|||||||
import 'toc_drawer.dart';
|
import 'toc_drawer.dart';
|
||||||
import 'image_viewer.dart';
|
import 'image_viewer.dart';
|
||||||
import 'footnote_popup.dart';
|
import 'footnote_popup.dart';
|
||||||
|
import 'search_sheet.dart';
|
||||||
|
|
||||||
part 'mixins/spine_navigation_mixin.dart';
|
part 'mixins/spine_navigation_mixin.dart';
|
||||||
part 'mixins/page_navigation_mixin.dart';
|
part 'mixins/page_navigation_mixin.dart';
|
||||||
@@ -94,6 +95,9 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
String displayProgress = '';
|
String displayProgress = '';
|
||||||
@override
|
@override
|
||||||
Timer? progressDebouncer;
|
Timer? progressDebouncer;
|
||||||
|
final Map<int, int> _chapterPageCounts = {};
|
||||||
|
@override
|
||||||
|
Map<int, int> get chapterPageCounts => _chapterPageCounts;
|
||||||
|
|
||||||
// Theme state (used by _ThemeMixin)
|
// Theme state (used by _ThemeMixin)
|
||||||
@override
|
@override
|
||||||
@@ -322,18 +326,21 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
if (mounted) ToastUtil.show(context, '已移除书签');
|
if (mounted) ToastUtil.show(context, '已移除书签');
|
||||||
} else {
|
} else {
|
||||||
// 添加书签
|
// 添加书签
|
||||||
final chapterTitle = bookSession.spine.isNotEmpty &&
|
|
||||||
currentSpineItemIndex < bookSession.spine.length
|
|
||||||
? bookSession.spine[currentSpineItemIndex].href
|
|
||||||
: '';
|
|
||||||
// 尝试从 TOC 找更友好的标题
|
// 尝试从 TOC 找更友好的标题
|
||||||
String title = chapterTitle;
|
String title = '';
|
||||||
for (final toc in bookSession.toc) {
|
for (final toc in bookSession.toc) {
|
||||||
if (toc.spineIndex == currentSpineItemIndex) {
|
if (toc.spineIndex == currentSpineItemIndex) {
|
||||||
title = toc.label;
|
title = toc.label;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 如果 TOC 没有标题(或者标题是原始文件路径),用页内文字内容
|
||||||
|
if (title.isEmpty || title.contains('.htm') || title.contains('.xhtml')) {
|
||||||
|
title = await _getPageTextPreview();
|
||||||
|
if (title.isEmpty) {
|
||||||
|
title = '第${currentSpineItemIndex + 1}章';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _readerDao.insertBookmark({
|
await _readerDao.insertBookmark({
|
||||||
'book_id': widget.bookId,
|
'book_id': widget.bookId,
|
||||||
@@ -348,6 +355,21 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
await _loadBookmarks();
|
await _loadBookmarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取当前页的文字预览(前 20 个字符)
|
||||||
|
Future<String> _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<String, dynamic> bookmark) {
|
void _jumpToBookmark(Map<String, dynamic> bookmark) {
|
||||||
final cfi = bookmark['cfi'] as String? ?? '';
|
final cfi = bookmark['cfi'] as String? ?? '';
|
||||||
if (cfi.isEmpty) return;
|
if (cfi.isEmpty) return;
|
||||||
@@ -384,6 +406,23 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
scaffoldKey.currentState?.openDrawer();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!bookSession.isLoaded) {
|
if (!bookSession.isLoaded) {
|
||||||
@@ -602,6 +641,7 @@ class _ReaderScreenState extends State<ReaderScreen>
|
|||||||
},
|
},
|
||||||
currentPageHasBookmark: _currentPageHasBookmark,
|
currentPageHasBookmark: _currentPageHasBookmark,
|
||||||
onBookmarkToggle: _toggleBookmark,
|
onBookmarkToggle: _toggleBookmark,
|
||||||
|
onSearchTap: _openSearch,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ class ReaderWebViewController {
|
|||||||
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
|
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
|
||||||
await _webViewState?._bridge.waitForEvents(tokens, timeoutMs);
|
await _webViewState?._bridge.waitForEvents(tokens, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<dynamic> runJavaScriptReturningResult(String js) async {
|
||||||
|
return await _webViewState?._controller?.evaluateJavascript(source: js);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
|
final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
|
||||||
|
|||||||
386
lib/pages/epub_reader/search_sheet.dart
Normal file
386
lib/pages/epub_reader/search_sheet.dart
Normal file
@@ -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<SearchSheet> createState() => _SearchSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SearchSheetState extends State<SearchSheet> {
|
||||||
|
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<void> _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<int> bytes) {
|
||||||
|
try {
|
||||||
|
final content = utf8.decode(bytes, allowMalformed: true);
|
||||||
|
final doc = XmlDocument.parse(content);
|
||||||
|
// 只提取 <body> 内容
|
||||||
|
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<TextSpan> _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 = <TextSpan>[];
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -134,6 +134,17 @@ class ReaderDao {
|
|||||||
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
|
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新批注感悟
|
||||||
|
Future<int> 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 ──────────────────────────────────────────────────
|
// ─── bookmarks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// 获取某本书的所有书签
|
/// 获取某本书的所有书签
|
||||||
|
|||||||
Reference in New Issue
Block a user