基础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

@@ -1099,8 +1099,8 @@ class Reader {
this.setView(this.view) this.setView(this.view)
await this.view.init({ lastLocation: cfi }) await this.view.init({ lastLocation: cfi })
// set html bg color to grey // set html bg color to match theme
document.documentElement.style.backgroundColor = 'grey' document.documentElement.style.backgroundColor = style.backgroundColor
} }
setView(view) { setView(view) {

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:async';
import 'dart:convert';
import 'dart:io'; 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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import '../../models/reader_book.dart'; import '../../models/reader_book.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../service/book_server.dart'; import '../../service/book_server.dart';
import '../../utils/reader/book_file_helper.dart'; import '../../utils/reader/book_file_helper.dart';
import '../../utils/reader/coordinates_to_part.dart';
import '../../utils/reader/reader_url_generator.dart'; import '../../utils/reader/reader_url_generator.dart';
/// 目录条目 /// 目录条目
class TocItem { class TocItem {
final String href; final String href;
final String title; 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) { factory TocItem.fromJson(Map<String, dynamic> json) {
return TocItem( return TocItem(
href: json['href'] ?? '', 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 阅读器 /// 核心电子书阅读组件 — 使用 InAppWebView 渲染 foliate-js 阅读器
class EpubPlayer extends StatefulWidget { class EpubPlayer extends StatefulWidget {
final ReaderBook book; final ReaderBook book;
@@ -51,14 +86,24 @@ class EpubPlayerState extends State<EpubPlayer> {
String cfi = ''; String cfi = '';
double percentage = 0.0; double percentage = 0.0;
String chapterTitle = ''; String chapterTitle = '';
String chapterHref = '';
int chapterCurrentPage = 0; int chapterCurrentPage = 0;
int chapterTotalPages = 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( InAppWebViewSettings get _settings => InAppWebViewSettings(
supportZoom: false, supportZoom: false,
transparentBackground: true, transparentBackground: true,
isInspectable: kDebugMode,
useHybridComposition: true, useHybridComposition: true,
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW, mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
); );
@@ -93,40 +138,14 @@ class EpubPlayerState extends State<EpubPlayer> {
_controller.evaluateJavascript(source: "goToCfi('$cfi')"); _controller.evaluateJavascript(source: "goToCfi('$cfi')");
} }
// ─── 样式方法 ─────────────────────────────────────── // ─── 历史导航 ───────────────────────────────────────
void changeStyle({ void backHistory() {
double? fontSize, _controller.evaluateJavascript(source: 'back()');
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 changeTheme(String bgColor, String textColor) { void forwardHistory() {
_controller.evaluateJavascript(source: ''' _controller.evaluateJavascript(source: 'forward()');
changeStyle({
backgroundColor: '#$bgColor',
fontColor: '#$textColor',
})
''');
} }
// ─── 保存进度 ─────────────────────────────────────── // ─── 保存进度 ───────────────────────────────────────
@@ -142,71 +161,168 @@ class EpubPlayerState extends State<EpubPlayer> {
await provider.updateReaderBook(updated); 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 回调 ─────────────────────────────────── // ─── WebView 回调 ───────────────────────────────────
Future<void> _onWebViewCreated(InAppWebViewController controller) async { Future<void> _onWebViewCreated(InAppWebViewController controller) async {
if (Platform.isAndroid) {
await InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
_controller = controller; _controller = controller;
_setHandlers(controller); _setHandlers(controller);
// 开启常亮
WakelockPlus.enable(); WakelockPlus.enable();
} }
void _setHandlers(InAppWebViewController controller) { void _setHandlers(InAppWebViewController controller) {
// 阅读位置变化 controller.addJavaScriptHandler(
handlerName: 'onLoadEnd',
callback: (args) {},
);
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'onRelocated', handlerName: 'onRelocated',
callback: (args) { callback: (args) {
final location = args[0] as Map<String, dynamic>; final location = args[0] as Map<String, dynamic>;
if (cfi == location['cfi']) return;
setState(() { setState(() {
cfi = location['cfi'] ?? ''; cfi = location['cfi'] ?? '';
percentage = double.tryParse(location['percentage']?.toString() ?? '0') ?? 0.0; percentage = double.tryParse(location['percentage']?.toString() ?? '0') ?? 0.0;
chapterTitle = location['chapterTitle'] ?? ''; chapterTitle = location['chapterTitle'] ?? '';
chapterHref = location['chapterHref'] ?? '';
chapterCurrentPage = location['chapterCurrentPage'] ?? 0; chapterCurrentPage = location['chapterCurrentPage'] ?? 0;
chapterTotalPages = location['chapterTotalPages'] ?? 0; chapterTotalPages = location['chapterTotalPages'] ?? 0;
}); });
saveReadingProgress();
}, },
); );
// 点击事件(控制翻页和工具栏)
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'onClick', handlerName: 'onClick',
callback: (args) { callback: (args) {
final location = args[0] as Map<String, dynamic>; final location = args[0] as Map<String, dynamic>;
final x = location['x'] as num?; _onClick(location);
final y = location['y'] as num?; },
if (x == null || y == null) return; );
final pageWidth = MediaQuery.of(context).size.width; controller.addJavaScriptHandler(
final clickX = x.toDouble() * pageWidth; handlerName: 'onExternalLink',
final oneThird = pageWidth / 3; callback: (args) async {
final twoThird = pageWidth * 2 / 3; await _handleExternalLink(args.isNotEmpty ? args.first : null);
if (clickX < oneThird) {
prevPage();
} else if (clickX > twoThird) {
nextPage();
} else {
widget.showOrHideToolbar();
}
}, },
); );
// 目录数据
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'onSetToc', handlerName: 'onSetToc',
callback: (args) { callback: (args) {
final List<dynamic> rawToc = args[0]; final List<dynamic> rawToc = args[0];
final toc = rawToc.map((item) { final toc = _flattenToc(rawToc);
if (item is Map) {
return TocItem.fromJson(Map<String, dynamic>.from(item));
}
return TocItem(href: '', title: item.toString());
}).toList();
widget.onTocReady?.call(toc); 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( controller.addJavaScriptHandler(
handlerName: 'onPullUp', handlerName: 'onPullUp',
callback: (args) { callback: (args) {
@@ -217,45 +333,169 @@ class EpubPlayerState extends State<EpubPlayer> {
@override @override
void dispose() { void dispose() {
_styleTimer?.cancel(); _scrollDebounceTimer?.cancel();
saveReadingProgress(); saveReadingProgress();
WakelockPlus.disable(); WakelockPlus.disable();
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final fileAbsolute = BookFileHelper.instance.resolveAbsolutePath(widget.book.filePath); 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 bookUrl = 'http://127.0.0.1:${Server().port}/book/${Uri.encodeComponent(fileAbsolute)}';
final initialCfi = widget.initialCfi ?? widget.book.lastReadCfi; final initialCfi = widget.initialCfi ?? widget.book.lastReadCfi;
final bgColor = isDark ? 'FF1A1A1A' : 'FFFFFFFF';
final textColor = isDark ? 'FFE5E5E5' : 'FF1A1A1A';
final url = generateReaderUrl( final url = generateReaderUrl(
fileUrl: bookUrl, fileUrl: bookUrl,
cfi: initialCfi, cfi: initialCfi,
backgroundColor: bgColor, backgroundColor: 'FFFBFBF3',
textColor: textColor, textColor: 'FF343434',
isDarkMode: isDark,
); );
debugPrint('[EpubPlayer] port=${Server().port} running=${Server().isRunning}'); return Listener(
debugPrint('[EpubPlayer] fileAbsolute=$fileAbsolute exists=$fileExists'); onPointerSignal: _handlePointerEvents,
child: Scaffold(
return InAppWebView( resizeToAvoidBottomInset: false,
initialUrlRequest: URLRequest(url: WebUri(url)), body: Stack(
initialSettings: _settings, children: [
onWebViewCreated: _onWebViewCreated, SizedBox.expand(
onReceivedError: (controller, request, error) { child: InAppWebView(
debugPrint('[EpubPlayer] WebView error: ${error.description}'); initialUrlRequest: URLRequest(url: WebUri(url)),
}, initialSettings: _settings,
onConsoleMessage: (controller, msg) { onWebViewCreated: _onWebViewCreated,
debugPrint('[EpubPlayer] JS: ${msg.message}'); 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 '../../models/reader_book.dart';
import '../../service/book_server.dart'; import '../../service/book_server.dart';
import '../../utils/reader/book_file_helper.dart'; import '../../utils/reader/book_file_helper.dart';
import '../../widgets/reader/reading_notes_panel.dart';
import 'epub_player.dart'; import 'epub_player.dart';
import 'toc_drawer.dart'; import 'toc_drawer.dart';
import 'progress_panel.dart'; import 'progress_panel.dart';
import 'style_panel.dart';
/// 书籍阅读页面 /// 书籍阅读页面
class ReadingPage extends StatefulWidget { class ReadingPage extends StatefulWidget {
@@ -22,12 +22,12 @@ class ReadingPage extends StatefulWidget {
class _ReadingPageState extends State<ReadingPage> { class _ReadingPageState extends State<ReadingPage> {
final GlobalKey<EpubPlayerState> _epubPlayerKey = GlobalKey<EpubPlayerState>(); final GlobalKey<EpubPlayerState> _epubPlayerKey = GlobalKey<EpubPlayerState>();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final FocusNode _readerFocusNode = FocusNode();
static const _empty = SizedBox.shrink(); static const _empty = SizedBox.shrink();
bool _toolbarOffstage = true; // true=隐藏, false=显示 bool _toolbarOffstage = true;
Widget _currentPage = const SizedBox.shrink(); Widget _currentPage = const SizedBox.shrink();
bool _serverReady = false; bool _serverReady = false;
List<TocItem> _toc = []; List<TocItem> _toc = [];
@override @override
@@ -35,6 +35,9 @@ class _ReadingPageState extends State<ReadingPage> {
super.initState(); super.initState();
_initServer(); _initServer();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
WidgetsBinding.instance.addPostFrameCallback((_) {
_readerFocusNode.requestFocus();
});
} }
Future<void> _initServer() async { Future<void> _initServer() async {
@@ -47,15 +50,12 @@ class _ReadingPageState extends State<ReadingPage> {
void dispose() { void dispose() {
_epubPlayerKey.currentState?.saveReadingProgress(); _epubPlayerKey.currentState?.saveReadingProgress();
Server().stop(); Server().stop();
_readerFocusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose(); super.dispose();
} }
void _showToolbar() { void _showToolbar() => setState(() => _toolbarOffstage = false);
setState(() {
_toolbarOffstage = false;
});
}
void _hideToolbar() { void _hideToolbar() {
setState(() { setState(() {
@@ -64,13 +64,7 @@ class _ReadingPageState extends State<ReadingPage> {
}); });
} }
void _toggleToolbar() { void _toggleToolbar() => _toolbarOffstage ? _showToolbar() : _hideToolbar();
if (_toolbarOffstage) {
_showToolbar();
} else {
_hideToolbar();
}
}
void _onTocReady(List<TocItem> toc) { void _onTocReady(List<TocItem> toc) {
if (mounted) setState(() => _toc = toc); if (mounted) setState(() => _toc = toc);
@@ -81,48 +75,62 @@ class _ReadingPageState extends State<ReadingPage> {
_scaffoldKey.currentState?.openDrawer(); _scaffoldKey.currentState?.openDrawer();
} }
// ─── 底部面板切换 ───────────────────────────────────── void _setPanel(Widget panel) {
setState(() => _currentPage = panel);
void _onProgressPressed() {
setState(() {
_currentPage = ProgressPanel(epubPlayerKey: _epubPlayerKey);
});
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
// ─── 工具栏覆盖层(照抄 anx-reader 的 Offstage + PointerInterceptor 模式) final toolbar = Offstage(
Offstage controller = Offstage(
offstage: _toolbarOffstage, offstage: _toolbarOffstage,
child: PointerInterceptor( child: PointerInterceptor(
child: Stack( child: Stack(
children: [ children: [
// 半透明背景,点击关闭工具栏
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: _hideToolbar, onTap: _hideToolbar,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: (details) {}, child: Container(color: Colors.black.withAlpha(38)),
onVerticalDragEnd: (details) {},
child: Container(
color: Colors.black.withValues(alpha: 0.15),
),
), ),
), ),
// 顶部 AppBar + 底部工具栏
Column( Column(
children: [ children: [
// 顶部 AppBar
AppBar( AppBar(
backgroundColor: colors.surface.withValues(alpha: 0.94), backgroundColor: colors.surface.withAlpha(240),
title: Text(widget.book.title, overflow: TextOverflow.ellipsis), title: Text(widget.book.title, overflow: TextOverflow.ellipsis),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
@@ -130,7 +138,6 @@ class _ReadingPageState extends State<ReadingPage> {
), ),
), ),
const Spacer(), const Spacer(),
// 底部面板 + 按钮行
BottomSheet( BottomSheet(
onClosing: () {}, onClosing: () {},
enableDrag: false, enableDrag: false,
@@ -145,14 +152,10 @@ class _ReadingPageState extends State<ReadingPage> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 面板内容区域
if (hasContent) if (hasContent)
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(child: _currentPage),
child: _currentPage,
),
), ),
// 按钮行
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
@@ -162,20 +165,20 @@ class _ReadingPageState extends State<ReadingPage> {
onPressed: _openTocDrawer, onPressed: _openTocDrawer,
), ),
IconButton( IconButton(
icon: const Icon(Icons.data_usage), icon: const Icon(Icons.edit_note),
tooltip: '进度', tooltip: '笔记',
onPressed: () { onPressed: () {
modalSetState(() { modalSetState(() {
_onProgressPressed(); _setPanel(_buildNotesPanel());
}); });
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.color_lens), icon: const Icon(Icons.data_usage),
tooltip: '样式', tooltip: '进度',
onPressed: () { onPressed: () {
modalSetState(() { modalSetState(() {
_onStylePressed(); _setPanel(ProgressPanel(epubPlayerKey: _epubPlayerKey));
}); });
}, },
), ),
@@ -213,7 +216,6 @@ class _ReadingPageState extends State<ReadingPage> {
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
backgroundColor: colors.surface, backgroundColor: colors.surface,
// TOC 目录抽屉
drawer: PointerInterceptor( drawer: PointerInterceptor(
child: Drawer( child: Drawer(
width: MediaQuery.of(context).size.width * 0.75, width: MediaQuery.of(context).size.width * 0.75,
@@ -224,20 +226,33 @@ class _ReadingPageState extends State<ReadingPage> {
), ),
), ),
body: _serverReady body: _serverReady
? Stack( ? Focus(
children: [ focusNode: _readerFocusNode,
// 阅读内容WebView onKeyEvent: _handleKeyEvent,
EpubPlayer( autofocus: true,
key: _epubPlayerKey, child: Stack(
book: widget.book, children: [
showOrHideToolbar: _toggleToolbar, EpubPlayer(
onTocReady: _onTocReady, key: _epubPlayerKey,
), book: widget.book,
// 工具栏覆盖层 showOrHideToolbar: _toggleToolbar,
controller, onTocReady: _onTocReady,
], ),
toolbar,
],
),
) )
: const Center(child: CircularProgressIndicator()), : 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), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row( child: Row(
children: [ 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), const SizedBox(width: 10),
Text('目录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), Text('目录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(), const Spacer(),
@@ -43,25 +43,27 @@ class TocDrawer extends StatelessWidget {
Expanded( Expanded(
child: toc.isEmpty child: toc.isEmpty
? Center( ? Center(
child: Text('暂无目录', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.3))), child: Text('暂无目录', style: TextStyle(color: colors.onSurfaceVariant)),
) )
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: toc.length, itemCount: toc.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = toc[index]; final item = toc[index];
final indent = (item.level - 1) * 16.0;
return InkWell( return InkWell(
onTap: () { onTap: () {
epubPlayerKey.currentState?.goToHref(item.href); epubPlayerKey.currentState?.goToHref(item.href);
Navigator.pop(context); Navigator.pop(context);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: EdgeInsets.only(left: 20 + indent, right: 20, top: 12, bottom: 12),
child: Text( child: Text(
item.title, item.title,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: item.level == 1 ? 14 : 13,
color: colors.onSurface.withValues(alpha: 0.8), fontWeight: item.level == 1 ? FontWeight.w500 : FontWeight.normal,
color: colors.onSurface.withAlpha(204),
), ),
), ),
), ),

View File

@@ -39,7 +39,7 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 22, version: 23,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -156,6 +156,10 @@ class DatabaseHelper {
// 为笔记表添加置顶字段 // 为笔记表添加置顶字段
await _upgradeNotesTableV22(db); await _upgradeNotesTableV22(db);
} }
if (oldVersion < 23) {
// 创建书籍批注表(高亮、下划线、书签)
await _createBookAnnotationsTable(db);
}
} }
/// 升级books表到V11添加ISBN和出版时间字段 /// 升级books表到V11添加ISBN和出版时间字段
@@ -683,6 +687,9 @@ class DatabaseHelper {
// Note Plus 块编辑器文档表 // Note Plus 块编辑器文档表
await _createNotePlusTable(db); await _createNotePlusTable(db);
// 书籍批注表
await _createBookAnnotationsTable(db);
} }
/// 创建 Note Plus 文档表 /// 创建 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 { Future close() async {
if (_database != null) { 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 'dart:convert';
import '../../service/book_server.dart'; import '../../service/book_server.dart';
import '../color_converter.dart';
/// 生成 foliate-js 阅读器 URL /// 生成 foliate-js 阅读器 URL
String generateReaderUrl({ String generateReaderUrl({
@@ -12,11 +11,11 @@ String generateReaderUrl({
}) { }) {
final indexHtmlPath = 'http://127.0.0.1:${Server().port}/foliate-js/index.html'; final indexHtmlPath = 'http://127.0.0.1:${Server().port}/foliate-js/index.html';
final jsBg = convertDartColorToJs(backgroundColor); final jsBg = _convertDartColorToJs(backgroundColor);
final jsTc = convertDartColorToJs(textColor); final jsTc = _convertDartColorToJs(textColor);
final style = { final style = {
'fontSize': 100, // 100 = base 100% 'fontSize': 100,
'fontName': '', 'fontName': '',
'fontPath': '', 'fontPath': '',
'fontWeight': 400, 'fontWeight': 400,
@@ -28,7 +27,7 @@ String generateReaderUrl({
'backgroundColor': '#$jsBg', 'backgroundColor': '#$jsBg',
'topMargin': 25, 'topMargin': 25,
'bottomMargin': 25, 'bottomMargin': 25,
'sideMargin': 15, 'sideMargin': 3,
'justify': true, 'justify': true,
'hyphenate': false, 'hyphenate': false,
'pageTurnStyle': 'slide', 'pageTurnStyle': 'slide',
@@ -48,17 +47,11 @@ String generateReaderUrl({
'codeHighlightTheme': 'atom-one-light', 'codeHighlightTheme': 'atom-one-light',
}; };
final readingRules = {
'convertChineseMode': 'none',
'bionicReadingMode': false,
};
final params = { final params = {
'importing': false, 'importing': false,
'url': fileUrl, 'url': fileUrl,
'initialCfi': cfi, 'initialCfi': cfi,
'style': style, 'style': style,
'readingRules': readingRules,
}; };
final queryParts = params.entries final queryParts = params.entries
@@ -67,3 +60,14 @@ String generateReaderUrl({
return '$indexHtmlPath?$queryParts'; 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, '阅读器', () { _buildToolItem(Icons.menu_book_outlined, '阅读器', () {
Navigator.pop(context); Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookshelfPage())); 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,
);
}),
],
);
}
}

View File

@@ -5,6 +5,7 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import battery_plus
import dynamic_color import dynamic_color
import file_picker import file_picker
import file_selector_macos import file_selector_macos
@@ -19,6 +20,7 @@ import wakelock_plus
import webview_flutter_wkwebview import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin"))
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))

View File

@@ -25,6 +25,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -271,7 +287,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_colorpicker: flutter_colorpicker:
dependency: transitive dependency: "direct main"
description: description:
name: flutter_colorpicker name: flutter_colorpicker
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
@@ -1154,6 +1170,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
upower:
dependency: transitive
description:
name: upower
sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf
url: "https://pub.dev"
source: hosted
version: "0.7.0"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -40,6 +40,8 @@ dependencies:
gbk_codec: ^0.4.0 gbk_codec: ^0.4.0
expandable: ^5.0.1 expandable: ^5.0.1
flutter_quill: ^11.5.0 flutter_quill: ^11.5.0
battery_plus: ^6.2.1
flutter_colorpicker: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <battery_plus/battery_plus_windows_plugin.h>
#include <dynamic_color/dynamic_color_plugin_c_api.h> #include <dynamic_color/dynamic_color_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h> #include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
@@ -14,6 +15,8 @@
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
BatteryPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin"));
DynamicColorPluginCApiRegisterWithRegistrar( DynamicColorPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); registry->GetRegistrarForPlugin("DynamicColorPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
battery_plus
dynamic_color dynamic_color
file_selector_windows file_selector_windows
flutter_inappwebview_windows flutter_inappwebview_windows