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 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<ControlPanel> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: widget.onSearchTap,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.currentPageHasBookmark
|
||||
|
||||
@@ -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<EpubDetailPage> {
|
||||
late Map<String, dynamic> _book;
|
||||
EpubBookInfo? _bookInfo;
|
||||
bool _descriptionExpanded = false;
|
||||
Future<List<BookExcerpt>>? _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<void> _loadBookInfo() async {
|
||||
@@ -196,6 +202,16 @@ class _EpubDetailPageState extends State<EpubDetailPage> {
|
||||
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<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) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -18,18 +18,31 @@ mixin _ProgressMixin on State<ReaderScreen> {
|
||||
Timer? get progressDebouncer;
|
||||
set progressDebouncer(Timer? v);
|
||||
|
||||
Map<int, int> 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<ReaderScreen>
|
||||
String displayProgress = '';
|
||||
@override
|
||||
Timer? progressDebouncer;
|
||||
final Map<int, int> _chapterPageCounts = {};
|
||||
@override
|
||||
Map<int, int> get chapterPageCounts => _chapterPageCounts;
|
||||
|
||||
// Theme state (used by _ThemeMixin)
|
||||
@override
|
||||
@@ -322,18 +326,21 @@ class _ReaderScreenState extends State<ReaderScreen>
|
||||
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<ReaderScreen>
|
||||
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) {
|
||||
final cfi = bookmark['cfi'] as String? ?? '';
|
||||
if (cfi.isEmpty) return;
|
||||
@@ -384,6 +406,23 @@ class _ReaderScreenState extends State<ReaderScreen>
|
||||
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<ReaderScreen>
|
||||
},
|
||||
currentPageHasBookmark: _currentPageHasBookmark,
|
||||
onBookmarkToggle: _toggleBookmark,
|
||||
onSearchTap: _openSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -78,6 +78,10 @@ class ReaderWebViewController {
|
||||
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
|
||||
await _webViewState?._bridge.waitForEvents(tokens, timeoutMs);
|
||||
}
|
||||
|
||||
Future<dynamic> runJavaScriptReturningResult(String js) async {
|
||||
return await _webViewState?._controller?.evaluateJavascript(source: js);
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
/// 更新批注感悟
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
/// 获取某本书的所有书签
|
||||
|
||||
Reference in New Issue
Block a user