基础epub阅读功能

This commit is contained in:
DelLevin-Home
2026-06-27 11:20:27 +08:00
parent 02242089e8
commit 5acbc3a602
18 changed files with 980 additions and 330 deletions

View File

@@ -0,0 +1,92 @@
/// 书籍批注数据模型 — 高亮、下划线、书签
class BookAnnotation {
final int? id;
final String bookId;
final String content; // 选中的文字内容
final String cfi; // EPUB CFI 位置
final String chapter; // 章节标题
final String type; // 'highlight' | 'underline' | 'bookmark'
final String color; // 颜色 hex如 'FFEB3B'
final String? readerNote; // 用户附注
final DateTime createdAt;
final DateTime updatedAt;
BookAnnotation({
this.id,
required this.bookId,
required this.content,
required this.cfi,
this.chapter = '',
required this.type,
required this.color,
this.readerNote,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'book_id': bookId,
'content': content,
'cfi': cfi,
'chapter': chapter,
'type': type,
'color': color,
'reader_note': readerNote ?? '',
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}
factory BookAnnotation.fromMap(Map<String, dynamic> map) {
return BookAnnotation(
id: map['id'] as int?,
bookId: map['book_id'] as String,
content: map['content'] as String? ?? '',
cfi: map['cfi'] as String? ?? '',
chapter: map['chapter'] as String? ?? '',
type: map['type'] as String? ?? 'highlight',
color: map['color'] as String? ?? 'FFEB3B',
readerNote: map['reader_note'] as String?,
createdAt: DateTime.tryParse(map['created_at'] as String? ?? '') ?? DateTime.now(),
updatedAt: DateTime.tryParse(map['updated_at'] as String? ?? '') ?? DateTime.now(),
);
}
/// 用于 JS bridge 的 JSON传给 foliate-js renderAnnotations
Map<String, dynamic> toJson() {
return {
'id': id ?? 0,
'type': type,
'value': cfi,
'color': '#$color',
'note': content,
};
}
BookAnnotation copyWith({
int? id,
String? bookId,
String? content,
String? cfi,
String? chapter,
String? type,
String? color,
String? readerNote,
}) {
return BookAnnotation(
id: id ?? this.id,
bookId: bookId ?? this.bookId,
content: content ?? this.content,
cfi: cfi ?? this.cfi,
chapter: chapter ?? this.chapter,
type: type ?? this.type,
color: color ?? this.color,
readerNote: readerNote ?? this.readerNote,
createdAt: createdAt,
updatedAt: DateTime.now(),
);
}
}

View File

@@ -1,32 +1,67 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:battery_plus/battery_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../models/reader_book.dart';
import '../../providers/app_provider.dart';
import '../../service/book_server.dart';
import '../../utils/reader/book_file_helper.dart';
import '../../utils/reader/coordinates_to_part.dart';
import '../../utils/reader/reader_url_generator.dart';
/// 目录条目
class TocItem {
final String href;
final String title;
final int level;
TocItem({required this.href, required this.title});
TocItem({required this.href, required this.title, this.level = 1});
factory TocItem.fromJson(Map<String, dynamic> json) {
return TocItem(
href: json['href'] ?? '',
title: json['title'] ?? '',
title: json['label'] ?? json['title'] ?? '',
level: json['level'] as int? ?? 1,
);
}
}
/// 递归展平嵌套目录
List<TocItem> _flattenToc(List<dynamic> items, [int level = 1]) {
final result = <TocItem>[];
for (final item in items) {
if (item is Map) {
final map = Map<String, dynamic>.from(item);
result.add(TocItem(
href: map['href'] ?? '',
title: map['label'] ?? map['title'] ?? '',
level: level,
));
if (map['subitems'] is List && (map['subitems'] as List).isNotEmpty) {
result.addAll(_flattenToc(map['subitems'] as List, level + 1));
}
}
}
return result;
}
/// 翻页区域动作
enum PageTurnAction { prev, next, menu, none }
/// 默认九宫格布局(中间菜单,左右翻页)
const List<PageTurnAction> _defaultZoneActions = [
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
];
/// 核心电子书阅读组件 — 使用 InAppWebView 渲染 foliate-js 阅读器
class EpubPlayer extends StatefulWidget {
final ReaderBook book;
@@ -51,14 +86,24 @@ class EpubPlayerState extends State<EpubPlayer> {
String cfi = '';
double percentage = 0.0;
String chapterTitle = '';
String chapterHref = '';
int chapterCurrentPage = 0;
int chapterTotalPages = 0;
Timer? _styleTimer;
// 浏览历史
bool _showHistory = false;
bool _canGoBack = false;
bool _canGoForward = false;
// 滚轮翻页
Timer? _scrollDebounceTimer;
double _accumulatedScrollDelta = 0;
static const double _scrollThreshold = 50.0;
InAppWebViewSettings get _settings => InAppWebViewSettings(
supportZoom: false,
transparentBackground: true,
isInspectable: kDebugMode,
useHybridComposition: true,
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
);
@@ -93,40 +138,14 @@ class EpubPlayerState extends State<EpubPlayer> {
_controller.evaluateJavascript(source: "goToCfi('$cfi')");
}
// ─── 样式方法 ───────────────────────────────────────
// ─── 历史导航 ───────────────────────────────────────
void changeStyle({
double? fontSize,
double? lineHeight,
double? paragraphSpacing,
String? fontColor,
String? backgroundColor,
}) {
_styleTimer?.cancel();
_styleTimer = Timer(const Duration(milliseconds: 200), () {
if (!mounted) return;
final params = <String, dynamic>{};
if (fontSize != null) params['fontSize'] = (fontSize * 100).round();
if (lineHeight != null) params['spacing'] = lineHeight;
if (paragraphSpacing != null) params['paragraphSpacing'] = paragraphSpacing;
if (fontColor != null) params['fontColor'] = '#$fontColor';
if (backgroundColor != null) params['backgroundColor'] = '#$backgroundColor';
if (params.isEmpty) return;
final jsonParams = jsonEncode(params);
_controller.evaluateJavascript(source: 'changeStyle($jsonParams)');
});
void backHistory() {
_controller.evaluateJavascript(source: 'back()');
}
void changeTheme(String bgColor, String textColor) {
_controller.evaluateJavascript(source: '''
changeStyle({
backgroundColor: '#$bgColor',
fontColor: '#$textColor',
})
''');
void forwardHistory() {
_controller.evaluateJavascript(source: 'forward()');
}
// ─── 保存进度 ───────────────────────────────────────
@@ -142,71 +161,168 @@ class EpubPlayerState extends State<EpubPlayer> {
await provider.updateReaderBook(updated);
}
// ─── 翻页区域处理 ──────────────────────────────────
void _onClick(Map<String, dynamic> location) {
final x = (location['x'] as num).toDouble();
final y = (location['y'] as num).toDouble();
final part = coordinatesToPart(x, y);
final action = _defaultZoneActions[part];
switch (action) {
case PageTurnAction.prev:
prevPage();
break;
case PageTurnAction.next:
nextPage();
break;
case PageTurnAction.menu:
widget.showOrHideToolbar();
break;
case PageTurnAction.none:
break;
}
}
// ─── 滚轮翻页 ──────────────────────────────────────
Future<void> _handlePointerEvents(PointerEvent event) async {
if (event is! PointerScrollEvent) return;
_accumulatedScrollDelta += event.scrollDelta.dy;
_scrollDebounceTimer?.cancel();
_scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
if (_accumulatedScrollDelta.abs() >= _scrollThreshold) {
if (_accumulatedScrollDelta > 0) {
nextPage();
} else {
prevPage();
}
}
_accumulatedScrollDelta = 0;
});
}
// ─── 外部链接处理 ──────────────────────────────────
Future<void> _handleExternalLink(dynamic rawLink) async {
String? link;
if (rawLink is String && rawLink.trim().isNotEmpty) {
link = rawLink.trim();
} else if (rawLink is Map && rawLink['href'] is String) {
link = (rawLink['href'] as String).trim();
}
if (!mounted || link == null || link.isEmpty) return;
final uri = Uri.tryParse(link);
if (uri == null || uri.scheme.isEmpty || uri.scheme == 'javascript') return;
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('打开外部链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('是否在浏览器中打开以下链接?'),
const SizedBox(height: 8),
SelectableText(link!, style: const TextStyle(fontSize: 13)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('打开')),
],
),
);
if (shouldOpen == true) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
// ─── WebView 回调 ───────────────────────────────────
Future<void> _onWebViewCreated(InAppWebViewController controller) async {
if (Platform.isAndroid) {
await InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
_controller = controller;
_setHandlers(controller);
// 开启常亮
WakelockPlus.enable();
}
void _setHandlers(InAppWebViewController controller) {
// 阅读位置变化
controller.addJavaScriptHandler(
handlerName: 'onLoadEnd',
callback: (args) {},
);
controller.addJavaScriptHandler(
handlerName: 'onRelocated',
callback: (args) {
final location = args[0] as Map<String, dynamic>;
if (cfi == location['cfi']) return;
setState(() {
cfi = location['cfi'] ?? '';
percentage = double.tryParse(location['percentage']?.toString() ?? '0') ?? 0.0;
chapterTitle = location['chapterTitle'] ?? '';
chapterHref = location['chapterHref'] ?? '';
chapterCurrentPage = location['chapterCurrentPage'] ?? 0;
chapterTotalPages = location['chapterTotalPages'] ?? 0;
});
saveReadingProgress();
},
);
// 点击事件(控制翻页和工具栏)
controller.addJavaScriptHandler(
handlerName: 'onClick',
callback: (args) {
final location = args[0] as Map<String, dynamic>;
final x = location['x'] as num?;
final y = location['y'] as num?;
if (x == null || y == null) return;
final pageWidth = MediaQuery.of(context).size.width;
final clickX = x.toDouble() * pageWidth;
final oneThird = pageWidth / 3;
final twoThird = pageWidth * 2 / 3;
if (clickX < oneThird) {
prevPage();
} else if (clickX > twoThird) {
nextPage();
} else {
widget.showOrHideToolbar();
}
_onClick(location);
},
);
controller.addJavaScriptHandler(
handlerName: 'onExternalLink',
callback: (args) async {
await _handleExternalLink(args.isNotEmpty ? args.first : null);
},
);
// 目录数据
controller.addJavaScriptHandler(
handlerName: 'onSetToc',
callback: (args) {
final List<dynamic> rawToc = args[0];
final toc = rawToc.map((item) {
if (item is Map) {
return TocItem.fromJson(Map<String, dynamic>.from(item));
}
return TocItem(href: '', title: item.toString());
}).toList();
final toc = _flattenToc(rawToc);
widget.onTocReady?.call(toc);
},
);
// 翻页上拉手势
// 以下 handler 保留注册以避免 JS 端报错,但不做处理
controller.addJavaScriptHandler(handlerName: 'onSelectionEnd', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onSelectionCleared', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onAnnotationClick', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onSearch', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'renderAnnotations', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onImageClick', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onFootnoteClose', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'handleBookmark', callback: (args) {});
controller.addJavaScriptHandler(
handlerName: 'onPushState',
callback: (args) {
final state = args[0] as Map<String, dynamic>;
if (!mounted) return;
setState(() {
_canGoBack = state['canGoBack'] ?? false;
_canGoForward = state['canGoForward'] ?? false;
_showHistory = _canGoBack || _canGoForward;
});
},
);
controller.addJavaScriptHandler(
handlerName: 'onPullUp',
callback: (args) {
@@ -217,45 +333,169 @@ class EpubPlayerState extends State<EpubPlayer> {
@override
void dispose() {
_styleTimer?.cancel();
_scrollDebounceTimer?.cancel();
saveReadingProgress();
WakelockPlus.disable();
super.dispose();
}
// ─── 阅读信息浮层 ──────────────────────────────────
Widget _buildReadingInfo() {
if (chapterCurrentPage == 0 && percentage == 0.0) {
return const SizedBox.shrink();
}
final isDark = Theme.of(context).brightness == Brightness.dark;
final infoColor = isDark ? Colors.white.withAlpha(130) : Colors.black.withAlpha(130);
final infoStyle = TextStyle(color: infoColor, fontSize: 10);
return IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 50, left: 16, right: 16),
child: Text(
chapterTitle.isNotEmpty ? chapterTitle : widget.book.title,
style: infoStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$chapterCurrentPage/$chapterTotalPages', style: infoStyle),
Text('${(percentage * 100).toStringAsFixed(1)}%', style: infoStyle),
_buildBatteryWidget(infoColor),
_buildClockWidget(infoStyle),
],
),
),
],
),
);
}
Widget _buildBatteryWidget(Color color) {
return FutureBuilder<int>(
future: Battery().batteryLevel,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.battery_std, size: 12, color: color),
const SizedBox(width: 2),
Text('${snapshot.data}%', style: TextStyle(color: color, fontSize: 10)),
],
);
},
);
}
Widget _buildClockWidget(TextStyle style) {
return StreamBuilder<DateTime>(
stream: Stream.periodic(const Duration(seconds: 30), (_) => DateTime.now()),
builder: (context, snapshot) {
final now = snapshot.data ?? DateTime.now();
return Text(
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}',
style: style,
);
},
);
}
// ─── 历史导航胶囊 ──────────────────────────────────
Widget _buildHistoryCapsule() {
final colors = Theme.of(context).colorScheme;
final buttonStyle = TextButton.styleFrom(
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)),
);
Widget btn(IconData icon, String label, VoidCallback onPressed) {
return TextButton.icon(
icon: Icon(icon, size: 18, color: colors.onSurface.withAlpha(180)),
label: Text(label, style: TextStyle(color: colors.onSurface.withAlpha(180), fontSize: 14)),
onPressed: onPressed,
style: buttonStyle,
);
}
final buttons = <Widget>[];
if (_canGoBack) buttons.add(btn(Icons.arrow_back, '返回', backHistory));
buttons.add(btn(Icons.close, '关闭', () => setState(() => _showHistory = false)));
if (_canGoForward) buttons.add(btn(Icons.arrow_forward, '前进', forwardHistory));
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
height: 32,
decoration: BoxDecoration(
color: colors.surfaceContainer.withAlpha(123),
borderRadius: BorderRadius.circular(32),
border: Border.all(color: colors.outline, width: 0.5),
),
child: Row(mainAxisSize: MainAxisSize.min, children: buttons),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final fileAbsolute = BookFileHelper.instance.resolveAbsolutePath(widget.book.filePath);
final fileExists = fileAbsolute.isNotEmpty && File(fileAbsolute).existsSync();
final bookUrl = 'http://127.0.0.1:${Server().port}/book/${Uri.encodeComponent(fileAbsolute)}';
final initialCfi = widget.initialCfi ?? widget.book.lastReadCfi;
final bgColor = isDark ? 'FF1A1A1A' : 'FFFFFFFF';
final textColor = isDark ? 'FFE5E5E5' : 'FF1A1A1A';
final url = generateReaderUrl(
fileUrl: bookUrl,
cfi: initialCfi,
backgroundColor: bgColor,
textColor: textColor,
isDarkMode: isDark,
backgroundColor: 'FFFBFBF3',
textColor: 'FF343434',
);
debugPrint('[EpubPlayer] port=${Server().port} running=${Server().isRunning}');
debugPrint('[EpubPlayer] fileAbsolute=$fileAbsolute exists=$fileExists');
return InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: _settings,
onWebViewCreated: _onWebViewCreated,
onReceivedError: (controller, request, error) {
debugPrint('[EpubPlayer] WebView error: ${error.description}');
},
onConsoleMessage: (controller, msg) {
debugPrint('[EpubPlayer] JS: ${msg.message}');
},
return Listener(
onPointerSignal: _handlePointerEvents,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
SizedBox.expand(
child: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: _settings,
onWebViewCreated: _onWebViewCreated,
onReceivedError: (controller, request, error) {
debugPrint('[EpubPlayer] WebView error: ${error.description}');
},
onConsoleMessage: (controller, msg) {
debugPrint('[EpubPlayer] JS: ${msg.message}');
},
),
),
_buildReadingInfo(),
if (_showHistory) _buildHistoryCapsule(),
],
),
),
);
}
}

View File

@@ -4,10 +4,10 @@ import 'package:pointer_interceptor/pointer_interceptor.dart';
import '../../models/reader_book.dart';
import '../../service/book_server.dart';
import '../../utils/reader/book_file_helper.dart';
import '../../widgets/reader/reading_notes_panel.dart';
import 'epub_player.dart';
import 'toc_drawer.dart';
import 'progress_panel.dart';
import 'style_panel.dart';
/// 书籍阅读页面
class ReadingPage extends StatefulWidget {
@@ -22,12 +22,12 @@ class ReadingPage extends StatefulWidget {
class _ReadingPageState extends State<ReadingPage> {
final GlobalKey<EpubPlayerState> _epubPlayerKey = GlobalKey<EpubPlayerState>();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final FocusNode _readerFocusNode = FocusNode();
static const _empty = SizedBox.shrink();
bool _toolbarOffstage = true; // true=隐藏, false=显示
bool _toolbarOffstage = true;
Widget _currentPage = const SizedBox.shrink();
bool _serverReady = false;
List<TocItem> _toc = [];
@override
@@ -35,6 +35,9 @@ class _ReadingPageState extends State<ReadingPage> {
super.initState();
_initServer();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
WidgetsBinding.instance.addPostFrameCallback((_) {
_readerFocusNode.requestFocus();
});
}
Future<void> _initServer() async {
@@ -47,15 +50,12 @@ class _ReadingPageState extends State<ReadingPage> {
void dispose() {
_epubPlayerKey.currentState?.saveReadingProgress();
Server().stop();
_readerFocusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _showToolbar() {
setState(() {
_toolbarOffstage = false;
});
}
void _showToolbar() => setState(() => _toolbarOffstage = false);
void _hideToolbar() {
setState(() {
@@ -64,13 +64,7 @@ class _ReadingPageState extends State<ReadingPage> {
});
}
void _toggleToolbar() {
if (_toolbarOffstage) {
_showToolbar();
} else {
_hideToolbar();
}
}
void _toggleToolbar() => _toolbarOffstage ? _showToolbar() : _hideToolbar();
void _onTocReady(List<TocItem> toc) {
if (mounted) setState(() => _toc = toc);
@@ -81,48 +75,62 @@ class _ReadingPageState extends State<ReadingPage> {
_scaffoldKey.currentState?.openDrawer();
}
// ─── 底部面板切换 ─────────────────────────────────────
void _onProgressPressed() {
setState(() {
_currentPage = ProgressPanel(epubPlayerKey: _epubPlayerKey);
});
void _setPanel(Widget panel) {
setState(() => _currentPage = panel);
}
void _onStylePressed() {
setState(() {
_currentPage = StylePanel(epubPlayerKey: _epubPlayerKey);
});
// ─── 键盘快捷键 ──────────────────────────────────
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.arrowRight ||
key == LogicalKeyboardKey.arrowDown ||
key == LogicalKeyboardKey.pageDown ||
key == LogicalKeyboardKey.space) {
_epubPlayerKey.currentState?.nextPage();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.pageUp) {
_epubPlayerKey.currentState?.prevPage();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter) {
_toggleToolbar();
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape && !_toolbarOffstage) {
_hideToolbar();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
// ─── 构建 ──────────────────────────────────────────
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
// ─── 工具栏覆盖层(照抄 anx-reader 的 Offstage + PointerInterceptor 模式)
Offstage controller = Offstage(
final toolbar = Offstage(
offstage: _toolbarOffstage,
child: PointerInterceptor(
child: Stack(
children: [
// 半透明背景,点击关闭工具栏
Positioned.fill(
child: GestureDetector(
onTap: _hideToolbar,
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: (details) {},
onVerticalDragEnd: (details) {},
child: Container(
color: Colors.black.withValues(alpha: 0.15),
),
child: Container(color: Colors.black.withAlpha(38)),
),
),
// 顶部 AppBar + 底部工具栏
Column(
children: [
// 顶部 AppBar
AppBar(
backgroundColor: colors.surface.withValues(alpha: 0.94),
backgroundColor: colors.surface.withAlpha(240),
title: Text(widget.book.title, overflow: TextOverflow.ellipsis),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -130,7 +138,6 @@ class _ReadingPageState extends State<ReadingPage> {
),
),
const Spacer(),
// 底部面板 + 按钮行
BottomSheet(
onClosing: () {},
enableDrag: false,
@@ -145,14 +152,10 @@ class _ReadingPageState extends State<ReadingPage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 面板内容区域
if (hasContent)
Expanded(
child: SingleChildScrollView(
child: _currentPage,
),
child: SingleChildScrollView(child: _currentPage),
),
// 按钮行
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
@@ -162,20 +165,20 @@ class _ReadingPageState extends State<ReadingPage> {
onPressed: _openTocDrawer,
),
IconButton(
icon: const Icon(Icons.data_usage),
tooltip: '进度',
icon: const Icon(Icons.edit_note),
tooltip: '笔记',
onPressed: () {
modalSetState(() {
_onProgressPressed();
_setPanel(_buildNotesPanel());
});
},
),
IconButton(
icon: const Icon(Icons.color_lens),
tooltip: '样式',
icon: const Icon(Icons.data_usage),
tooltip: '进度',
onPressed: () {
modalSetState(() {
_onStylePressed();
_setPanel(ProgressPanel(epubPlayerKey: _epubPlayerKey));
});
},
),
@@ -213,7 +216,6 @@ class _ReadingPageState extends State<ReadingPage> {
return Scaffold(
key: _scaffoldKey,
backgroundColor: colors.surface,
// TOC 目录抽屉
drawer: PointerInterceptor(
child: Drawer(
width: MediaQuery.of(context).size.width * 0.75,
@@ -224,20 +226,33 @@ class _ReadingPageState extends State<ReadingPage> {
),
),
body: _serverReady
? Stack(
children: [
// 阅读内容WebView
EpubPlayer(
key: _epubPlayerKey,
book: widget.book,
showOrHideToolbar: _toggleToolbar,
onTocReady: _onTocReady,
),
// 工具栏覆盖层
controller,
],
? Focus(
focusNode: _readerFocusNode,
onKeyEvent: _handleKeyEvent,
autofocus: true,
child: Stack(
children: [
EpubPlayer(
key: _epubPlayerKey,
book: widget.book,
showOrHideToolbar: _toggleToolbar,
onTocReady: _onTocReady,
),
toolbar,
],
),
)
: const Center(child: CircularProgressIndicator()),
);
}
Widget _buildNotesPanel() {
return ReadingNotesPanel(
bookId: widget.book.id,
onNavigate: (cfi) {
_hideToolbar();
_epubPlayerKey.currentState?.goToCfi(cfi);
},
);
}
}

View File

@@ -1,162 +0,0 @@
import 'package:flutter/material.dart';
import '../../utils/user_prefs.dart';
import 'epub_player.dart';
/// 预设主题
const _presetThemes = [
{'bg': 'FFFFFFFF', 'fg': 'FF1A1A1A', 'name': '默认'},
{'bg': 'FF1A1A1A', 'fg': 'FFE5E5E5', 'name': '暗黑'},
{'bg': 'FFF8F0E3', 'fg': 'FF333333', 'name': '护眼'},
{'bg': 'FF2B2B2B', 'fg': 'FFCCCCCC', 'name': '深灰'},
{'bg': 'FF2D3E50', 'fg': 'FFD4D4D4', 'name': '蓝灰'},
];
/// 样式设置面板 — 字号、行距、主题
class StylePanel extends StatefulWidget {
final GlobalKey<EpubPlayerState> epubPlayerKey;
const StylePanel({super.key, required this.epubPlayerKey});
@override
State<StylePanel> createState() => _StylePanelState();
}
class _StylePanelState extends State<StylePanel> {
double _fontSize = 1.0;
double _lineHeight = 1.6;
int _selectedTheme = 0;
@override
void initState() {
super.initState();
_loadPrefs();
}
void _loadPrefs() {
final sp = UserPrefs().prefs;
setState(() {
_fontSize = sp.getDouble('reader_font_size') ?? 1.0;
_lineHeight = sp.getDouble('reader_line_height') ?? 1.6;
_selectedTheme = sp.getInt('reader_theme_index') ?? 0;
});
}
void _savePrefs() {
final sp = UserPrefs().prefs;
sp.setDouble('reader_font_size', _fontSize);
sp.setDouble('reader_line_height', _lineHeight);
sp.setInt('reader_theme_index', _selectedTheme);
}
void _applyStyle() {
widget.epubPlayerKey.currentState?.changeStyle(
fontSize: _fontSize,
lineHeight: _lineHeight,
);
_savePrefs();
}
void _applyTheme(int index) {
final theme = _presetThemes[index];
final bg = theme['bg']!;
final fg = theme['fg']!;
widget.epubPlayerKey.currentState?.changeTheme(bg, fg);
setState(() => _selectedTheme = index);
_savePrefs();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 字号
Row(
children: [
Text('字号', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
Expanded(
child: Slider(
value: _fontSize,
min: 0.5,
max: 3.0,
divisions: 25,
label: '${(_fontSize * 100).round()}%',
onChanged: (value) {
setState(() => _fontSize = value);
_applyStyle();
},
),
),
SizedBox(
width: 44,
child: Text('${(_fontSize * 100).round()}%',
textAlign: TextAlign.end,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
),
],
),
// 行距
Row(
children: [
Text('行距', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
Expanded(
child: Slider(
value: _lineHeight,
min: 1.0,
max: 3.0,
divisions: 20,
label: _lineHeight.toStringAsFixed(1),
onChanged: (value) {
setState(() => _lineHeight = value);
_applyStyle();
},
),
),
SizedBox(
width: 44,
child: Text(_lineHeight.toStringAsFixed(1),
textAlign: TextAlign.end,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))),
),
],
),
const SizedBox(height: 8),
// 主题色块
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_presetThemes.length, (index) {
final theme = _presetThemes[index];
final bg = Color(int.parse(theme['bg']!, radix: 16));
final fg = Color(int.parse(theme['fg']!, radix: 16));
final isSelected = index == _selectedTheme;
return GestureDetector(
onTap: () => _applyTheme(index),
child: Container(
width: 42,
height: 42,
margin: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(21),
border: Border.all(
color: isSelected ? colors.primary : colors.outlineVariant,
width: isSelected ? 2.5 : 1,
),
),
child: Center(
child: Text('A', style: TextStyle(color: fg, fontSize: 16, fontWeight: FontWeight.w500)),
),
),
);
}),
),
],
),
);
}
}

View File

@@ -28,7 +28,7 @@ class TocDrawer extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.toc, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
Icon(Icons.toc, size: 20, color: colors.onSurfaceVariant),
const SizedBox(width: 10),
Text('目录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(),
@@ -43,25 +43,27 @@ class TocDrawer extends StatelessWidget {
Expanded(
child: toc.isEmpty
? Center(
child: Text('暂无目录', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.3))),
child: Text('暂无目录', style: TextStyle(color: colors.onSurfaceVariant)),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: toc.length,
itemBuilder: (context, index) {
final item = toc[index];
final indent = (item.level - 1) * 16.0;
return InkWell(
onTap: () {
epubPlayerKey.currentState?.goToHref(item.href);
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
padding: EdgeInsets.only(left: 20 + indent, right: 20, top: 12, bottom: 12),
child: Text(
item.title,
style: TextStyle(
fontSize: 14,
color: colors.onSurface.withValues(alpha: 0.8),
fontSize: item.level == 1 ? 14 : 13,
fontWeight: item.level == 1 ? FontWeight.w500 : FontWeight.normal,
color: colors.onSurface.withAlpha(204),
),
),
),

View File

@@ -39,7 +39,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 22,
version: 23,
onCreate: _createDB,
onUpgrade: _onUpgrade,
);
@@ -156,6 +156,10 @@ class DatabaseHelper {
// 为笔记表添加置顶字段
await _upgradeNotesTableV22(db);
}
if (oldVersion < 23) {
// 创建书籍批注表(高亮、下划线、书签)
await _createBookAnnotationsTable(db);
}
}
/// 升级books表到V11添加ISBN和出版时间字段
@@ -683,6 +687,9 @@ class DatabaseHelper {
// Note Plus 块编辑器文档表
await _createNotePlusTable(db);
// 书籍批注表
await _createBookAnnotationsTable(db);
}
/// 创建 Note Plus 文档表
@@ -703,6 +710,32 @@ class DatabaseHelper {
''');
}
/// 创建书籍批注表(高亮、下划线、书签)
Future<void> _createBookAnnotationsTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS book_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
cfi TEXT NOT NULL DEFAULT '',
chapter TEXT DEFAULT '',
type TEXT NOT NULL DEFAULT 'highlight',
color TEXT NOT NULL DEFAULT 'FFEB3B',
reader_note TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// 索引:按 book_id 查询加速
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_book_id ON book_annotations(book_id)',
);
// 索引:按 book_id + type 查询加速
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
);
}
// 关闭数据库
Future close() async {
if (_database != null) {

View File

@@ -0,0 +1,117 @@
import 'package:sqflite/sqflite.dart';
import '../../models/book_annotation.dart';
import '../database_helper.dart';
/// 书籍批注数据访问对象
class BookAnnotationDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<Database> get _db async => await _dbHelper.database;
/// 插入批注,返回插入的 id
Future<int> insert(BookAnnotation annotation) async {
final db = await _db;
return await db.insert('book_annotations', annotation.toMap()..remove('id'));
}
/// 更新批注
Future<void> update(BookAnnotation annotation) async {
final db = await _db;
await db.update(
'book_annotations',
annotation.toMap(),
where: 'id = ?',
whereArgs: [annotation.id],
);
}
/// 保存(有 id 则更新,无 id 则插入)
Future<BookAnnotation> save(BookAnnotation annotation) async {
if (annotation.id != null) {
await update(annotation);
return annotation;
}
final id = await insert(annotation);
return annotation.copyWith(id: id);
}
/// 根据 id 删除
Future<void> deleteById(int id) async {
final db = await _db;
await db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
}
/// 根据 CFI 删除(用于删除高亮/下划线)
Future<void> deleteByCfi(String bookId, String cfi) async {
final db = await _db;
await db.delete(
'book_annotations',
where: 'book_id = ? AND cfi = ?',
whereArgs: [bookId, cfi],
);
}
/// 查询某本书的所有批注
Future<List<BookAnnotation>> getByBookId(String bookId) async {
final db = await _db;
final maps = await db.query(
'book_annotations',
where: 'book_id = ?',
whereArgs: [bookId],
orderBy: 'created_at DESC',
);
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
}
/// 查询某本书的某种类型批注
Future<List<BookAnnotation>> getByBookIdAndType(String bookId, String type) async {
final db = await _db;
final maps = await db.query(
'book_annotations',
where: 'book_id = ? AND type = ?',
whereArgs: [bookId, type],
orderBy: 'created_at DESC',
);
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
}
/// 查询某本书的所有书签
Future<List<BookAnnotation>> getBookmarks(String bookId) async {
return getByBookIdAndType(bookId, 'bookmark');
}
/// 查询某本书的所有高亮/下划线
Future<List<BookAnnotation>> getAnnotations(String bookId) async {
final db = await _db;
final maps = await db.query(
'book_annotations',
where: "book_id = ? AND type IN ('highlight', 'underline')",
whereArgs: [bookId],
orderBy: 'created_at DESC',
);
return maps.map((m) => BookAnnotation.fromMap(m)).toList();
}
/// 根据 id 查询
Future<BookAnnotation?> getById(int id) async {
final db = await _db;
final maps = await db.query(
'book_annotations',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (maps.isEmpty) return null;
return BookAnnotation.fromMap(maps.first);
}
/// 获取某本书的批注数量
Future<int> getCount(String bookId) async {
final db = await _db;
final result = await db.rawQuery(
'SELECT COUNT(*) as cnt FROM book_annotations WHERE book_id = ?',
[bookId],
);
return Sqflite.firstIntValue(result) ?? 0;
}
}

View File

@@ -0,0 +1,12 @@
/// 将归一化坐标 (0-1) 映射到 3x3 九宫格区域 (0-8)
///
/// ```
/// 0 1 2
/// 3 4 5
/// 6 7 8
/// ```
int coordinatesToPart(double x, double y) {
final col = x < 0.33 ? 0 : (x < 0.66 ? 1 : 2);
final row = y < 0.33 ? 0 : (y < 0.66 ? 1 : 2);
return row * 3 + col;
}

View File

@@ -1,6 +1,5 @@
import 'dart:convert';
import '../../service/book_server.dart';
import '../color_converter.dart';
/// 生成 foliate-js 阅读器 URL
String generateReaderUrl({
@@ -12,11 +11,11 @@ String generateReaderUrl({
}) {
final indexHtmlPath = 'http://127.0.0.1:${Server().port}/foliate-js/index.html';
final jsBg = convertDartColorToJs(backgroundColor);
final jsTc = convertDartColorToJs(textColor);
final jsBg = _convertDartColorToJs(backgroundColor);
final jsTc = _convertDartColorToJs(textColor);
final style = {
'fontSize': 100, // 100 = base 100%
'fontSize': 100,
'fontName': '',
'fontPath': '',
'fontWeight': 400,
@@ -28,7 +27,7 @@ String generateReaderUrl({
'backgroundColor': '#$jsBg',
'topMargin': 25,
'bottomMargin': 25,
'sideMargin': 15,
'sideMargin': 3,
'justify': true,
'hyphenate': false,
'pageTurnStyle': 'slide',
@@ -48,17 +47,11 @@ String generateReaderUrl({
'codeHighlightTheme': 'atom-one-light',
};
final readingRules = {
'convertChineseMode': 'none',
'bionicReadingMode': false,
};
final params = {
'importing': false,
'url': fileUrl,
'initialCfi': cfi,
'style': style,
'readingRules': readingRules,
};
final queryParts = params.entries
@@ -67,3 +60,14 @@ String generateReaderUrl({
return '$indexHtmlPath?$queryParts';
}
/// 将 Dart 的 ARGB hex (FFRRGGBB) 转成 CSS 的 #RRGGBB 格式
String _convertDartColorToJs(String dartColor) {
if (dartColor.startsWith('#')) {
dartColor = dartColor.substring(1);
}
if (dartColor.length == 8) {
return '#${dartColor.substring(2)}';
}
return '#$dartColor';
}

View File

@@ -198,7 +198,7 @@ class _CustomDrawerState extends State<CustomDrawer> {
_buildToolItem(Icons.menu_book_outlined, '阅读器', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookshelfPage()));
}, bottomRounded: true, enabled: false),
}, bottomRounded: true),
],
),
);

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import '../../../models/book_annotation.dart';
import '../../../utils/reader/book_annotation_dao.dart';
/// 书签列表组件 — 显示当前书籍的所有书签,点击跳转
class BookmarkList extends StatefulWidget {
final String bookId;
final void Function(String cfi) onNavigate;
const BookmarkList({
super.key,
required this.bookId,
required this.onNavigate,
});
@override
State<BookmarkList> createState() => _BookmarkListState();
}
class _BookmarkListState extends State<BookmarkList> {
final _dao = BookAnnotationDao();
List<BookAnnotation> _bookmarks = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadBookmarks();
}
Future<void> _loadBookmarks() async {
final bookmarks = await _dao.getBookmarks(widget.bookId);
if (mounted) {
setState(() {
_bookmarks = bookmarks;
_loading = false;
});
}
}
Future<void> _deleteBookmark(BookAnnotation bookmark) async {
await _dao.deleteById(bookmark.id!);
await _loadBookmarks();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_bookmarks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bookmark_border, size: 48, color: colors.onSurfaceVariant.withAlpha(80)),
const SizedBox(height: 12),
Text('暂无书签', style: TextStyle(color: colors.onSurfaceVariant)),
const SizedBox(height: 4),
Text(
'阅读时点击顶部 ⭐ 按钮添加书签',
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant.withAlpha(150)),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _bookmarks.length,
itemBuilder: (context, index) {
final bm = _bookmarks[index];
return Dismissible(
key: ValueKey(bm.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
color: colors.error,
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (_) async {
return await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除书签'),
content: Text('确定删除「${bm.content.isNotEmpty ? bm.content : bm.chapter}」?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('删除')),
],
),
);
},
onDismissed: (_) => _deleteBookmark(bm),
child: ListTile(
leading: Icon(Icons.bookmark, color: Colors.amber, size: 20),
title: Text(
bm.content.isNotEmpty ? bm.content : bm.chapter,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
bm.chapter.isNotEmpty ? bm.chapter : '',
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
dense: true,
onTap: () => widget.onNavigate(bm.cfi),
),
);
},
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../models/book_annotation.dart';
import '../../../utils/reader/book_annotation_dao.dart';
/// 阅读笔记面板 — 显示当前书籍的高亮和下划线批注
class ReadingNotesPanel extends StatefulWidget {
final String bookId;
final void Function(String cfi)? onNavigate;
const ReadingNotesPanel({
super.key,
required this.bookId,
this.onNavigate,
});
@override
State<ReadingNotesPanel> createState() => _ReadingNotesPanelState();
}
class _ReadingNotesPanelState extends State<ReadingNotesPanel> {
final _dao = BookAnnotationDao();
List<BookAnnotation> _annotations = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadAnnotations();
}
Future<void> _loadAnnotations() async {
final list = await _dao.getAnnotations(widget.bookId);
if (mounted) {
setState(() {
_annotations = list;
_loading = false;
});
}
}
Future<void> _deleteAnnotation(BookAnnotation anno) async {
await _dao.deleteById(anno.id!);
await _loadAnnotations();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (_loading) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
if (_annotations.isEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_note, size: 40, color: colors.onSurfaceVariant.withAlpha(80)),
const SizedBox(height: 12),
Text('暂无笔记', style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant)),
const SizedBox(height: 4),
Text(
'选中文字后可添加高亮、下划线和笔记',
style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant.withAlpha(150)),
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'笔记 (${_annotations.length})',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface),
),
),
...List.generate(_annotations.length, (i) {
final anno = _annotations[i];
final annoColor = Color(int.parse('0x${anno.color}'));
final isHighlight = anno.type == 'highlight';
return ListTile(
dense: true,
leading: Container(
width: 4,
height: 32,
decoration: BoxDecoration(
color: annoColor.withAlpha(180),
borderRadius: BorderRadius.circular(2),
),
),
title: Text(
anno.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
decoration: isHighlight ? null : TextDecoration.underline,
decorationColor: annoColor.withAlpha(120),
decorationThickness: 2,
),
),
subtitle: anno.chapter.isNotEmpty
? Text(anno.chapter,
style: TextStyle(fontSize: 11, color: colors.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis)
: null,
trailing: PopupMenuButton<String>(
icon: Icon(Icons.more_vert, size: 18, color: colors.onSurfaceVariant),
onSelected: (action) async {
if (action == 'copy') {
await Clipboard.setData(ClipboardData(text: anno.content));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已复制'), duration: Duration(seconds: 1)),
);
}
} else if (action == 'delete') {
await _deleteAnnotation(anno);
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'copy', child: Text('复制')),
const PopupMenuItem(value: 'delete', child: Text('删除')),
],
),
onTap: widget.onNavigate != null ? () => widget.onNavigate!(anno.cfi) : null,
);
}),
],
);
}
}