From 5acbc3a6029aa9f11ebcb697752a39c2f6572de6 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 27 Jun 2026 11:20:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=A1=80epub=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/foliate-js/src/book.js | 4 +- lib/models/book_annotation.dart | 92 ++++ lib/pages/reader/epub_player.dart | 412 ++++++++++++++---- lib/pages/reader/reading_page.dart | 137 +++--- lib/pages/reader/style_panel.dart | 162 ------- lib/pages/reader/toc_drawer.dart | 12 +- lib/utils/database_helper.dart | 35 +- lib/utils/reader/book_annotation_dao.dart | 117 +++++ lib/utils/reader/coordinates_to_part.dart | 12 + lib/utils/reader/reader_url_generator.dart | 26 +- lib/widgets/custom_drawer.dart | 2 +- lib/widgets/reader/bookmark_list.dart | 121 +++++ lib/widgets/reader/reading_notes_panel.dart | 144 ++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 26 +- pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 18 files changed, 980 insertions(+), 330 deletions(-) create mode 100644 lib/models/book_annotation.dart delete mode 100644 lib/pages/reader/style_panel.dart create mode 100644 lib/utils/reader/book_annotation_dao.dart create mode 100644 lib/utils/reader/coordinates_to_part.dart create mode 100644 lib/widgets/reader/bookmark_list.dart create mode 100644 lib/widgets/reader/reading_notes_panel.dart diff --git a/assets/foliate-js/src/book.js b/assets/foliate-js/src/book.js index bbd1661..321a78a 100644 --- a/assets/foliate-js/src/book.js +++ b/assets/foliate-js/src/book.js @@ -1099,8 +1099,8 @@ class Reader { this.setView(this.view) await this.view.init({ lastLocation: cfi }) - // set html bg color to grey - document.documentElement.style.backgroundColor = 'grey' + // set html bg color to match theme + document.documentElement.style.backgroundColor = style.backgroundColor } setView(view) { diff --git a/lib/models/book_annotation.dart b/lib/models/book_annotation.dart new file mode 100644 index 0000000..ac13a11 --- /dev/null +++ b/lib/models/book_annotation.dart @@ -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 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 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 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(), + ); + } +} diff --git a/lib/pages/reader/epub_player.dart b/lib/pages/reader/epub_player.dart index 5b48ea8..7d1c668 100644 --- a/lib/pages/reader/epub_player.dart +++ b/lib/pages/reader/epub_player.dart @@ -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 json) { return TocItem( href: json['href'] ?? '', - title: json['title'] ?? '', + title: json['label'] ?? json['title'] ?? '', + level: json['level'] as int? ?? 1, ); } } +/// 递归展平嵌套目录 +List _flattenToc(List items, [int level = 1]) { + final result = []; + for (final item in items) { + if (item is Map) { + final map = Map.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 _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 { 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 { _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 = {}; - 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 { await provider.updateReaderBook(updated); } + // ─── 翻页区域处理 ────────────────────────────────── + + void _onClick(Map 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 _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 _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( + 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 _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; + 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; - 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 rawToc = args[0]; - final toc = rawToc.map((item) { - if (item is Map) { - return TocItem.fromJson(Map.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; + 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 { @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( + 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( + 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 = []; + 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(), + ], + ), + ), ); } } diff --git a/lib/pages/reader/reading_page.dart b/lib/pages/reader/reading_page.dart index b58272e..e6c358f 100644 --- a/lib/pages/reader/reading_page.dart +++ b/lib/pages/reader/reading_page.dart @@ -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 { final GlobalKey _epubPlayerKey = GlobalKey(); final GlobalKey _scaffoldKey = GlobalKey(); + 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 _toc = []; @override @@ -35,6 +35,9 @@ class _ReadingPageState extends State { super.initState(); _initServer(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + WidgetsBinding.instance.addPostFrameCallback((_) { + _readerFocusNode.requestFocus(); + }); } Future _initServer() async { @@ -47,15 +50,12 @@ class _ReadingPageState extends State { 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 { }); } - void _toggleToolbar() { - if (_toolbarOffstage) { - _showToolbar(); - } else { - _hideToolbar(); - } - } + void _toggleToolbar() => _toolbarOffstage ? _showToolbar() : _hideToolbar(); void _onTocReady(List toc) { if (mounted) setState(() => _toc = toc); @@ -81,48 +75,62 @@ class _ReadingPageState extends State { _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 { ), ), const Spacer(), - // 底部面板 + 按钮行 BottomSheet( onClosing: () {}, enableDrag: false, @@ -145,14 +152,10 @@ class _ReadingPageState extends State { 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 { 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 { 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 { ), ), 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); + }, + ); + } } diff --git a/lib/pages/reader/style_panel.dart b/lib/pages/reader/style_panel.dart deleted file mode 100644 index bf6f094..0000000 --- a/lib/pages/reader/style_panel.dart +++ /dev/null @@ -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 epubPlayerKey; - - const StylePanel({super.key, required this.epubPlayerKey}); - - @override - State createState() => _StylePanelState(); -} - -class _StylePanelState extends State { - 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)), - ), - ), - ); - }), - ), - ], - ), - ); - } -} diff --git a/lib/pages/reader/toc_drawer.dart b/lib/pages/reader/toc_drawer.dart index fbd9675..af74a3c 100644 --- a/lib/pages/reader/toc_drawer.dart +++ b/lib/pages/reader/toc_drawer.dart @@ -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), ), ), ), diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 58c6b14..5691843 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -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 _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) { diff --git a/lib/utils/reader/book_annotation_dao.dart b/lib/utils/reader/book_annotation_dao.dart new file mode 100644 index 0000000..da73ac7 --- /dev/null +++ b/lib/utils/reader/book_annotation_dao.dart @@ -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 get _db async => await _dbHelper.database; + + /// 插入批注,返回插入的 id + Future insert(BookAnnotation annotation) async { + final db = await _db; + return await db.insert('book_annotations', annotation.toMap()..remove('id')); + } + + /// 更新批注 + Future update(BookAnnotation annotation) async { + final db = await _db; + await db.update( + 'book_annotations', + annotation.toMap(), + where: 'id = ?', + whereArgs: [annotation.id], + ); + } + + /// 保存(有 id 则更新,无 id 则插入) + Future 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 deleteById(int id) async { + final db = await _db; + await db.delete('book_annotations', where: 'id = ?', whereArgs: [id]); + } + + /// 根据 CFI 删除(用于删除高亮/下划线) + Future deleteByCfi(String bookId, String cfi) async { + final db = await _db; + await db.delete( + 'book_annotations', + where: 'book_id = ? AND cfi = ?', + whereArgs: [bookId, cfi], + ); + } + + /// 查询某本书的所有批注 + Future> 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> 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> getBookmarks(String bookId) async { + return getByBookIdAndType(bookId, 'bookmark'); + } + + /// 查询某本书的所有高亮/下划线 + Future> 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 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 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; + } +} diff --git a/lib/utils/reader/coordinates_to_part.dart b/lib/utils/reader/coordinates_to_part.dart new file mode 100644 index 0000000..cede4ad --- /dev/null +++ b/lib/utils/reader/coordinates_to_part.dart @@ -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; +} diff --git a/lib/utils/reader/reader_url_generator.dart b/lib/utils/reader/reader_url_generator.dart index b54ee3c..ee3084a 100644 --- a/lib/utils/reader/reader_url_generator.dart +++ b/lib/utils/reader/reader_url_generator.dart @@ -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'; +} diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 206982f..235087a 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -198,7 +198,7 @@ class _CustomDrawerState extends State { _buildToolItem(Icons.menu_book_outlined, '阅读器', () { Navigator.pop(context); Navigator.push(context, MaterialPageRoute(builder: (_) => const BookshelfPage())); - }, bottomRounded: true, enabled: false), + }, bottomRounded: true), ], ), ); diff --git a/lib/widgets/reader/bookmark_list.dart b/lib/widgets/reader/bookmark_list.dart new file mode 100644 index 0000000..b7561f7 --- /dev/null +++ b/lib/widgets/reader/bookmark_list.dart @@ -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 createState() => _BookmarkListState(); +} + +class _BookmarkListState extends State { + final _dao = BookAnnotationDao(); + List _bookmarks = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadBookmarks(); + } + + Future _loadBookmarks() async { + final bookmarks = await _dao.getBookmarks(widget.bookId); + if (mounted) { + setState(() { + _bookmarks = bookmarks; + _loading = false; + }); + } + } + + Future _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( + 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), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/reader/reading_notes_panel.dart b/lib/widgets/reader/reading_notes_panel.dart new file mode 100644 index 0000000..20baeee --- /dev/null +++ b/lib/widgets/reader/reading_notes_panel.dart @@ -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 createState() => _ReadingNotesPanelState(); +} + +class _ReadingNotesPanelState extends State { + final _dao = BookAnnotationDao(); + List _annotations = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadAnnotations(); + } + + Future _loadAnnotations() async { + final list = await _dao.getAnnotations(widget.bookId); + if (mounted) { + setState(() { + _annotations = list; + _loading = false; + }); + } + } + + Future _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( + 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, + ); + }), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 4d90895..36d9266 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import battery_plus import dynamic_color import file_picker import file_selector_macos @@ -19,6 +20,7 @@ import wakelock_plus import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 269ae23..5799849 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + battery_plus: + dependency: "direct main" + description: + name: battery_plus + sha256: "03d5a6bb36db9d2b977c548f6b0262d5a84c4d5a4cfee2edac4a91d57011b365" + url: "https://pub.dev" + source: hosted + version: "6.2.3" + battery_plus_platform_interface: + dependency: transitive + description: + name: battery_plus_platform_interface + sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910 + url: "https://pub.dev" + source: hosted + version: "2.0.1" boolean_selector: dependency: transitive description: @@ -271,7 +287,7 @@ packages: source: sdk version: "0.0.0" flutter_colorpicker: - dependency: transitive + dependency: "direct main" description: name: flutter_colorpicker sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" @@ -1154,6 +1170,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + upower: + dependency: transitive + description: + name: upower + sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf + url: "https://pub.dev" + source: hosted + version: "0.7.0" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a343888..1037984 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,8 @@ dependencies: gbk_codec: ^0.4.0 expandable: ^5.0.1 flutter_quill: ^11.5.0 + battery_plus: ^6.2.1 + flutter_colorpicker: ^1.1.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ab82016..19c6f20 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -14,6 +15,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ce48080..395581b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + battery_plus dynamic_color file_selector_windows flutter_inappwebview_windows