原生epub阅读

This commit is contained in:
DelLevin-Home
2026-06-27 14:06:44 +08:00
parent 5acbc3a602
commit d54e33e4cf
177 changed files with 6500 additions and 93933 deletions

View File

@@ -1,92 +0,0 @@
/// 书籍批注数据模型 — 高亮、下划线、书签
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,104 +0,0 @@
/// 阅读器书籍模型
class ReaderBook {
final String id;
final String title;
final String author;
final String? coverPath;
final String filePath; // 相对路径
final String fileName;
final String fileExtension;
final String lastReadCfi;
final double readingPercentage;
final DateTime createdAt;
final DateTime updatedAt;
final bool isDeleted;
ReaderBook({
required this.id,
required this.title,
this.author = '',
this.coverPath,
required this.filePath,
required this.fileName,
required this.fileExtension,
this.lastReadCfi = '',
this.readingPercentage = 0.0,
required this.createdAt,
required this.updatedAt,
this.isDeleted = false,
});
factory ReaderBook.fromJson(Map<String, dynamic> json) {
return ReaderBook(
id: json['id'] ?? '',
title: json['title'] ?? '',
author: json['author'] ?? '',
coverPath: json['cover_path'],
filePath: json['file_path'] ?? '',
fileName: json['file_name'] ?? '',
fileExtension: json['file_extension'] ?? 'epub',
lastReadCfi: json['last_read_cfi'] ?? '',
readingPercentage: (json['reading_percentage'] as num?)?.toDouble() ?? 0.0,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: DateTime.now(),
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'author': author,
'cover_path': coverPath,
'file_path': filePath,
'file_name': fileName,
'file_extension': fileExtension,
'last_read_cfi': lastReadCfi,
'reading_percentage': readingPercentage,
'created_at': createdAt.toUtc().toIso8601String(),
'updated_at': updatedAt.toUtc().toIso8601String(),
'is_deleted': isDeleted ? 1 : 0,
};
}
ReaderBook copyWith({
String? id,
String? title,
String? author,
Object? coverPath = _readerBookCopyWithNull,
String? filePath,
String? fileName,
String? fileExtension,
String? lastReadCfi,
double? readingPercentage,
DateTime? createdAt,
DateTime? updatedAt,
bool? isDeleted,
}) {
return ReaderBook(
id: id ?? this.id,
title: title ?? this.title,
author: author ?? this.author,
coverPath: coverPath is _ReaderBookCopyWithNullSentinel ? this.coverPath : (coverPath as String?),
filePath: filePath ?? this.filePath,
fileName: fileName ?? this.fileName,
fileExtension: fileExtension ?? this.fileExtension,
lastReadCfi: lastReadCfi ?? this.lastReadCfi,
readingPercentage: readingPercentage ?? this.readingPercentage,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted,
);
}
}
class _ReaderBookCopyWithNullSentinel {
const _ReaderBookCopyWithNullSentinel();
}
const _readerBookCopyWithNull = _ReaderBookCopyWithNullSentinel();

View File

@@ -0,0 +1,266 @@
import 'dart:async';
import '../../utils/epub/epub_webview_handler.dart';
import '../../utils/epub/reader_dao.dart';
import '../../utils/epub/reader_models.dart';
/// Manages the current reading session including book data, TOC state, and
/// progress tracking. Adapted from lumina's BookSession but uses mooknote's
/// existing models instead of Isar.
class BookSession {
final String fileHash;
final Map<String, dynamic> bookData;
EpubBookInfo epubInfo;
final ReaderDao _readerDao;
// TOC Synchronization: Pre-calculated lookup maps
final Map<String, List<String>> _spineToAnchorsMap = {};
final List<TocEntry> _tocItemFallback = [];
final List<TocEntry> _flatToc = [];
final Map<String, int> _hrefToTocIndexMap = {};
Set<String> _activeAnchors = {};
final List<SpineItem> _spine = [];
final List<SpineItem> _noLinearSpine = [];
Timer? _debounceTimer;
BookSession({
required this.fileHash,
required this.bookData,
required this.epubInfo,
required ReaderDao readerDao,
}) : _readerDao = readerDao;
void dispose() {
_debounceTimer?.cancel();
_debounceTimer = null;
}
/// Update EPUB info after parsing (called when session is created before parse)
void updateEpubInfo(EpubBookInfo info) {
epubInfo = info;
}
// Getters
Map<String, dynamic> get book => bookData;
EpubBookInfo get epubBookInfo => epubInfo;
List<SpineItem> get spine => _spine;
List<SpineItem> get noLinearSpine => _noLinearSpine;
List<TocEntry> get toc => epubInfo.toc;
Set<String> get activeAnchors => _activeAnchors;
bool get isLoaded => true; // data is passed at construction time
int get direction => 0; // mooknote reader_books has no direction column
/// Initialize spine filtering and TOC lookup maps from passed data.
/// Call once after construction.
void load() {
// Filter spine into linear / non-linear
_spine.clear();
_noLinearSpine.clear();
for (final item in epubInfo.spine) {
if (item.linear) {
_spine.add(item);
} else {
_noLinearSpine.add(item);
}
}
_buildTocLookupMaps();
}
/// Pre-calculate TOC lookup maps for efficient synchronization.
void _buildTocLookupMaps() {
_flatToc.clear();
_hrefToTocIndexMap.clear();
_spineToAnchorsMap.clear();
void processItem(TocEntry item) {
final id = _flatToc.length;
_flatToc.add(item);
// TocEntry.href is "path#anchor"
final parts = item.href.split('#');
final filePath = parts[0];
final anchorId = parts.length > 1 ? parts[1] : 'top';
// Use composite key "path#anchor" for uniqueness
_hrefToTocIndexMap[item.href] = id;
_spineToAnchorsMap.putIfAbsent(filePath, () => []).add(anchorId);
for (final child in item.children) {
processItem(child);
}
}
for (final item in epubInfo.toc) {
processItem(item);
}
// Build fallback: for each spine item, pick the nearest preceding TOC entry
TocEntry? fallback;
_tocItemFallback.clear();
for (final spineItem in _spine) {
if (fallback != null) _tocItemFallback.add(fallback);
final anchors = _spineToAnchorsMap[spineItem.href] ?? [];
if (anchors.isNotEmpty) {
final lastHref = '${spineItem.href}#${anchors.last}';
final idx = _hrefToTocIndexMap[lastHref];
if (idx != null) {
fallback = _flatToc[idx];
}
}
}
}
// ─── Progress saving ──────────────────────────────────────────────
/// Save reading progress (debounced 10ms).
void saveProgress({
required int currentChapterIndex,
required int currentPageInChapter,
required int totalPagesInChapter,
}) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 10), () async {
var progress = 0.0;
if (_spine.isNotEmpty) {
final delta = 1.0 / _spine.length;
progress = (currentChapterIndex + 1) / _spine.length;
if (totalPagesInChapter > 0) {
progress -= delta;
progress +=
delta * ((currentPageInChapter + 1) / totalPagesInChapter);
}
}
await _readerDao.updateReadingProgress(
fileHash,
'$currentChapterIndex',
progress,
);
});
}
// ─── Spine / TOC helpers ──────────────────────────────────────────
/// Get anchors (anchor ids) for a given spine file path.
List<String> getAnchorsForSpine(String spinePath) {
return _spineToAnchorsMap[spinePath] ?? [];
}
/// Update active anchors based on scroll position.
void updateActiveAnchors(List<String> anchorIds) {
_activeAnchors = anchorIds.toSet();
}
/// Get the virtual URL for a spine item, optionally with an anchor.
String getSpineItemUrl(int index, [String anchor = 'top']) {
if (index < 0 || index >= _spine.length) return '';
final href = Href(path: _spine[index].href, anchor: anchor);
return EpubWebViewHandler.getFileUrl(fileHash, href);
}
/// Find the spine index that contains a given TOC entry.
int? findSpineIndexForTocItem(TocEntry item) {
final parts = item.href.split('#');
final targetPath = parts[0];
final index = _spine.indexWhere((s) => s.href == targetPath);
return index != -1 ? index : null;
}
/// Resolve all active TOC items for the current spine item + anchors.
Set<TocEntry> resolveActiveItems(int currentSpineItemIndex) {
final activeItems = <TocEntry>{};
if (currentSpineItemIndex < 0 ||
currentSpineItemIndex >= _spine.length) {
return activeItems;
}
final path = _spine[currentSpineItemIndex].href;
for (final anchor in _activeAnchors) {
final key = '$path#$anchor';
final tocIndex = _hrefToTocIndexMap[key];
if (tocIndex != null && tocIndex < _flatToc.length) {
activeItems.add(_flatToc[tocIndex]);
}
}
if (activeItems.isEmpty &&
_tocItemFallback.isNotEmpty &&
currentSpineItemIndex < _tocItemFallback.length) {
activeItems.add(_tocItemFallback[currentSpineItemIndex]);
}
return activeItems;
}
/// Find the first valid (non-empty path) href in a TOC entry tree.
TocEntry? findFirstValidHref(TocEntry item) {
final parts = item.href.split('#');
if (parts[0].isNotEmpty) {
return item;
}
for (final child in item.children) {
final found = findFirstValidHref(child);
if (found != null) return found;
}
return null;
}
/// Get spine item properties (if any). Mooknote SpineItem has no
/// properties field, so return null.
String? getSpineProperties(int index) => null;
/// Resolve the TOC entry that best represents the current view.
TocEntry? resolveActiveTocEntry(int currentSpineItemIndex) {
if (currentSpineItemIndex < 0 ||
currentSpineItemIndex >= _spine.length) {
return null;
}
final path = _spine[currentSpineItemIndex].href;
for (final anchor in _activeAnchors) {
final key = '$path#$anchor';
final tocIndex = _hrefToTocIndexMap[key];
if (tocIndex != null && tocIndex < _flatToc.length) {
return _flatToc[tocIndex];
}
}
// Fallback
if (_tocItemFallback.isNotEmpty &&
currentSpineItemIndex < _tocItemFallback.length) {
return _tocItemFallback[currentSpineItemIndex];
}
return null;
}
/// Find spine index from a URL string (virtual epub:// or relative path).
int? findSpineIndexByUrl(String url) {
String path;
if (url.startsWith(EpubWebViewHandler.virtualScheme)) {
final uri = Uri.parse(url);
path = uri.pathSegments.skip(2).join('/');
} else {
path = url.split('#')[0];
}
final index = _spine.indexWhere((s) => s.href == path);
return index != -1 ? index : null;
}
// ─── Initial position (from saved state) ──────────────────────────
/// The last-read chapter index stored in the book record.
int get initialChapterIndex {
final cfi = bookData['last_read_cfi'] as String? ?? '';
if (cfi.isEmpty) return 0;
return int.tryParse(cfi) ?? 0;
}
/// No scroll-position column in reader_books yet; return null.
double? get initialScrollPosition => null;
}

View File

@@ -0,0 +1,458 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'reader_style_sheet.dart';
class ControlPanel extends StatefulWidget {
final bool showControls;
final String title;
final int currentSpineItemIndex;
final int totalSpineItems;
final int currentPageInChapter;
final int totalPagesInChapter;
final int direction;
final double fontSize;
final double zoom;
final double marginTop;
final double marginBottom;
final double marginLeft;
final double marginRight;
final VoidCallback onBack;
final VoidCallback onOpenDrawer;
final VoidCallback onPreviousPage;
final VoidCallback onFirstPage;
final VoidCallback onNextPage;
final VoidCallback onLastPage;
final VoidCallback onPreviousChapter;
final VoidCallback onNextChapter;
final VoidCallback onToggleStyleDrawer;
final ValueChanged<double> onZoomChanged;
final ValueChanged<double> onFontSizeChanged;
final ValueChanged<double> onMarginTopChanged;
final ValueChanged<double> onMarginBottomChanged;
final ValueChanged<double> onMarginLeftChanged;
final ValueChanged<double> onMarginRightChanged;
const ControlPanel({
super.key,
required this.showControls,
required this.title,
required this.currentSpineItemIndex,
required this.totalSpineItems,
required this.currentPageInChapter,
required this.totalPagesInChapter,
required this.direction,
required this.fontSize,
required this.zoom,
required this.marginTop,
required this.marginBottom,
required this.marginLeft,
required this.marginRight,
required this.onBack,
required this.onOpenDrawer,
required this.onPreviousPage,
required this.onFirstPage,
required this.onNextPage,
required this.onLastPage,
required this.onPreviousChapter,
required this.onNextChapter,
required this.onToggleStyleDrawer,
required this.onZoomChanged,
required this.onFontSizeChanged,
required this.onMarginTopChanged,
required this.onMarginBottomChanged,
required this.onMarginLeftChanged,
required this.onMarginRightChanged,
});
bool get isVertical => direction == 1;
@override
State<ControlPanel> createState() => _ControlPanelState();
}
class _ControlPanelState extends State<ControlPanel> {
Timer? _longPressTimer;
static const int _animDurationMs = 250;
static const double _topBarHeight = 64.0;
static const double _bottomBarHeight = 48.0 + 16.0 + 16.0;
@override
void dispose() {
_longPressTimer?.cancel();
super.dispose();
}
bool get _shouldHandleOnPreviousChapter {
return widget.currentSpineItemIndex > 0 || widget.currentPageInChapter > 0;
}
bool get _shouldHandleOnNextChapter {
return widget.currentSpineItemIndex < widget.totalSpineItems - 1 ||
(widget.currentSpineItemIndex == widget.totalSpineItems - 1 &&
widget.currentPageInChapter < widget.totalPagesInChapter - 1);
}
bool get _shouldHandleOnLongPressLeft {
if (widget.isVertical) {
return _shouldHandleOnNextChapter;
} else {
return _shouldHandleOnPreviousChapter;
}
}
bool get _shouldHandleOnLongPressRight {
if (widget.isVertical) {
return _shouldHandleOnPreviousChapter;
} else {
return _shouldHandleOnNextChapter;
}
}
bool get _shouldHandleOnPreviousPage {
return widget.currentSpineItemIndex > 0 || widget.currentPageInChapter > 0;
}
bool get _shouldHandleOnNextPage {
return widget.currentSpineItemIndex < widget.totalSpineItems - 1 ||
widget.currentPageInChapter < widget.totalPagesInChapter - 1;
}
bool get _shouldHandleOnPressLeft {
if (widget.isVertical) {
return _shouldHandleOnNextPage;
} else {
return _shouldHandleOnPreviousPage;
}
}
bool get _shouldHandleOnPressRight {
if (widget.isVertical) {
return _shouldHandleOnPreviousPage;
} else {
return _shouldHandleOnNextPage;
}
}
void _handlePreviousChapter() {
if (widget.currentPageInChapter == 0 && widget.currentSpineItemIndex > 0) {
HapticFeedback.selectionClick();
widget.onPreviousChapter();
} else if (widget.currentPageInChapter > 0) {
HapticFeedback.selectionClick();
widget.onFirstPage();
}
}
void _handleNextChapter() {
if (widget.currentSpineItemIndex < widget.totalSpineItems - 1) {
HapticFeedback.selectionClick();
widget.onNextChapter();
} else if (widget.currentSpineItemIndex == widget.totalSpineItems - 1 &&
widget.currentPageInChapter < widget.totalPagesInChapter - 1) {
HapticFeedback.selectionClick();
widget.onLastPage();
}
}
void _handleLongPressLeft() {
if (widget.isVertical) {
_handleNextChapter();
} else {
_handlePreviousChapter();
}
}
void _handleLongPressRight() {
if (widget.isVertical) {
_handlePreviousChapter();
} else {
_handleNextChapter();
}
}
void _handleTapLeft() {
if (widget.isVertical) {
widget.onNextPage();
} else {
widget.onPreviousPage();
}
}
void _handleTapRight() {
if (widget.isVertical) {
widget.onPreviousPage();
} else {
widget.onNextPage();
}
}
String _formatPageIndicator(int current, int total) {
if (total == 0) {
return '0/0';
}
current = current.clamp(1, total);
final totalStr = total.toString();
final currentStr = current.toString();
return '$currentStr/$totalStr';
}
void _openStyleSheet() {
widget.onToggleStyleDrawer();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
elevation: 0,
barrierColor: Colors.black54,
constraints: const BoxConstraints(maxWidth: double.infinity),
builder: (ctx) {
return Container(
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.surfaceContainerLow,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(ctx).height * 0.75,
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
margin: const EdgeInsets.only(top: 24, bottom: 16),
height: 4,
width: 32,
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Flexible(
child: ReaderStyleSheet(
zoom: widget.zoom,
marginTop: widget.marginTop,
marginBottom: widget.marginBottom,
marginLeft: widget.marginLeft,
marginRight: widget.marginRight,
fontSize: widget.fontSize,
onZoomChanged: widget.onZoomChanged,
onFontSizeChanged: widget.onFontSizeChanged,
onMarginTopChanged: widget.onMarginTopChanged,
onMarginBottomChanged: widget.onMarginBottomChanged,
onMarginLeftChanged: widget.onMarginLeftChanged,
onMarginRightChanged: widget.onMarginRightChanged,
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final topStatusBarHeight = MediaQuery.of(context).padding.top;
final bottomStatusBarHeight = MediaQuery.of(context).padding.bottom;
return Stack(
children: [
// Top Bar
AnimatedPositioned(
duration: const Duration(milliseconds: _animDurationMs),
curve: Curves.easeInOut,
top: widget.showControls ? 0 : -(_topBarHeight + topStatusBarHeight),
left: 0,
right: 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: _animDurationMs),
opacity: widget.showControls ? 1.0 : 0.0,
child: Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainer,
),
child: AppBar(
backgroundColor: colorScheme.surfaceContainer,
leading: IconButton(
icon: const Icon(Icons.arrow_back_outlined),
onPressed: widget.onBack,
),
title: Text(
widget.title,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
),
),
),
// Bottom Bar
AnimatedPositioned(
duration: const Duration(milliseconds: _animDurationMs),
curve: Curves.easeInOut,
bottom: widget.showControls
? 0
: -(_bottomBarHeight + bottomStatusBarHeight),
left: 0,
right: 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: _animDurationMs),
opacity: widget.showControls ? 1.0 : 0.0,
child: Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: bottomStatusBarHeight + 16,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainer,
),
constraints: BoxConstraints(
maxHeight: _bottomBarHeight + bottomStatusBarHeight,
minHeight: _bottomBarHeight + bottomStatusBarHeight,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.list_outlined),
onPressed: widget.onOpenDrawer,
color: colorScheme.onSurface,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onLongPressStart: _shouldHandleOnLongPressLeft
? (_) {
_handleLongPressLeft();
_longPressTimer = Timer.periodic(
const Duration(milliseconds: 500),
(timer) {
_handleLongPressLeft();
},
);
}
: null,
onLongPressEnd: (_) {
_longPressTimer?.cancel();
},
onLongPressCancel: () {
_longPressTimer?.cancel();
},
child: IconButton(
icon: const Icon(Icons.chevron_left_outlined),
onPressed:
_shouldHandleOnPressLeft ? _handleTapLeft : null,
onLongPress: null,
disabledColor: Theme.of(context).disabledColor,
color: colorScheme.onSurface,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
Visibility(
visible: false,
maintainSize: true,
maintainAnimation: true,
maintainState: true,
child: Text(
'0' * (2 * 4 + 1),
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
),
),
Text(
_formatPageIndicator(
widget.currentSpineItemIndex + 1,
widget.totalSpineItems,
),
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
),
],
),
if (widget.totalPagesInChapter > 1)
Text(
_formatPageIndicator(
widget.currentPageInChapter + 1,
widget.totalPagesInChapter,
),
style: textTheme.bodyMedium?.copyWith(
fontSize: 10,
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
),
],
),
GestureDetector(
onLongPressStart: _shouldHandleOnLongPressRight
? (_) {
_handleLongPressRight();
_longPressTimer = Timer.periodic(
const Duration(milliseconds: 500),
(timer) {
_handleLongPressRight();
},
);
}
: null,
onLongPressEnd: (_) {
_longPressTimer?.cancel();
},
onLongPressCancel: () {
_longPressTimer?.cancel();
},
child: IconButton(
icon: const Icon(Icons.chevron_right_outlined),
onPressed: _shouldHandleOnPressRight
? _handleTapRight
: null,
onLongPress: null,
disabledColor: Theme.of(context).disabledColor,
color: colorScheme.onSurface,
),
),
],
),
IconButton(
icon: const Icon(Icons.brush_outlined),
onPressed: _openStyleSheet,
color: colorScheme.onSurface,
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,245 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import '../../utils/epub/reader_dao.dart';
import '../../utils/epub/epub_service.dart';
import 'reader_screen.dart';
/// EPUB 书架页面
class EpubLibraryPage extends StatefulWidget {
const EpubLibraryPage({super.key});
@override
State<EpubLibraryPage> createState() => _EpubLibraryPageState();
}
class _EpubLibraryPageState extends State<EpubLibraryPage> {
final ReaderDao _dao = ReaderDao();
final EpubService _service = EpubService();
List<Map<String, dynamic>> _books = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadBooks();
}
Future<void> _loadBooks() async {
setState(() => _isLoading = true);
final books = await _dao.getAllReaderBooks();
if (mounted) {
setState(() {
_books = books;
_isLoading = false;
});
}
}
Future<void> _pickAndImport() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['epub'],
);
if (result == null || result.files.isEmpty) return;
final path = result.files.single.path;
if (path == null) return;
if (!mounted) return;
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
final imported = await _service.importBook(path);
if (mounted) Navigator.pop(context); // 关闭 loading
if (imported != null) {
await _loadBooks();
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('EPUB 解析失败,请检查文件')),
);
}
}
Future<void> _deleteBook(Map<String, dynamic> book) async {
final colors = Theme.of(context).colorScheme;
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除书籍', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Text('确定删除《${book['title']}》?', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.4))),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('删除', style: TextStyle(color: colors.error)),
),
],
),
);
if (confirm == true) {
await _service.deleteBook(book['id']);
await _loadBooks();
}
}
void _openBook(Map<String, dynamic> book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReaderScreen(
bookId: book['id'],
filePath: book['file_path'],
title: book['title'],
coverPath: book['cover_path'],
),
),
).then((_) => _loadBooks()); // 返回时刷新进度
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
backgroundColor: colors.surface,
elevation: 0,
title: Text('EPUB 阅读',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: colors.onSurface)),
leading: IconButton(
icon: Icon(Icons.arrow_back, color: colors.onSurface),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
icon: Icon(Icons.add_outlined, color: colors.onSurface.withValues(alpha: 0.7)),
onPressed: _pickAndImport,
),
const SizedBox(width: 4),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator(color: colors.primary))
: _books.isEmpty
? _buildEmpty(colors)
: _buildGrid(colors),
);
}
Widget _buildEmpty(ColorScheme colors) {
return Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(Icons.auto_stories_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(height: 24),
Text('EPUB 阅读', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 8),
Text('点击右上角导入 .epub 文件', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 32),
GestureDetector(
onTap: _pickAndImport,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(24)),
child: Text('导入 EPUB', style: TextStyle(fontSize: 15, color: colors.onPrimary, fontWeight: FontWeight.w500)),
),
),
],
),
),
);
}
Widget _buildGrid(ColorScheme colors) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.55,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
),
itemCount: _books.length,
itemBuilder: (context, index) {
final book = _books[index];
return _buildBookItem(book, colors);
},
);
}
Widget _buildBookItem(Map<String, dynamic> book, ColorScheme colors) {
final coverPath = book['cover_path'] as String?;
final title = book['title'] as String? ?? '';
final author = book['author'] as String? ?? '';
final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0;
return GestureDetector(
onTap: () => _openBook(book),
onLongPress: () => _deleteBook(book),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync()
? Image.file(File(coverPath), fit: BoxFit.cover)
: Icon(Icons.auto_stories_outlined, size: 36, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
const SizedBox(height: 8),
// 标题
Text(title, maxLines: 2, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface)),
if (author.isNotEmpty) ...[
const SizedBox(height: 2),
Text(author, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
],
const SizedBox(height: 4),
// 进度条
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: colors.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation(colors.primary.withValues(alpha: 0.5)),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,216 @@
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
class FootnotePopupOverlay extends StatefulWidget {
final Rect anchorRect;
final String rawHtml;
final VoidCallback onDismiss;
final ColorScheme colorScheme;
final double zoom;
const FootnotePopupOverlay({
super.key,
required this.anchorRect,
required this.rawHtml,
required this.onDismiss,
required this.colorScheme,
this.zoom = 1.0,
});
@override
State<FootnotePopupOverlay> createState() => FootnotePopupOverlayState();
}
class FootnotePopupOverlayState extends State<FootnotePopupOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<Offset> _slideAnimation;
late bool _slideFromLeft;
Future<void> playReverseAnimation() async {
if (mounted) {
await _animationController.reverse();
}
}
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_slideFromLeft = true;
if (!_animationController.isAnimating &&
!_animationController.isCompleted) {
_animationController.forward();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final screenWidth = MediaQuery.of(context).size.width;
_slideFromLeft = widget.anchorRect.center.dx < (screenWidth / 2);
_slideAnimation =
Tween<Offset>(
begin: Offset(_slideFromLeft ? -1.0 : 1.0, 0.0),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final safePadding = MediaQuery.of(context).padding;
const double minBookmarkWidth = 150;
final double maxBookmarkWidth = screenSize.width * 0.8;
final spaceBelow =
screenSize.height -
widget.anchorRect.bottom -
max(safePadding.bottom, 32);
final spaceAbove = widget.anchorRect.top - safePadding.top;
final bool showBelow = spaceBelow >= spaceAbove;
final double calculatedMaxHeight = showBelow
? (spaceBelow - safePadding.bottom - 12.0)
: (spaceAbove - safePadding.top - 12.0);
final double maxBookmarkHeight = calculatedMaxHeight.clamp(
100.0,
screenSize.height * 0.4,
);
final double topPosition = showBelow ? widget.anchorRect.bottom + 6.0 : -1;
final double bottomPosition = !showBelow
? (screenSize.height - widget.anchorRect.top) + 6.0
: -1;
final borderRadius = BorderRadius.horizontal(
left: _slideFromLeft ? Radius.zero : const Radius.circular(4),
right: _slideFromLeft ? const Radius.circular(4) : Radius.zero,
);
final isDark = Theme.of(context).brightness == Brightness.dark;
// Strip HTML tags for simple text display
final plainText = widget.rawHtml
.replaceAll(RegExp(r'<[^>]*>'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: widget.onDismiss,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.transparent),
),
),
Positioned(
top: topPosition != -1 ? topPosition : null,
bottom: bottomPosition != -1 ? bottomPosition : null,
left: _slideFromLeft ? 0 : null,
right: !_slideFromLeft ? 0 : null,
child: Material(
color: Colors.transparent,
child: SlideTransition(
position: _slideAnimation,
child: Container(
decoration: BoxDecoration(
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: widget.colorScheme.shadow.withAlpha(
isDark ? 50 : 25,
),
blurRadius: 16,
offset: Offset(_slideFromLeft ? 4 : -4, 6),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
child: Container(
constraints: BoxConstraints(
maxHeight: maxBookmarkHeight,
maxWidth: maxBookmarkWidth,
minWidth: minBookmarkWidth,
),
decoration: BoxDecoration(
color: widget.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.75),
border: Border(
left: !_slideFromLeft
? BorderSide(
color: widget.colorScheme.primary,
width: 4,
)
: BorderSide.none,
right: _slideFromLeft
? BorderSide(
color: widget.colorScheme.primary,
width: 4,
)
: BorderSide.none,
top: BorderSide(
color: widget.colorScheme.outlineVariant,
width: 1,
),
bottom: BorderSide(
color: widget.colorScheme.outlineVariant,
width: 1,
),
),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Text(
plainText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: widget.colorScheme.onSurface,
height: 1.6,
fontSize:
(Theme.of(context)
.textTheme
.bodyMedium
?.fontSize ??
14.0) *
widget.zoom,
),
),
),
),
),
),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class ImageViewer extends StatefulWidget {
final Uint8List imageData;
final VoidCallback onClose;
final Rect sourceRect;
final ColorScheme colorScheme;
const ImageViewer({
super.key,
required this.imageData,
required this.onClose,
required this.sourceRect,
required this.colorScheme,
});
@override
State<ImageViewer> createState() => _ImageViewerState();
}
class _ImageViewerState extends State<ImageViewer>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _curve;
double? _imageAspectRatio;
bool _isLoading = true;
bool _isClosing = false;
final TransformationController _transformController =
TransformationController();
Rect? _dynamicCloseRect;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_curve = CurvedAnimation(parent: _controller, curve: Curves.easeOutQuart);
_resolveImage();
}
@override
void dispose() {
_controller.dispose();
_transformController.dispose();
super.dispose();
}
void _resolveImage() {
final imageProvider = MemoryImage(widget.imageData);
final imageStream = imageProvider.resolve(const ImageConfiguration());
late ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool synchronousCall) {
if (mounted) {
setState(() {
_imageAspectRatio = info.image.width / info.image.height;
_isLoading = false;
});
_triggerAnimation();
}
imageStream.removeListener(listener);
},
onError: (dynamic error, StackTrace? stackTrace) {
debugPrint('Error resolving image info: $error');
imageStream.removeListener(listener);
_handleLoadError();
},
);
imageStream.addListener(listener);
}
void _triggerAnimation() {
HapticFeedback.lightImpact();
Future.delayed(const Duration(milliseconds: 10), () {
if (mounted) {
_controller.forward();
}
});
}
Future<void> _handleClose() async {
if (_isClosing) return;
final Size screenSize = MediaQuery.of(context).size;
final Matrix4 matrix = _transformController.value;
final double scale = matrix.getMaxScaleOnAxis();
final translation = matrix.getTranslation();
setState(() {
_isClosing = true;
_dynamicCloseRect = Rect.fromLTWH(
translation.x,
translation.y,
screenSize.width * scale,
screenSize.height * scale,
);
_transformController.value = Matrix4.identity();
});
await _controller.reverse();
if (mounted) {
widget.onClose();
}
}
void _handleLoadError() {
HapticFeedback.lightImpact();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load image')),
);
_handleClose();
}
}
@override
Widget build(BuildContext context) {
final Size screenSize = MediaQuery.of(context).size;
final Rect fullscreenRect = Rect.fromLTWH(
0,
0,
screenSize.width,
screenSize.height,
);
final Rect targetEndRect = _dynamicCloseRect ?? fullscreenRect;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
await _handleClose();
},
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final double t = _curve.value;
final Rect currentRect = Rect.lerp(
widget.sourceRect,
targetEndRect,
t,
)!;
final bool isExpanded = t == 1.0;
final bool canZoom = isExpanded && !_isLoading;
final double bgOpacity = 0.9 * t;
return Stack(
children: [
GestureDetector(
onTap: _handleClose,
child: Container(
color: widget.colorScheme.scrim.withValues(alpha: bgOpacity),
),
),
Positioned.fromRect(
rect: currentRect,
child: GestureDetector(
onTap: _handleClose,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: canZoom
? _buildInteractiveViewer()
: Opacity(opacity: t, child: _buildStaticImage()),
),
),
),
],
);
},
),
);
}
Widget _buildImageView(double t) {
if (_imageAspectRatio == null) {
return const SizedBox();
}
final curve = Curves.easeOutQuart.transform(t);
final backgroundColor = Colors.white.withValues(alpha: curve);
return Center(
child: AspectRatio(
aspectRatio: _imageAspectRatio!,
child: Container(
color: backgroundColor,
child: Image.memory(
widget.imageData,
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
),
),
),
);
}
Widget _buildInteractiveViewer() {
return InteractiveViewer(
transformationController: _transformController,
minScale: 0.5,
maxScale: 4.0,
child: Center(child: _buildImageView(_controller.value)),
);
}
Widget _buildStaticImage() {
if (_isLoading) {
return const SizedBox();
}
return SizedBox.expand(child: _buildImageView(_controller.value));
}
}

View File

@@ -0,0 +1,57 @@
part of '../reader_screen.dart';
mixin _FootnoteMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
OverlayEntry? get footnoteOverlayEntry;
set footnoteOverlayEntry(OverlayEntry? v);
GlobalKey<FootnotePopupOverlayState> get footnoteKey;
bool get isClosingFootnote;
set isClosingFootnote(bool v);
EpubWebViewHandler get webViewHandler;
BookSession get bookSession;
ReaderSettings get readerSettings;
// === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme();
void handleFootnoteTap(String innerHtml, Rect rect, String baseUrl) {
removeFootnoteOverlay();
final overlayState = Overlay.of(context);
final colorScheme = Theme.of(context).colorScheme;
setState(() {
footnoteOverlayEntry = OverlayEntry(
builder: (context) => FootnotePopupOverlay(
key: footnoteKey,
anchorRect: rect,
rawHtml: innerHtml,
onDismiss: () => removeFootnoteOverlay(),
colorScheme: colorScheme,
zoom: readerSettings.zoom,
),
);
});
overlayState.insert(footnoteOverlayEntry!);
}
Future<void> removeFootnoteOverlay({bool animate = true}) async {
if (footnoteOverlayEntry == null || isClosingFootnote) return;
if (animate) {
isClosingFootnote = true;
if (footnoteKey.currentState != null) {
await footnoteKey.currentState!.playReverseAnimation();
}
}
footnoteOverlayEntry?.remove();
setState(() {
footnoteOverlayEntry = null;
isClosingFootnote = false;
});
}
}

View File

@@ -0,0 +1,47 @@
part of '../reader_screen.dart';
mixin _ImageViewerMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
BookSession get bookSession;
bool get showControls;
bool get isImageViewerVisible;
set isImageViewerVisible(bool v);
Uint8List? get currentImageData;
set currentImageData(Uint8List? v);
Rect? get currentImageRect;
set currentImageRect(Rect? v);
Future<void> handleImageLongPress(String imageUrl, Rect rect) async {
if (!bookSession.isLoaded) return;
if (showControls) return;
// Resolve image bytes from the epub via webViewHandler
final data = await webViewHandler.resolveImageFromEpub(
epubPath: bookSession.book['file_path'] as String? ?? '',
imageUrl: imageUrl,
fileHash: widget.bookId,
);
if (data == null || !mounted) return;
setState(() {
currentImageData = data;
currentImageRect = rect;
isImageViewerVisible = true;
});
}
void closeImageViewer() {
setState(() {
isImageViewerVisible = false;
currentImageData = null;
currentImageRect = null;
});
}
// Cross-reference: webViewHandler is defined in _ReaderScreenState
EpubWebViewHandler get webViewHandler;
}

View File

@@ -0,0 +1,82 @@
part of '../reader_screen.dart';
mixin _LinkHandlingMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
BookSession get bookSession;
ReaderSettings get readerSettings;
// === Cross-mixin: _SpineNavigationMixin ===
Future<void> loadCarousel({
String anchor = 'top',
int? overrideSpineIndex,
double? restoreScrollRatio,
});
// === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme();
bool shouldHandleLinkTap(String url) {
if (url.startsWith('epub://')) {
final index = bookSession.findSpineIndexByUrl(url);
return index != null;
} else {
return readerSettings.linkHandling != ReaderLinkHandling.never;
}
}
Future<void> handleLinkTap(String url) async {
if (url.startsWith('epub://')) {
final index = bookSession.findSpineIndexByUrl(url);
if (index != null) {
String anchor = 'top';
if (url.contains('#')) {
anchor = url.split('#').last;
}
await loadCarousel(anchor: anchor, overrideSpineIndex: index);
}
} else {
final linkHandling = readerSettings.linkHandling;
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
if (linkHandling == ReaderLinkHandling.always) {
await launchUrl(uri);
} else if (linkHandling == ReaderLinkHandling.ask) {
if (mounted && context.mounted) {
final shouldOpen =
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('打开外部链接'),
content: Text('是否打开链接: $url'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('打开'),
),
],
),
) ??
false;
if (shouldOpen) {
await launchUrl(uri);
}
}
}
// ReaderLinkHandling.never: do nothing
} else {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('无法打开链接: $url')),
);
}
}
}
}
}

View File

@@ -0,0 +1,88 @@
part of '../reader_screen.dart';
mixin _PageNavigationMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
int get currentPageInChapter;
set currentPageInChapter(int v);
int get totalPagesInChapter;
set totalPagesInChapter(int v);
int get currentSpineItemIndex;
BookSession get bookSession;
ReaderRendererController get rendererController;
// === Cross-mixin: _SpineNavigationMixin ===
Future<void> nextSpineItem();
Future<void> previousSpineItem();
// === Cross-mixin: _ProgressMixin ===
void updateProgressDebounced();
void saveProgress();
// === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme();
bool canPerformPageTurn(bool isNext) {
if (isNext) {
if (currentPageInChapter >= totalPagesInChapter - 1 &&
currentSpineItemIndex >= bookSession.spine.length - 1) {
_showToast('已经是最后一页');
return false;
}
} else {
if (currentPageInChapter <= 0 && currentSpineItemIndex <= 0) {
_showToast('已经是第一页');
return false;
}
}
return true;
}
Future<void> handlePageTurn(bool isNext) async {
if (isNext) {
await nextPage();
} else {
await previousPage();
}
}
Future<void> goToPage(int pageIndex) async {
if (pageIndex < 0 || pageIndex >= totalPagesInChapter) return;
setState(() {
currentPageInChapter = pageIndex;
});
updateProgressDebounced();
await rendererController.jumpToPage(pageIndex);
saveProgress();
}
Future<void> nextPage() async {
if (currentPageInChapter < totalPagesInChapter - 1) {
await goToPage(currentPageInChapter + 1);
} else {
await nextSpineItem();
}
saveProgress();
}
Future<void> previousPage() async {
if (currentPageInChapter > 0) {
await goToPage(currentPageInChapter - 1);
} else {
await previousSpineItem();
}
saveProgress();
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
);
}
}

View File

@@ -0,0 +1,45 @@
part of '../reader_screen.dart';
mixin _ProgressMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
int get totalPagesInChapter;
int get currentPageInChapter;
int get currentSpineItemIndex;
BookSession get bookSession;
bool get isWebViewLoading;
String get displayProgress;
set displayProgress(String v);
Timer? get progressDebouncer;
set progressDebouncer(Timer? v);
void updateProgressDebounced() {
progressDebouncer?.cancel();
progressDebouncer = Timer(const Duration(milliseconds: 150), () {
if (!mounted) return;
if (isWebViewLoading) return;
final pageInChapterStr =
'${currentPageInChapter + 1}/$totalPagesInChapter';
if (displayProgress != pageInChapterStr) {
setState(() {
displayProgress = pageInChapterStr;
});
}
});
}
void saveProgress() {
bookSession.saveProgress(
currentChapterIndex: currentSpineItemIndex,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
);
}
}

View File

@@ -0,0 +1,239 @@
part of '../reader_screen.dart';
mixin _SpineNavigationMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
BookSession get bookSession;
ReaderRendererController get rendererController;
bool get isWebViewLoading;
set isWebViewLoading(bool v);
int get currentSpineItemIndex;
set currentSpineItemIndex(int v);
int get currentPageInChapter;
set currentPageInChapter(int v);
// === Cross-mixin: _ProgressMixin ===
void updateProgressDebounced();
void saveProgress();
// === Cross-mixin: _ThemeMixin ===
EpubTheme getEpubTheme();
List<String> getAnchorsForSpine(String spinePath) {
return bookSession.getAnchorsForSpine(spinePath);
}
void handleScrollAnchors(List<String> anchorIds) {
setState(() {
bookSession.updateActiveAnchors(anchorIds);
});
}
String getSpineItemUrl(int index, [String anchor = 'top']) {
return bookSession.getSpineItemUrl(index, anchor);
}
String? getSpineProperties(int index) {
return bookSession.getSpineProperties(index);
}
Future<void> loadCarousel({
String anchor = 'top',
int? overrideSpineIndex,
double? restoreScrollRatio,
}) async {
if (bookSession.spine.isEmpty) return;
if (mounted) {
setState(() {
isWebViewLoading = true;
});
}
if (overrideSpineIndex != null &&
overrideSpineIndex >= 0 &&
overrideSpineIndex < bookSession.spine.length) {
currentSpineItemIndex = overrideSpineIndex;
}
final currIndex = currentSpineItemIndex;
final prevIndex = currIndex > 0 ? currIndex - 1 : null;
final nextIndex = currIndex < bookSession.spine.length - 1
? currIndex + 1
: null;
final tokensForWait = <int>[];
final currUrl = getSpineItemUrl(currIndex, anchor);
final currentSpinePath = bookSession.spine[currIndex].href;
final currToken = await rendererController.preloadCurrentChapter(
currUrl,
getAnchorsForSpine(currentSpinePath),
getSpineProperties(currIndex),
);
if (currToken != null) tokensForWait.add(currToken);
if (prevIndex != null) {
final prevUrl = getSpineItemUrl(prevIndex);
final prevSpinePath = bookSession.spine[prevIndex].href;
final prevToken = await rendererController.preloadPreviousChapter(
prevUrl,
getAnchorsForSpine(prevSpinePath),
getSpineProperties(prevIndex),
);
if (prevToken != null) tokensForWait.add(prevToken);
}
if (nextIndex != null) {
final nextUrl = getSpineItemUrl(nextIndex);
final nextSpinePath = bookSession.spine[nextIndex].href;
final nextToken = await rendererController.preloadNextChapter(
nextUrl,
getAnchorsForSpine(nextSpinePath),
getSpineProperties(nextIndex),
);
if (nextToken != null) tokensForWait.add(nextToken);
}
await rendererController.waitForEvents(tokensForWait);
if (restoreScrollRatio != null) {
await rendererController.restoreScrollPosition(restoreScrollRatio);
}
await Future.delayed(const Duration(milliseconds: 30));
setState(() {
isWebViewLoading = false;
});
}
Future<void> preloadNextOf(int currentIndex) async {
final nextIndex = currentIndex + 1;
if (nextIndex < bookSession.spine.length) {
final url = getSpineItemUrl(nextIndex);
final nextSpinePath = bookSession.spine[nextIndex].href;
await rendererController.preloadNextChapter(
url,
getAnchorsForSpine(nextSpinePath),
getSpineProperties(nextIndex),
);
}
}
Future<void> preloadPreviousOf(int currentIndex) async {
final prevIndex = currentIndex - 1;
if (prevIndex >= 0) {
final url = getSpineItemUrl(prevIndex);
final prevSpinePath = bookSession.spine[prevIndex].href;
await rendererController.preloadPreviousChapter(
url,
getAnchorsForSpine(prevSpinePath),
getSpineProperties(prevIndex),
);
}
}
Future<void> navigateToSpineItem(int index, [String anchor = 'top']) async {
if (index < 0 || index >= bookSession.spine.length) return;
setState(() {
currentSpineItemIndex = index;
currentPageInChapter = 0;
});
updateProgressDebounced();
await loadCarousel(anchor: anchor);
saveProgress();
}
Future<void> previousSpineItem() async {
if (currentSpineItemIndex <= 0) {
_showToast('已经是第一章');
return;
}
await rendererController.jumpToPreviousChapterLastPage();
setState(() {
currentSpineItemIndex--;
});
preloadPreviousOf(currentSpineItemIndex);
saveProgress();
}
Future<void> previousSpineItemFirstPage() async {
if (currentSpineItemIndex <= 0) {
_showToast('已经是第一章');
return;
}
await rendererController.jumpToPreviousChapterFirstPage();
setState(() {
currentSpineItemIndex--;
currentPageInChapter = 0;
});
updateProgressDebounced();
preloadPreviousOf(currentSpineItemIndex);
saveProgress();
}
Future<void> nextSpineItem() async {
if (currentSpineItemIndex >= bookSession.spine.length - 1) {
_showToast('已经是最后一章');
return;
}
await rendererController.jumpToNextChapter();
setState(() {
currentSpineItemIndex++;
currentPageInChapter = 0;
});
updateProgressDebounced();
preloadNextOf(currentSpineItemIndex);
saveProgress();
}
Future<void> navigateToTocItem(TocEntry item) async {
final targetHref = bookSession.findFirstValidHref(item);
if (targetHref == null) {
_showToast('该章节无内容');
return;
}
final index = bookSession.findSpineIndexForTocItem(item);
if (index != null) {
final anchor = targetHref.href.contains('#')
? targetHref.href.split('#').last
: 'top';
await navigateToSpineItem(index, anchor);
} else {
_showToast('目录章节未找到');
debugPrint(
'Warning: Chapter with href ${targetHref.href} not found in spine.',
);
}
}
Future<void> navigateToFirstTocItemFirstPage() async {
navigateToSpineItem(0, 'top');
}
Set<TocEntry> resolveActiveItems() {
return bookSession.resolveActiveItems(currentSpineItemIndex);
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 1)),
);
}
}

View File

@@ -0,0 +1,46 @@
part of '../reader_screen.dart';
mixin _ThemeMixin on State<ReaderScreen> {
// === Borrowed state (provided by _ReaderScreenState fields) ===
ReaderRendererController get rendererController;
ReaderSettings get readerSettings;
ThemeData? get currentTheme;
set currentTheme(ThemeData? v);
bool get updatingTheme;
set updatingTheme(bool v);
Timer? get themeUpdateDebouncer;
set themeUpdateDebouncer(Timer? v);
EpubTheme getEpubTheme() {
return readerSettings.toEpubTheme(context);
}
void updateWebViewThemeWithDebounce() {
themeUpdateDebouncer?.cancel();
themeUpdateDebouncer = Timer(const Duration(milliseconds: 50), () {
updateWebViewTheme();
});
}
Future<void> updateWebViewTheme() async {
final newTheme = getEpubTheme();
final currentWebViewTheme = rendererController.currentTheme;
if (currentWebViewTheme != null && currentWebViewTheme == newTheme) {
return;
}
setState(() {
updatingTheme = true;
});
await rendererController.updateTheme(getEpubTheme());
setState(() {
updatingTheme = false;
});
}
}

View File

@@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../reader_webview.dart';
class AndroidPageTurnSession {
late final AnimationController _animController;
late Animation<Offset> _slideAnimation;
ui.Image? _screenshotData;
bool _isAnimating = false;
bool _isForwardAnimation = true;
int _pageTurnToken = 0;
AndroidPageTurnSession({
required TickerProvider vsync,
required Duration duration,
}) {
_animController = AnimationController(vsync: vsync, duration: duration);
_slideAnimation = Tween<Offset>(
begin: Offset.zero,
end: Offset.zero,
).animate(_animController);
}
void dispose() {
_screenshotData?.dispose();
_animController.dispose();
}
void _setupTween(bool isNext, bool isVertical) {
Tween<Offset> tween;
if (isNext) {
tween = Tween<Offset>(
begin: Offset.zero,
end: Offset(isVertical ? 1.0 : -1.0, 0.0),
);
} else {
tween = Tween<Offset>(
begin: Offset(isVertical ? 1.0 : -1.0, 0.0),
end: Offset.zero,
);
}
_slideAnimation = CurvedAnimation(
parent: _animController,
curve: Curves.easeOut,
).drive(tween);
}
Future<ui.Image?> _takeScreenshot(
ReaderWebViewController webViewController,
) async {
ui.Image? screenshot;
try {
screenshot = await webViewController.takeScreenshot();
} catch (e) {
debugPrint('Error taking screenshot: $e');
screenshot = null;
}
return screenshot;
}
Future<void> perform({
required ReaderWebViewController webViewController,
required bool needAnimation,
required bool isNext,
required bool isVertical,
required Future<void> Function(bool) onPerformPageTurn,
required void Function(VoidCallback) setState,
required bool Function() isMounted,
}) async {
if (!needAnimation) {
await onPerformPageTurn(isNext);
return;
}
final int turnToken = ++_pageTurnToken;
final screenshot = await _takeScreenshot(webViewController);
// If screenshot fails, just perform the page turn without animation
if (screenshot == null) {
_screenshotData?.dispose();
_screenshotData = null;
_animController.reset();
await onPerformPageTurn(isNext);
return;
}
// If the widget has been unmounted or a new page turn has started, dispose the screenshot and exit
if (!isMounted() || turnToken != _pageTurnToken) {
screenshot.dispose();
return;
}
setState(() {
_isForwardAnimation = isNext;
_screenshotData?.dispose();
_screenshotData = screenshot;
_isAnimating = true;
_setupTween(isNext, isVertical);
_animController.reset();
});
// Perform the page turn in parallel with the animation
await onPerformPageTurn(isNext);
// Start the animation
if (!isMounted() || turnToken != _pageTurnToken) {
return;
}
try {
await _animController.forward();
} finally {
if (turnToken == _pageTurnToken) {
final finishedScreenshot = _screenshotData;
if (isMounted()) {
setState(() {
_screenshotData = null;
_isAnimating = false;
});
} else {
_screenshotData = null;
_isAnimating = false;
}
finishedScreenshot?.dispose();
_animController.reset();
_isAnimating = false;
}
}
}
Widget buildAnimatedContainer(
BuildContext context,
Widget child,
Widget Function(ui.Image?) buildScreenshotContainer,
) {
return Stack(
children: [
// Backward animation: show the current page as the background
if (_isAnimating && !_isForwardAnimation)
Positioned.fill(child: buildScreenshotContainer(_screenshotData)),
// Webview page: slide it in from the left for backward animation, or keep it static for forward animation
Positioned.fill(
child: SlideTransition(
position: _isAnimating && !_isForwardAnimation
? _slideAnimation
: const AlwaysStoppedAnimation(Offset.zero),
child: child,
),
),
// Forward animation: slide the current page out to the right
if (_isAnimating && _isForwardAnimation)
Positioned.fill(
child: SlideTransition(
position: _slideAnimation,
child: buildScreenshotContainer(_screenshotData),
),
),
],
);
}
bool get isAnimating => _isAnimating;
}

View File

@@ -0,0 +1,67 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class IOSPageTurnSession {
static const MethodChannel _nativePageTurnChannel = MethodChannel(
'mooknote/reader_page_turn',
);
int _currentToken = 0;
bool _isAnimating = false;
Future<void> _prepareIOSPageTurn() async {
if (!Platform.isIOS) return;
try {
await _nativePageTurnChannel.invokeMethod<void>('preparePageTurn');
} on MissingPluginException {
// no-op for configurations without iOS native channel
} catch (e) {
debugPrint('preparePageTurn failed: $e');
}
}
Future<void> _animateIOSPageTurn(bool isNext, bool isVertical) async {
if (!Platform.isIOS) return;
try {
final token = ++_currentToken;
_isAnimating = true;
await _nativePageTurnChannel.invokeMethod<void>('animatePageTurn', {
'isNext': isNext,
'isVertical': isVertical,
});
if (token == _currentToken) {
_isAnimating = false;
return;
}
} on MissingPluginException {
// no-op for configurations without iOS native channel
} catch (e) {
debugPrint('animatePageTurn failed: $e');
}
}
Future<void> perform({
required bool needAnimation,
required bool isNext,
required bool isVertical,
required Future<void> Function(bool) onPerformPageTurn,
}) async {
if (!needAnimation) {
await onPerformPageTurn(isNext);
return;
}
await _prepareIOSPageTurn();
await onPerformPageTurn(isNext);
unawaited(_animateIOSPageTurn(isNext, isVertical));
}
Widget buildAnimatedContainer(BuildContext context, Widget child) {
return child;
}
bool get isAnimating => _isAnimating;
}

View File

@@ -0,0 +1,2 @@
export 'android_page_turn_session.dart';
export 'ios_page_turn_session.dart';

View File

@@ -0,0 +1,512 @@
import 'dart:io';
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../utils/epub/epub_theme.dart';
import '../../utils/epub/reader_settings.dart';
import '../../utils/epub/epub_webview_handler.dart';
import 'book_session.dart';
import 'reader_webview.dart';
import 'page_turn/page_turn.dart';
class ReaderRendererController {
_ReaderRendererState? _rendererState;
bool get isAttached => _rendererState != null;
EpubTheme? get currentTheme => _rendererState?._currentTheme;
ReaderWebViewController? get webViewController =>
_rendererState?._webViewController;
void _attachState(_ReaderRendererState? state) {
_rendererState = state;
}
Future<void> performPreviousPageTurn() async {
await webViewController?.waitForRender();
await _rendererState?._performPageTurn(false);
}
Future<void> performNextPageTurn() async {
await webViewController?.waitForRender();
await _rendererState?._performPageTurn(true);
}
Future<void> jumpToPage(int pageIndex) async {
await webViewController?.jumpToPage(pageIndex);
}
Future<void> restoreScrollPosition(double ratio) async {
await webViewController?.restoreScrollPosition(ratio);
}
Future<void> jumpToPreviousChapterLastPage() async {
final token1 = await webViewController?.jumpToLastPageOfFrame('prev');
final token2 = await webViewController?.cycleFrames('prev');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<void> jumpToPreviousChapterFirstPage() async {
final token1 = await webViewController?.jumpToPageFor('prev', 0);
final token2 = await webViewController?.cycleFrames('prev');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<void> jumpToNextChapter() async {
final token1 = await webViewController?.jumpToPageFor('next', 0);
final token2 = await webViewController?.cycleFrames('next');
final tokens = [token1, token2].whereType<int>().toList();
await webViewController?.waitForEvents(tokens);
}
Future<int?> preloadCurrentChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'curr',
url,
anchorsJson,
propertiesJson,
);
}
Future<int?> preloadNextChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'next',
url,
anchorsJson,
propertiesJson,
);
}
Future<int?> preloadPreviousChapter(
String url,
List<String> anchors,
String? properties,
) async {
final anchorsParam = anchors.map((a) => '"$a"').join(',');
final anchorsJson = '[$anchorsParam]';
final propertiesList = List<String>.from(properties?.split(' ') ?? []);
final encodedPropertiesList = propertiesList
.map((p) => p.replaceAll(':', '-COLON-'))
.toList();
final propertiesParam = encodedPropertiesList.map((p) => '"$p"').join(',');
final propertiesJson = '[$propertiesParam]';
return await webViewController?.loadFrame(
'prev',
url,
anchorsJson,
propertiesJson,
);
}
Future<void> updateTheme(EpubTheme theme) async {
await _rendererState?._updateTheme(theme);
}
Future<void> waitForEvents(List<int> tokens) async {
await webViewController?.waitForEvents(tokens);
}
Future<void> waitForEvent(int token) async {
await webViewController?.waitForEvent(token);
}
}
class ReaderRenderer extends StatefulWidget {
final ReaderRendererController controller;
final BookSession bookSession;
final EpubWebViewHandler webViewHandler;
final String fileHash;
final bool showControls;
final bool isLoading;
final bool Function(bool isNext) canPerformPageTurn;
final Future<void> Function(bool isNext) onPerformPageTurn;
final VoidCallback onToggleControls;
final Future<void> Function() onInitialized;
final Future<void> Function(int totalPages) onPageCountReady;
final ValueChanged<int> onPageChanged;
final ValueChanged<List<String>> onScrollAnchors;
final Function(String imageUrl, Rect rect) onImageLongPress;
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap;
final bool shouldShowWebView;
final EpubTheme initializeTheme;
final String statusBarLeftContent;
final String statusBarRightContent;
/// Controls page-turn animation style. Caller reads from ReaderSettings
/// and passes the value here (avoids Riverpod dependency).
final ReaderPageAnimation pageAnimation;
const ReaderRenderer({
super.key,
required this.controller,
required this.bookSession,
required this.webViewHandler,
required this.fileHash,
required this.showControls,
required this.isLoading,
required this.canPerformPageTurn,
required this.onPerformPageTurn,
required this.onToggleControls,
required this.onInitialized,
required this.onPageCountReady,
required this.onPageChanged,
required this.onScrollAnchors,
required this.onImageLongPress,
required this.onFootnoteTap,
required this.onLinkTap,
required this.shouldHandleLinkTap,
required this.shouldShowWebView,
required this.initializeTheme,
required this.statusBarLeftContent,
required this.statusBarRightContent,
this.pageAnimation = ReaderPageAnimation.slide,
});
bool get isVertical {
return bookSession.direction == 1;
}
@override
State<ReaderRenderer> createState() => _ReaderRendererState();
}
class _ReaderRendererState extends State<ReaderRenderer>
with TickerProviderStateMixin {
final GlobalKey _webViewKey = GlobalKey();
final ReaderWebViewController _webViewController = ReaderWebViewController();
late final AndroidPageTurnSession _androidPageTurnSession;
late final IOSPageTurnSession _iosPageTurnSession;
late EpubTheme _currentTheme;
late bool _needPageTurnAnimation;
EdgeInsets _addSafeAreaToPadding(EdgeInsets basePadding) {
final safePaddings = MediaQuery.paddingOf(context);
final safeBottomPadding = max(safePaddings.bottom, 32);
return EdgeInsets.fromLTRB(
basePadding.left + safePaddings.left,
basePadding.top + safePaddings.top,
basePadding.right + safePaddings.right,
basePadding.bottom + safeBottomPadding,
);
}
EpubTheme _addSafeAreaToThemePadding(EpubTheme theme) {
final newPadding = _addSafeAreaToPadding(theme.padding);
return theme.copyWith(padding: newPadding);
}
Future<void> _updateTheme(EpubTheme theme) async {
_currentTheme = theme;
await _webViewController.updateTheme(
theme.copyWith(padding: _addSafeAreaToPadding(theme.padding)),
);
}
@override
void initState() {
super.initState();
widget.controller._attachState(this);
_androidPageTurnSession = AndroidPageTurnSession(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_iosPageTurnSession = IOSPageTurnSession();
_currentTheme = widget.initializeTheme;
_needPageTurnAnimation =
widget.pageAnimation != ReaderPageAnimation.none;
}
@override
void didUpdateWidget(covariant ReaderRenderer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.pageAnimation != widget.pageAnimation) {
setState(() {
_needPageTurnAnimation =
widget.pageAnimation != ReaderPageAnimation.none;
});
}
}
@override
void dispose() {
widget.controller._attachState(null);
_androidPageTurnSession.dispose();
super.dispose();
}
Future<void> _performPageTurn(bool isNext) async {
if (!widget.canPerformPageTurn(isNext)) return;
if (Platform.isAndroid) {
await _androidPageTurnSession.perform(
webViewController: _webViewController,
needAnimation: _needPageTurnAnimation,
isNext: isNext,
isVertical: widget.isVertical,
onPerformPageTurn: widget.onPerformPageTurn,
setState: setState,
isMounted: () => mounted,
);
} else {
await _iosPageTurnSession.perform(
needAnimation: _needPageTurnAnimation,
isNext: isNext,
isVertical: widget.isVertical,
onPerformPageTurn: widget.onPerformPageTurn,
);
}
}
void _handleTap(TapUpDetails details) {
if (widget.showControls) {
widget.onToggleControls();
} else if (_androidPageTurnSession.isAnimating ||
_iosPageTurnSession.isAnimating) {
_handleTapZone(details.globalPosition.dx, details.globalPosition.dy);
} else {
_webViewController.checkTapElementAt(
details.globalPosition.dx,
details.globalPosition.dy,
);
}
}
void _handleTapZone(double x, double y) {
final width = MediaQuery.of(context).size.width;
if (width <= 0) return;
final ratio = x / width;
if (ratio < 0.3) {
if (widget.showControls) {
widget.onToggleControls();
return;
}
if (widget.isVertical) {
_performPageTurn(true);
} else {
_performPageTurn(false);
}
} else if (ratio > 0.7) {
if (widget.showControls) {
widget.onToggleControls();
return;
}
if (widget.isVertical) {
_performPageTurn(false);
} else {
_performPageTurn(true);
}
} else {
widget.onToggleControls();
}
}
Future<void> _handleHorizontalDragEnd(DragEndDetails details) async {
if (widget.showControls) {
return;
}
final velocity = details.primaryVelocity ?? 0;
if (velocity < -200) {
if (widget.isVertical) {
await _performPageTurn(false);
} else {
await _performPageTurn(true);
}
} else if (velocity > 200) {
if (widget.isVertical) {
await _performPageTurn(true);
} else {
await _performPageTurn(false);
}
}
}
Future<void> _handleLongPressStart(LongPressStartDetails details) async {
await _webViewController.checkLongPressElementAt(
details.localPosition.dx,
details.localPosition.dy,
);
}
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: widget.shouldShowWebView ? _handleTap : null,
onHorizontalDragEnd: widget.shouldShowWebView
? _handleHorizontalDragEnd
: null,
onLongPressStart: widget.shouldShowWebView
? _handleLongPressStart
: null,
child: Stack(
fit: StackFit.expand,
children: [_buildBody(), _buildBottomStatusBarOverlay()],
),
),
);
}
Widget _buildBottomStatusBarOverlay() {
Widget buildBadge(
String content,
bool tabular, {
TextOverflow overflow = TextOverflow.clip,
}) {
return Text(
content,
overflow: overflow,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
fontSize: 10,
fontWeight: FontWeight.w500,
fontFeatures: tabular ? const [FontFeature.tabularFigures()] : null,
shadows: [
Shadow(
color: Theme.of(
context,
).colorScheme.surface.withValues(alpha: 0.5),
blurRadius: 1.0,
offset: Offset.zero,
),
],
),
);
}
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.only(left: 32, right: 32, bottom: 8),
constraints: const BoxConstraints(minHeight: 32, maxHeight: 32),
child: AnimatedOpacity(
duration: (widget.isLoading || !widget.shouldShowWebView)
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOut,
opacity: (widget.isLoading || !widget.shouldShowWebView) ? 0.0 : 1.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: buildBadge(
widget.statusBarLeftContent,
false,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
buildBadge(widget.statusBarRightContent, true),
],
),
),
),
);
}
Widget _buildBody() {
return Platform.isAndroid
? _androidPageTurnSession.buildAnimatedContainer(
context,
_buildWebView(),
_buildScreenshotContainer,
)
: _iosPageTurnSession.buildAnimatedContainer(context, _buildWebView());
}
Widget _buildContentWrapper(Widget child) {
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withValues(
alpha: _currentTheme.isDark ? 0.3 : 0.15,
),
blurRadius: 25,
offset: Offset.zero,
),
],
color: _currentTheme.surfaceColor,
),
child: Container(alignment: AlignmentGeometry.center, child: child),
);
}
Widget _buildWebView() {
return _buildContentWrapper(
ReaderWebView(
key: _webViewKey,
bookSession: widget.bookSession,
webViewHandler: widget.webViewHandler,
fileHash: widget.fileHash,
initializeTheme: _addSafeAreaToThemePadding(widget.initializeTheme),
isLoading: widget.isLoading,
controller: _webViewController,
callbacks: ReaderWebViewCallbacks(
onInitialized: () async {
await widget.onInitialized();
},
onPageCountReady: (totalPages) async {
await widget.onPageCountReady(totalPages);
},
onPageChanged: widget.onPageChanged,
onScrollAnchors: widget.onScrollAnchors,
onImageLongPress: widget.onImageLongPress,
onTap: _handleTapZone,
onFootnoteTap: widget.onFootnoteTap,
onLinkTap: widget.onLinkTap,
shouldHandleLinkTap: widget.shouldHandleLinkTap,
),
shouldShowWebView: widget.shouldShowWebView,
coverRelativePath: widget.bookSession.book['cover_path'] as String?,
direction: widget.bookSession.direction,
),
);
}
Widget _buildScreenshotContainer(ui.Image? screenshot) {
if (screenshot == null) {
return _buildContentWrapper(Container(color: _currentTheme.surfaceColor));
}
return _buildContentWrapper(RawImage(image: screenshot, fit: BoxFit.cover));
}
}

View File

@@ -0,0 +1,546 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../utils/epub/epub_theme.dart';
import '../../utils/epub/epub_webview_handler.dart';
import '../../utils/epub/epub_stream_service.dart';
import '../../utils/epub/epub_parser.dart';
import '../../utils/epub/reader_settings.dart';
import '../../utils/epub/reader_models.dart';
import '../../utils/epub/volume_control_service.dart';
import '../../utils/epub/reader_dao.dart';
import 'book_session.dart';
import 'reader_renderer.dart';
import 'control_panel.dart';
import 'toc_drawer.dart';
import 'image_viewer.dart';
import 'footnote_popup.dart';
part 'mixins/spine_navigation_mixin.dart';
part 'mixins/page_navigation_mixin.dart';
part 'mixins/progress_mixin.dart';
part 'mixins/theme_mixin.dart';
part 'mixins/link_handling_mixin.dart';
part 'mixins/image_viewer_mixin.dart';
part 'mixins/footnote_mixin.dart';
/// Reads EPUB directly from compressed file without extraction.
class ReaderScreen extends StatefulWidget {
final String bookId;
final String filePath;
final String title;
final String? coverPath;
const ReaderScreen({
super.key,
required this.bookId,
required this.filePath,
required this.title,
this.coverPath,
});
@override
State<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends State<ReaderScreen>
with
WidgetsBindingObserver,
_SpineNavigationMixin,
_PageNavigationMixin,
_ProgressMixin,
_ThemeMixin,
_LinkHandlingMixin,
_ImageViewerMixin,
_FootnoteMixin {
@override
late final EpubWebViewHandler webViewHandler;
@override
late final BookSession bookSession;
@override
final ReaderRendererController rendererController =
ReaderRendererController();
// Core UI state
@override
bool isWebViewLoading = true;
@override
bool showControls = false;
// WebView visibility control for smoother transitions
Animation<double>? routeAnimation;
bool shouldShowWebView = false;
// Spine navigation state (used by _SpineNavigationMixin)
@override
int currentSpineItemIndex = 0;
// Pagination state (used by _PageNavigationMixin)
@override
int currentPageInChapter = 0;
@override
int totalPagesInChapter = 1;
// Progress state (used by _ProgressMixin)
@override
String displayProgress = '';
@override
Timer? progressDebouncer;
// Theme state (used by _ThemeMixin)
@override
ThemeData? currentTheme;
@override
bool updatingTheme = false;
@override
Timer? themeUpdateDebouncer;
@override
ReaderSettings readerSettings = const ReaderSettings();
// Image viewer state (used by _ImageViewerMixin)
@override
bool isImageViewerVisible = false;
@override
Uint8List? currentImageData;
@override
Rect? currentImageRect;
// Footnote state (used by _FootnoteMixin)
@override
OverlayEntry? footnoteOverlayEntry;
@override
final GlobalKey<FootnotePopupOverlayState> footnoteKey =
GlobalKey<FootnotePopupOverlayState>();
@override
bool isClosingFootnote = false;
final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
StreamSubscription<String>? volumeSubscription;
bool tocDrawerOpen = false;
bool styleDrawerOpen = false;
AppLifecycleState? lastLifecycleState = AppLifecycleState.resumed;
// Services
final EpubStreamService _streamService = EpubStreamService();
final ReaderDao _readerDao = ReaderDao();
final EpubParser _epubParser = EpubParser();
@override
void initState() {
super.initState();
webViewHandler = EpubWebViewHandler(streamService: _streamService);
// Create a placeholder BookSession; epubInfo will be replaced after parsing.
bookSession = BookSession(
fileHash: widget.bookId,
bookData: {
'id': widget.bookId,
'file_path': widget.filePath,
'cover_path': widget.coverPath,
},
epubInfo: EpubBookInfo(
title: widget.title,
author: '',
authors: [],
opfRootPath: '',
epubVersion: '',
spine: [],
toc: [],
),
readerDao: _readerDao,
);
// Load settings first, then book
ReaderSettings.load().then((settings) {
readerSettings = settings;
if (mounted) {
setupVolumeControl();
_loadBook();
}
});
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
final route = ModalRoute.of(context);
if (route != null && route.animation != null) {
routeAnimation = route.animation!;
routeAnimation?.addStatusListener(handleRouteAnimationStatus);
} else {
shouldShowWebView = true;
}
});
hideBottomNavigationBar();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeAnimation?.removeStatusListener(handleRouteAnimationStatus);
routeAnimation = null;
themeUpdateDebouncer?.cancel();
progressDebouncer?.cancel();
removeFootnoteOverlay(animate: false);
restoreSystemUI();
volumeSubscription?.cancel();
VolumeControlService.disableInterception();
bookSession.dispose();
_streamService.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
saveProgress();
}
lastLifecycleState = state;
setupVolumeControl();
}
void setupVolumeControl() {
final resume = readerSettings.volumeKeyTurnsPage &&
!tocDrawerOpen &&
!styleDrawerOpen &&
lastLifecycleState == AppLifecycleState.resumed;
if (resume) {
VolumeControlService.enableInterception();
volumeSubscription ??= VolumeControlService.volumeKeyEvents.listen((
event,
) {
if (readerSettings.volumeKeyTurnsPage) {
if (footnoteOverlayEntry != null) {
removeFootnoteOverlay();
return;
}
if (event == 'up') {
rendererController.performPreviousPageTurn();
} else if (event == 'down') {
rendererController.performNextPageTurn();
}
}
});
} else {
VolumeControlService.disableInterception();
}
}
void hideBottomNavigationBar() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [SystemUiOverlay.top],
);
}
void restoreSystemUI() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (currentTheme == null) {
currentTheme = Theme.of(context);
} else if (currentTheme?.colorScheme != Theme.of(context).colorScheme) {
currentTheme = Theme.of(context);
updateWebViewThemeWithDebounce();
}
}
void handleRouteAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) {
setState(() {
shouldShowWebView = true;
});
routeAnimation?.removeStatusListener(handleRouteAnimationStatus);
routeAnimation = null;
}
}
/// Load book data from EPUB file and initialize session.
Future<void> _loadBook() async {
try {
// filePath is the original absolute path from import
final fullEpubPath = widget.filePath;
// Parse EPUB metadata using existing EpubParser
final epubInfo = await _epubParser.parseFromFile(
fullEpubPath,
fileName: widget.title,
);
if (epubInfo == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('EPUB 解析失败')),
);
Navigator.of(context).pop();
}
return;
}
// Open archive in stream service for WebView resource serving
await _streamService.openBook(fullEpubPath);
// Update session with parsed data
bookSession.updateEpubInfo(epubInfo);
bookSession.load();
if (mounted) {
setState(() {
currentSpineItemIndex = bookSession.initialChapterIndex;
});
updateProgressDebounced();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载书籍失败: $e')),
);
Navigator.of(context).pop();
}
}
}
void toggleControls() {
if (showControls) {
hideBottomNavigationBar();
} else {
restoreSystemUI();
}
setState(() {
showControls = !showControls;
});
}
void openDrawer() {
scaffoldKey.currentState?.openDrawer();
}
@override
Widget build(BuildContext context) {
if (!bookSession.isLoaded) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: const SizedBox.shrink(),
);
}
final epubTheme = getEpubTheme();
final isDark = epubTheme.isDark;
final colorScheme = epubTheme.colorScheme;
final themeData = Theme.of(context);
final overlayStyle = isDark
? SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: colorScheme.surface,
systemNavigationBarIconBrightness: Brightness.light,
)
: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: colorScheme.surface,
systemNavigationBarIconBrightness: Brightness.dark,
);
final activeItems = resolveActiveItems();
final activateTocTitle = activeItems.isNotEmpty
? activeItems.last.label
: widget.title;
return PopScope(
canPop: footnoteOverlayEntry == null,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (footnoteOverlayEntry != null) {
removeFootnoteOverlay();
}
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: Stack(
children: [
Scaffold(
key: scaffoldKey,
backgroundColor: colorScheme.surfaceContainer,
drawer: TocDrawer(
bookTitle: widget.title,
coverPath: widget.coverPath,
totalChapters: bookSession.spine.length,
toc: bookSession.toc,
activeTocItems: activeItems,
onTocItemSelected: navigateToTocItem,
onCoverTap: navigateToFirstTocItemFirstPage,
themeData: themeData,
),
onDrawerChanged: (isOpened) {
tocDrawerOpen = isOpened;
setupVolumeControl();
},
body: Container(
color: epubTheme.surfaceColor,
child: Stack(
children: [
ReaderRenderer(
controller: rendererController,
bookSession: bookSession,
webViewHandler: webViewHandler,
fileHash: widget.bookId,
showControls: showControls,
isLoading: isWebViewLoading || updatingTheme,
canPerformPageTurn: canPerformPageTurn,
onPerformPageTurn: handlePageTurn,
onToggleControls: toggleControls,
onInitialized: () async {
final ratio = bookSession.initialScrollPosition;
await loadCarousel(restoreScrollRatio: ratio);
},
onPageCountReady: (totalPages) async {
setState(() {
totalPagesInChapter = totalPages;
if (currentPageInChapter >= totalPagesInChapter) {
currentPageInChapter = totalPagesInChapter - 1;
}
});
updateProgressDebounced();
},
onPageChanged: (pageIndex) {
setState(() {
currentPageInChapter = pageIndex;
});
updateProgressDebounced();
saveProgress();
},
onScrollAnchors: handleScrollAnchors,
onImageLongPress: handleImageLongPress,
onFootnoteTap: handleFootnoteTap,
onLinkTap: handleLinkTap,
shouldHandleLinkTap: shouldHandleLinkTap,
shouldShowWebView: shouldShowWebView,
initializeTheme: epubTheme,
statusBarLeftContent: activateTocTitle,
statusBarRightContent: displayProgress,
pageAnimation: readerSettings.pageAnimation,
),
ControlPanel(
showControls: showControls,
title: bookSession.spine.isEmpty
? widget.title
: activateTocTitle,
currentSpineItemIndex: currentSpineItemIndex,
totalSpineItems: bookSession.spine.length,
currentPageInChapter: currentPageInChapter,
totalPagesInChapter: totalPagesInChapter,
direction: bookSession.direction,
fontSize: readerSettings.zoom * 18.0,
zoom: readerSettings.zoom,
marginTop: readerSettings.marginTop,
marginBottom: readerSettings.marginBottom,
marginLeft: readerSettings.marginLeft,
marginRight: readerSettings.marginRight,
onBack: () {
saveProgress();
Navigator.of(context).pop();
},
onOpenDrawer: openDrawer,
onPreviousPage: () =>
rendererController.performPreviousPageTurn(),
onFirstPage: () => goToPage(0),
onNextPage: () =>
rendererController.performNextPageTurn(),
onLastPage: () => goToPage(totalPagesInChapter - 1),
onPreviousChapter: previousSpineItemFirstPage,
onNextChapter: nextSpineItem,
onToggleStyleDrawer: () {
// Style sheet is opened internally by ControlPanel
},
onZoomChanged: (value) {
setState(() {
readerSettings = readerSettings.copyWith(zoom: value);
});
readerSettings.save();
updateWebViewTheme();
},
onFontSizeChanged: (value) {
// 将字号值映射为 zoom12px→0.7, 18px→1.0, 32px→1.8
final zoom = value / 18.0;
setState(() {
readerSettings = readerSettings.copyWith(zoom: zoom);
});
readerSettings.save();
updateWebViewTheme();
},
onMarginTopChanged: (value) {
setState(() {
readerSettings =
readerSettings.copyWith(marginTop: value);
});
readerSettings.save();
updateWebViewTheme();
},
onMarginBottomChanged: (value) {
setState(() {
readerSettings =
readerSettings.copyWith(marginBottom: value);
});
readerSettings.save();
updateWebViewTheme();
},
onMarginLeftChanged: (value) {
setState(() {
readerSettings =
readerSettings.copyWith(marginLeft: value);
});
readerSettings.save();
updateWebViewTheme();
},
onMarginRightChanged: (value) {
setState(() {
readerSettings =
readerSettings.copyWith(marginRight: value);
});
readerSettings.save();
updateWebViewTheme();
},
),
],
),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: !isImageViewerVisible,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
opacity: isImageViewerVisible ? 1.0 : 0.0,
child: (currentImageData != null && currentImageRect != null)
? ImageViewer(
imageData: currentImageData!,
onClose: closeImageViewer,
sourceRect: currentImageRect!,
colorScheme: colorScheme,
)
: const SizedBox.shrink(),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'widgets/integer_stepper.dart';
import 'widgets/reader_scale_slider.dart';
/// Simplified reader style configuration bottom sheet.
///
/// Provides zoom slider, margin controls, and font size adjustments.
class ReaderStyleSheet extends StatefulWidget {
final double zoom;
final double marginTop;
final double marginBottom;
final double marginLeft;
final double marginRight;
final double fontSize;
final ValueChanged<double> onZoomChanged;
final ValueChanged<double> onMarginTopChanged;
final ValueChanged<double> onMarginBottomChanged;
final ValueChanged<double> onMarginLeftChanged;
final ValueChanged<double> onMarginRightChanged;
final ValueChanged<double> onFontSizeChanged;
const ReaderStyleSheet({
super.key,
required this.zoom,
required this.marginTop,
required this.marginBottom,
required this.marginLeft,
required this.marginRight,
required this.fontSize,
required this.onZoomChanged,
required this.onMarginTopChanged,
required this.onMarginBottomChanged,
required this.onMarginLeftChanged,
required this.onMarginRightChanged,
required this.onFontSizeChanged,
});
@override
State<ReaderStyleSheet> createState() => _ReaderStyleSheetState();
}
class _ReaderStyleSheetState extends State<ReaderStyleSheet> {
late double _scale;
late int _topMargin;
late int _bottomMargin;
late int _leftMargin;
late int _rightMargin;
late double _fontSize;
static const int _marginMin = 0;
static const int _marginMax = 64;
static const int _marginStep = 2;
static const double _fontSizeMin = 12.0;
static const double _fontSizeMax = 32.0;
@override
void initState() {
super.initState();
_scale = widget.zoom;
_topMargin = widget.marginTop.toInt();
_bottomMargin = widget.marginBottom.toInt();
_leftMargin = widget.marginLeft.toInt();
_rightMargin = widget.marginRight.toInt();
_fontSize = widget.fontSize;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
return DraggableScrollableSheet(
initialChildSize: 1.0,
minChildSize: 0.5,
expand: false,
builder: (BuildContext context, ScrollController scrollController) {
return SingleChildScrollView(
controller: scrollController,
physics: const ClampingScrollPhysics(),
child: Padding(
padding: EdgeInsets.fromLTRB(24, 12, 24, 24 + bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// -- 缩放比例 --
const _SectionTitle(label: '缩放比例'),
const SizedBox(height: 4),
Row(
children: [
const _SubLabel(label: '缩放'),
const Spacer(),
Text(
'${_scale.toStringAsFixed(1)}x',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 4),
ReaderScaleSlider(
value: _scale,
onChanged: (v) {
setState(() => _scale = v);
widget.onZoomChanged(v);
},
),
const SizedBox(height: 20),
// -- 字号 --
const _SectionTitle(label: '字号'),
const SizedBox(height: 4),
Row(
children: [
const _SubLabel(label: '字号'),
const Spacer(),
Text(
'${_fontSize.toInt()}px',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
GestureDetector(
onTap: _fontSize > _fontSizeMin
? () {
final v =
(_fontSize - 1).clamp(_fontSizeMin, _fontSizeMax);
setState(() => _fontSize = v);
widget.onFontSizeChanged(v);
}
: null,
child: Text(
'A',
style: TextStyle(
fontSize: 12,
color: _fontSize > _fontSizeMin
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
),
),
Expanded(
child: Slider(
value: _fontSize,
min: _fontSizeMin,
max: _fontSizeMax,
divisions: 20,
label: _fontSize.toInt().toString(),
onChanged: (v) {
setState(() => _fontSize = v);
widget.onFontSizeChanged(v);
},
),
),
GestureDetector(
onTap: _fontSize < _fontSizeMax
? () {
final v =
(_fontSize + 1).clamp(_fontSizeMin, _fontSizeMax);
setState(() => _fontSize = v);
widget.onFontSizeChanged(v);
}
: null,
child: Text(
'A',
style: TextStyle(
fontSize: 22,
color: _fontSize < _fontSizeMax
? colorScheme.onSurfaceVariant
: colorScheme.outline,
),
),
),
],
),
const SizedBox(height: 24),
// -- 边距 --
const _SectionTitle(label: '边距'),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: IntegerStepper(
label: '',
value: _topMargin,
min: _marginMin,
max: _marginMax,
step: _marginStep,
onChanged: (v) {
setState(() => _topMargin = v);
widget.onMarginTopChanged(v.toDouble());
},
),
),
const SizedBox(width: 12),
Expanded(
child: IntegerStepper(
label: '',
value: _bottomMargin,
min: _marginMin,
max: _marginMax,
step: _marginStep,
onChanged: (v) {
setState(() => _bottomMargin = v);
widget.onMarginBottomChanged(v.toDouble());
},
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: IntegerStepper(
label: '',
value: _leftMargin,
min: _marginMin,
max: _marginMax,
step: _marginStep,
onChanged: (v) {
setState(() => _leftMargin = v);
widget.onMarginLeftChanged(v.toDouble());
},
),
),
const SizedBox(width: 12),
Expanded(
child: IntegerStepper(
label: '',
value: _rightMargin,
min: _marginMin,
max: _marginMax,
step: _marginStep,
onChanged: (v) {
setState(() => _rightMargin = v);
widget.onMarginRightChanged(v.toDouble());
},
),
),
],
),
],
),
),
);
},
);
}
}
/// Section title (equivalent to lumina's SettingsSectionTitle)
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Text(
label,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.primary,
),
);
}
}
/// Sub-label (equivalent to lumina's SettingsSubLabel)
class _SubLabel extends StatelessWidget {
const _SubLabel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Text(
label,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
}

View File

@@ -0,0 +1,524 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../utils/epub/epub_theme.dart';
import 'book_session.dart';
import '../../utils/epub/epub_webview_handler.dart';
import '../../utils/epub/reader_scripts.dart';
import '../../utils/epub/web/webview_bridge.dart';
import '../../utils/epub/web/reader_api.dart';
/// Controller for ReaderWebView that provides methods to control the WebView
class ReaderWebViewController {
_ReaderWebViewState? _webViewState;
bool get isAttached => _webViewState != null;
void _attachState(_ReaderWebViewState? state) {
_webViewState = state;
}
// JavaScript wrapper methods
Future<int?> jumpToLastPageOfFrame(String frame) async {
return await _webViewState?._jumpToLastPageOfFrame(frame);
}
Future<int?> cycleFrames(String direction) async {
return await _webViewState?._cycleFrames(direction);
}
Future<int?> jumpToPageFor(String frame, int pageIndex) async {
return await _webViewState?._jumpToPageFor(frame, pageIndex);
}
Future<int?> loadFrame(
String frame,
String url,
String anchors,
String properties,
) async {
return await _webViewState?._loadFrame(frame, url, anchors, properties);
}
Future<void> jumpToPage(int pageIndex) async {
await _webViewState?._jumpToPage(pageIndex);
}
Future<void> restoreScrollPosition(double ratio) async {
await _webViewState?._restoreScrollPosition(ratio);
}
Future<void> checkLongPressElementAt(double x, double y) async {
await _webViewState?._checkLongPressElementAt(x, y);
}
Future<void> checkTapElementAt(double x, double y) async {
await _webViewState?._checkTapElementAt(x, y);
}
Future<ui.Image?> takeScreenshot() async {
return await _webViewState?._takeScreenshot();
}
Future<void> waitForRender() async {
await _webViewState?._waitForRender();
}
Future<void> updateTheme(EpubTheme theme) async {
await _webViewState?._updateTheme(theme);
}
Future<void> waitForEvent(int token, [int timeoutMs = 10000]) async {
await _webViewState?._bridge.waitForEvent(token, timeoutMs);
}
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
await _webViewState?._bridge.waitForEvents(tokens, timeoutMs);
}
}
final InAppWebViewSettings defaultSettings = InAppWebViewSettings(
disableContextMenu: true,
disableLongPressContextMenuOnLinks: true,
selectionGranularity: SelectionGranularity.CHARACTER,
transparentBackground: true,
allowFileAccessFromFileURLs: true,
allowUniversalAccessFromFileURLs: true,
useShouldInterceptRequest: true,
useOnLoadResource: false,
useShouldOverrideUrlLoading: true,
javaScriptEnabled: true,
disableHorizontalScroll: true,
disableVerticalScroll: true,
supportZoom: false,
useHybridComposition: false,
resourceCustomSchemes: [EpubWebViewHandler.virtualScheme],
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
overScrollMode: OverScrollMode.NEVER,
);
/// Callbacks for WebView events
class ReaderWebViewCallbacks {
final Function() onInitialized;
final Function(int totalPages) onPageCountReady;
final Function(int pageIndex) onPageChanged;
final Function(List<String> anchors) onScrollAnchors;
final Function(String imageUrl, Rect rect) onImageLongPress;
final Function(double x, double y) onTap;
final Function(String innerHtml, Rect rect, String baseUrl) onFootnoteTap;
final Function(String url) onLinkTap;
final bool Function(String url) shouldHandleLinkTap;
const ReaderWebViewCallbacks({
required this.onInitialized,
required this.onPageCountReady,
required this.onPageChanged,
required this.onScrollAnchors,
required this.onImageLongPress,
required this.onTap,
required this.onFootnoteTap,
required this.onLinkTap,
required this.shouldHandleLinkTap,
});
}
/// WebView widget for reading EPUB content
class ReaderWebView extends StatefulWidget {
final BookSession bookSession;
final EpubWebViewHandler webViewHandler;
final String fileHash;
final ReaderWebViewCallbacks callbacks;
final EpubTheme initializeTheme;
final bool isLoading;
final ReaderWebViewController controller;
final VoidCallback? onWebViewCreated;
final bool shouldShowWebView;
final String? coverRelativePath;
final int direction;
const ReaderWebView({
super.key,
required this.bookSession,
required this.webViewHandler,
required this.fileHash,
required this.callbacks,
required this.initializeTheme,
required this.isLoading,
required this.controller,
this.onWebViewCreated,
required this.shouldShowWebView,
this.coverRelativePath,
required this.direction,
});
@override
State<ReaderWebView> createState() => _ReaderWebViewState();
}
class _ReaderWebViewState extends State<ReaderWebView> {
final GlobalKey _repaintKey = GlobalKey();
InAppWebViewController? _controller;
HeadlessInAppWebView? _headlessWebView;
bool _isHeadlessInitialized = false;
bool _isSubsequentLoad = false;
late EpubTheme _currentTheme;
final WebViewBridge _bridge = WebViewBridge();
late final ReaderApi _api = ReaderApi(_bridge);
@override
void initState() {
super.initState();
_currentTheme = widget.initializeTheme;
widget.controller._attachState(this);
}
@override
void didUpdateWidget(covariant ReaderWebView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.isLoading && widget.isLoading) {
setState(() {
_isSubsequentLoad = true;
});
}
}
void _initHeadlessWebViewIfNeeded(double width, double height) {
if (_isHeadlessInitialized) return;
_headlessWebView = HeadlessInAppWebView(
initialData: _generateInitialData(width, height),
initialSettings: defaultSettings,
shouldInterceptRequest: _shouldInterceptRequest,
onLoadResourceWithCustomScheme: _onLoadResourceWithCustomScheme,
shouldOverrideUrlLoading: _shouldOverrideUrlLoading,
onWebViewCreated: _onWebViewCreated,
onLoadStop: _onLoadStop,
);
_headlessWebView?.run();
_isHeadlessInitialized = true;
}
Future<void> _waitForWebviewRender() async {
if (_controller == null) return;
await _api.waitForRender();
}
Future<void> _waitForRender() async {
await _waitForWebviewRender();
}
Future<int> _jumpToLastPageOfFrame(String frame) =>
_api.jumpToLastPageOfFrame(frame);
Future<int> _cycleFrames(String direction) => _api.cycleFrames(direction);
Future<int> _jumpToPageFor(String frame, int pageIndex) =>
_api.jumpToPageFor(frame, pageIndex);
Future<int> _loadFrame(
String frame,
String url,
String anchors,
String properties,
) => _api.loadFrame(frame, url, anchors, properties);
Future<void> _jumpToPage(int pageIndex) => _api.jumpToPage(pageIndex);
Future<void> _restoreScrollPosition(double ratio) =>
_api.restoreScrollPosition(ratio);
Future<void> _checkLongPressElementAt(double x, double y) =>
_api.checkLongPressElementAt(x, y);
Future<void> _checkTapElementAt(double x, double y) =>
_api.checkTapElementAt(x, y);
InAppWebViewInitialData _generateInitialData(double width, double height) {
return InAppWebViewInitialData(
data: generateSkeletonHtml(
width,
height,
_currentTheme,
widget.direction,
),
baseUrl: WebUri(EpubWebViewHandler.getBaseUrl()),
);
}
Future<WebResourceResponse?> _shouldInterceptRequest(
InAppWebViewController controller,
WebResourceRequest request,
) async {
return await widget.webViewHandler.handleRequest(
epubPath: widget.bookSession.book['file_path'] as String,
fileHash: widget.fileHash,
requestUrl: request.url,
);
}
Future<CustomSchemeResponse?> _onLoadResourceWithCustomScheme(
InAppWebViewController controller,
WebResourceRequest request,
) async {
return await widget.webViewHandler.handleRequestWithCustomScheme(
epubPath: widget.bookSession.book['file_path'] as String,
fileHash: widget.fileHash,
requestUrl: request.url,
);
}
Future<NavigationActionPolicy?> _shouldOverrideUrlLoading(
InAppWebViewController controller,
NavigationAction navigationAction,
) async {
final uri = navigationAction.request.url!;
if (uri.scheme == 'data') {
return NavigationActionPolicy.ALLOW;
}
if (EpubWebViewHandler.isEpubRequest(uri)) {
return NavigationActionPolicy.ALLOW;
}
return NavigationActionPolicy.CANCEL;
}
void _onWebViewCreated(InAppWebViewController controller) {
_controller = controller;
_bridge.attach(controller);
_setupJavaScriptHandlers(controller);
widget.onWebViewCreated?.call();
}
void _onLoadStop(InAppWebViewController controller, WebUri? url) {
widget.callbacks.onInitialized();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth - _currentTheme.padding.horizontal;
final height = constraints.maxHeight - _currentTheme.padding.vertical;
_initHeadlessWebViewIfNeeded(width, height);
return Stack(
children: [
RepaintBoundary(
key: _repaintKey,
child: AbsorbPointer(
child: widget.shouldShowWebView
? InAppWebView(
headlessWebView: _headlessWebView,
initialData: _generateInitialData(width, height),
initialSettings: defaultSettings,
shouldInterceptRequest: _shouldInterceptRequest,
onLoadResourceWithCustomScheme:
_onLoadResourceWithCustomScheme,
shouldOverrideUrlLoading: _shouldOverrideUrlLoading,
onWebViewCreated: _onWebViewCreated,
onLoadStop: _onLoadStop,
)
: Container(color: _currentTheme.surfaceColor),
),
),
Positioned.fill(
child: IgnorePointer(
ignoring: !widget.isLoading && widget.shouldShowWebView,
child: AnimatedOpacity(
duration: (widget.isLoading || !widget.shouldShowWebView)
? Duration.zero
: const Duration(milliseconds: 250),
curve: Curves.easeOut,
opacity: (widget.isLoading || !widget.shouldShowWebView)
? 1.0
: 0.0,
child: Container(
color: _currentTheme.surfaceColor,
child: _isSubsequentLoad
? null
: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height * 0.4,
maxWidth:
MediaQuery.of(context).size.width * 0.6,
),
child: _buildCoverPlaceholder(),
),
),
),
),
),
),
],
);
},
);
}
/// Simple cover placeholder — loads image from file if available,
/// otherwise shows a book icon.
Widget _buildCoverPlaceholder() {
final path = widget.coverRelativePath;
if (path != null && path.isNotEmpty) {
final file = File(path);
if (file.existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, fit: BoxFit.cover),
);
}
}
return Container(
decoration: BoxDecoration(
color: _currentTheme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.book_outlined,
size: 64,
color: _currentTheme.colorScheme.onSurfaceVariant,
),
);
}
void _setupJavaScriptHandlers(InAppWebViewController controller) {
controller.addJavaScriptHandler(
handlerName: 'onPageCountReady',
callback: (args) async {
if (args.isNotEmpty && args[0] is int) {
widget.callbacks.onPageCountReady(args[0] as int);
}
},
);
controller.addJavaScriptHandler(
handlerName: 'onPageChanged',
callback: (args) {
if (args.isNotEmpty && args[0] is int) {
widget.callbacks.onPageChanged(args[0] as int);
}
},
);
controller.addJavaScriptHandler(
handlerName: 'onScrollAnchors',
callback: (args) {
if (args.isEmpty) return;
final List<String> anchors = List<String>.from(args[0] as List);
widget.callbacks.onScrollAnchors(anchors);
},
);
controller.addJavaScriptHandler(
handlerName: 'onTap',
callback: (args) {
if (args.isEmpty) return;
final x = (args[0] as num).toDouble();
final y = (args[1] as num).toDouble();
widget.callbacks.onTap(x, y);
},
);
controller.addJavaScriptHandler(
handlerName: 'onFootnoteTap',
callback: (args) {
if (args.isEmpty) return;
final innerHtml = args[0] as String;
final rect = Rect.fromLTWH(
(args[1] as num).toDouble(),
(args[2] as num).toDouble(),
(args[3] as num).toDouble(),
(args[4] as num).toDouble(),
);
final baseUrl = args.length > 5 && args[5] is String
? args[5] as String
: '';
widget.callbacks.onFootnoteTap(innerHtml, rect, baseUrl);
},
);
controller.addJavaScriptHandler(
handlerName: 'onLinkTap',
callback: (args) {
if (args.isEmpty) return;
final url = args[0] as String;
final x = (args[1] as num).toDouble();
final y = (args[2] as num).toDouble();
if (widget.callbacks.shouldHandleLinkTap(url)) {
widget.callbacks.onLinkTap(url);
} else {
widget.callbacks.onTap(x, y);
}
},
);
controller.addJavaScriptHandler(
handlerName: 'onImageLongPress',
callback: (args) {
if (args.length >= 5 && args[0] is String) {
final imageUrl = args[0] as String;
final rect = Rect.fromLTWH(
(args[1] as num).toDouble(),
(args[2] as num).toDouble(),
(args[3] as num).toDouble(),
(args[4] as num).toDouble(),
);
widget.callbacks.onImageLongPress(imageUrl, rect);
}
},
);
controller.addJavaScriptHandler(
handlerName: 'onViewportResize',
callback: (args) {
_updateTheme(_currentTheme);
},
);
controller.addJavaScriptHandler(
handlerName: 'onEventFinished',
callback: (args) {
if (args.isNotEmpty) {
_bridge.resolveToken(args[0] as int);
}
},
);
}
Future<ui.Image?> _takeScreenshot() async {
if (Platform.isAndroid) {
// for Android
final BuildContext? context = _repaintKey.currentContext;
if (context == null) return null;
final RenderRepaintBoundary? boundary =
context.findRenderObject() as RenderRepaintBoundary?;
if (boundary == null) return null;
ui.Image image = await boundary.toImage(pixelRatio: 3.0);
return image;
} else {
throw UnimplementedError(
'Do not use screenshot on iOS, it may cause performance issues.',
);
}
}
Future<void> _updateTheme(EpubTheme theme) async {
if (_controller == null) return;
final width = MediaQuery.of(context).size.width - theme.padding.horizontal;
final height = MediaQuery.of(context).size.height - theme.padding.vertical;
_currentTheme = theme;
await _api.updateTheme(width, height, theme.toThemeMap());
}
}

View File

@@ -0,0 +1,370 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../utils/epub/reader_models.dart';
/// Helper class to represent a visible row in the flattened TOC list
class _TocRowItem {
final TocEntry item;
final int depth;
final bool isExpanded;
final bool hasChildren;
_TocRowItem({
required this.item,
required this.depth,
required this.isExpanded,
required this.hasChildren,
});
}
class TocDrawer extends StatefulWidget {
final String bookTitle;
final String? coverPath;
final int totalChapters;
final List<TocEntry> toc;
final Set<TocEntry> activeTocItems;
final Function(TocEntry) onTocItemSelected;
final VoidCallback? onCoverTap;
final ThemeData themeData;
const TocDrawer({
super.key,
required this.bookTitle,
this.coverPath,
required this.totalChapters,
required this.toc,
required this.activeTocItems,
required this.onTocItemSelected,
this.onCoverTap,
required this.themeData,
});
@override
State<TocDrawer> createState() => _TocDrawerState();
}
class _TocDrawerState extends State<TocDrawer> {
final ScrollController _scrollController = ScrollController();
final Set<TocEntry> _expandedItems = {};
List<_TocRowItem> _visibleItems = [];
@override
void initState() {
super.initState();
_initExpansionState();
_regenerateVisibleItems();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToFirstActive();
});
}
@override
void didUpdateWidget(covariant TocDrawer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.toc != oldWidget.toc) {
_expandedItems.clear();
_initExpansionState();
_regenerateVisibleItems();
} else if (widget.activeTocItems != oldWidget.activeTocItems) {
bool expandedChanged = _autoExpandParents();
if (expandedChanged) {
_regenerateVisibleItems();
}
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _initExpansionState() {
_autoExpandParents();
}
bool _autoExpandParents() {
bool changed = false;
if (widget.activeTocItems.isEmpty) return false;
bool findAndExpand(TocEntry current, TocEntry target) {
if (current == target) return true;
for (final child in current.children) {
if (findAndExpand(child, target)) {
if (!_expandedItems.contains(current)) {
_expandedItems.add(current);
changed = true;
}
return true;
}
}
return false;
}
for (final root in widget.toc) {
for (final active in widget.activeTocItems) {
findAndExpand(root, active);
}
}
return changed;
}
void _regenerateVisibleItems() {
final newItems = <_TocRowItem>[];
void traverse(List<TocEntry> items, int depth) {
for (final item in items) {
final isExpanded = _expandedItems.contains(item);
final hasChildren = item.children.isNotEmpty;
newItems.add(
_TocRowItem(
item: item,
depth: depth,
isExpanded: isExpanded,
hasChildren: hasChildren,
),
);
if (hasChildren && isExpanded) {
traverse(item.children, depth + 1);
}
}
}
traverse(widget.toc, 0);
setState(() {
_visibleItems = newItems;
});
}
void _toggleExpansion(TocEntry item) {
if (_expandedItems.contains(item)) {
_expandedItems.remove(item);
} else {
_expandedItems.add(item);
}
_regenerateVisibleItems();
}
void _scrollToFirstActive() {
if (widget.activeTocItems.isEmpty || _visibleItems.isEmpty) return;
final index = _visibleItems.indexWhere(
(row) => widget.activeTocItems.contains(row.item),
);
if (index != -1 && _scrollController.hasClients) {
final offset = (index * 56.0) - (56.0 * 4);
_scrollController.jumpTo(
offset.clamp(0.0, _scrollController.position.maxScrollExtent),
);
}
}
@override
Widget build(BuildContext context) {
final isDark = widget.themeData.brightness == Brightness.dark;
return Drawer(
backgroundColor: widget.themeData.scaffoldBackgroundColor,
child: SafeArea(
child: Column(
children: [
_buildHeader(context, isDark),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _visibleItems.length + 1,
itemExtent: 56.0,
itemBuilder: (context, index) {
if (index == _visibleItems.length) {
return const SizedBox(height: 56);
}
final row = _visibleItems[index];
return _buildRowItem(context, row, isDark);
},
),
),
],
),
),
);
}
Widget _buildRowItem(BuildContext context, _TocRowItem row, bool isDark) {
final item = row.item;
final isActive = widget.activeTocItems.contains(item);
final double paddingLeft = 16.0 + (row.depth * 16.0);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
if (row.hasChildren) {
_toggleExpansion(item);
} else {
widget.onTocItemSelected(item);
Navigator.of(context).pop();
}
},
child: Container(
height: 56.0,
padding: EdgeInsets.only(left: paddingLeft, right: 16.0),
alignment: Alignment.centerLeft,
child: Row(
children: [
if (row.hasChildren)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(
row.isExpanded
? Icons.expand_more_outlined
: Icons.chevron_right_outlined,
size: 20,
color: widget.themeData.colorScheme.onSurfaceVariant,
),
)
else
const SizedBox(width: 28),
Expanded(
child: Text(
item.label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: widget.themeData.textTheme.bodyMedium?.copyWith(
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
color: isActive
? widget.themeData.colorScheme.onSurface
: widget.themeData.colorScheme.onSurfaceVariant,
),
),
),
if (isActive)
Icon(
Icons.circle_outlined,
size: 8,
color: widget.themeData.colorScheme.onSurface,
),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context, bool isDark) {
const authorText = '';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: widget.themeData.colorScheme.surface,
border: Border(
bottom: BorderSide(
color: widget.themeData.colorScheme.outline,
width: 1,
),
),
),
child: GestureDetector(
onTap: () {
widget.onCoverTap?.call();
Navigator.of(context).pop();
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Book Cover (placeholder or local image)
SizedBox(
width: 60,
height: 90,
child: _buildCoverPlaceholder(),
),
const SizedBox(width: 16),
// Book Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.bookTitle,
style: widget.themeData.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w400,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (authorText.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
authorText,
style: widget.themeData.textTheme.bodySmall?.copyWith(
color: widget.themeData.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 8),
Text(
'${widget.totalChapters}',
style: widget.themeData.textTheme.bodySmall?.copyWith(
color: widget.themeData.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}
Widget _buildCoverPlaceholder() {
if (widget.coverPath != null &&
widget.coverPath!.isNotEmpty &&
File(widget.coverPath!).existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _placeholderIcon(),
),
);
}
return _placeholderIcon();
}
Widget _placeholderIcon() {
return Container(
decoration: BoxDecoration(
color: widget.themeData.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Icon(
Icons.book_outlined,
size: 32,
color: widget.themeData.colorScheme.onSurfaceVariant,
),
),
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// A compact card-style stepper for an integer value.
class IntegerStepper extends StatelessWidget {
const IntegerStepper({
super.key,
required this.label,
required this.value,
required this.min,
required this.max,
required this.step,
required this.onChanged,
});
final String label;
final int value;
final int min;
final int max;
final int step;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outlineVariant, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.remove_outlined, size: 16),
onPressed: value > min ? () => onChanged(value - step) : null,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
visualDensity: VisualDensity.compact,
color: colorScheme.primary,
disabledColor: colorScheme.outline,
),
Text(
'$value',
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: const Icon(Icons.add_outlined, size: 16),
onPressed: value < max ? () => onChanged(value + step) : null,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
visualDensity: VisualDensity.compact,
color: colorScheme.primary,
disabledColor: colorScheme.outline,
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// A horizontal scale slider with small/large "A" tap targets on either side.
///
/// The slider range is fixed to [0.5, 2.5] with 0.1 increments.
/// Tapping the letter glyphs nudges the value by 0.1 in the respective
/// direction; the glyph is greyed-out when the limit is reached.
class ReaderScaleSlider extends StatelessWidget {
const ReaderScaleSlider({
super.key,
required this.value,
required this.onChanged,
});
final double value;
final ValueChanged<double> onChanged;
static const double _min = 0.5;
static const double _max = 2.5;
static const double _nudge = 0.1;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.onSurfaceVariant;
final disabledColor = Theme.of(context).colorScheme.outline;
return Row(
children: [
GestureDetector(
onTap: value > _min
? () => onChanged((value - _nudge).clamp(_min, _max))
: null,
child: Text(
'A',
style: TextStyle(
fontSize: 12,
color: value > _min ? color : disabledColor,
),
),
),
Expanded(
child: Slider(
value: value,
min: _min,
max: _max,
divisions: 20,
label: value.toStringAsFixed(1),
onChanged: onChanged,
),
),
GestureDetector(
onTap: value < _max
? () => onChanged((value + _nudge).clamp(_min, _max))
: null,
child: Text(
'A',
style: TextStyle(
fontSize: 22,
color: value < _max ? color : disabledColor,
),
),
),
],
);
}
}

View File

@@ -1,182 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/reader_book.dart';
import '../../providers/app_provider.dart';
import '../../service/book_import_service.dart';
import 'reader_book_detail_page.dart';
/// 书架页面 — 展示导入的阅读器书籍,支持网格/列表切换
class BookshelfPage extends StatefulWidget {
const BookshelfPage({super.key});
@override
State<BookshelfPage> createState() => _BookshelfPageState();
}
class _BookshelfPageState extends State<BookshelfPage> {
bool _isGridView = true;
Future<void> _importBook() async {
final provider = context.read<AppProvider>();
await BookImportService.pickAndImportBook(context, provider);
}
void _openBookDetail(ReaderBook book) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReaderBookDetailPage(book: book),
),
).then((_) {
context.read<AppProvider>().loadReaderBooks();
});
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('阅读器'),
actions: [
IconButton(
icon: Icon(_isGridView ? Icons.view_list : Icons.grid_view),
tooltip: _isGridView ? '列表视图' : '网格视图',
onPressed: () {
setState(() {
_isGridView = !_isGridView;
});
},
),
IconButton(
icon: const Icon(Icons.add),
tooltip: '导入书籍',
onPressed: _importBook,
),
],
),
body: Consumer<AppProvider>(
builder: (context, provider, child) {
final books = provider.readerBooks;
if (books.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.15)),
const SizedBox(height: 16),
Text('点击右上角导入书籍',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
);
}
if (_isGridView) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 16,
crossAxisSpacing: 12,
childAspectRatio: 0.65,
),
itemCount: books.length,
itemBuilder: (context, index) => _buildGridViewItem(books[index], colors),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: books.length,
itemBuilder: (context, index) => _buildListViewItem(books[index], colors),
);
},
),
);
}
Widget _buildGridViewItem(ReaderBook book, ColorScheme colors) {
return GestureDetector(
onTap: () => _openBookDetail(book),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Center(
child: Icon(Icons.menu_book, size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
),
const SizedBox(height: 6),
Text(book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.onSurface)),
if (book.readingPercentage > 0)
Padding(
padding: const EdgeInsets.only(top: 1),
child: Text('${(book.readingPercentage * 100).toStringAsFixed(0)}%',
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))),
),
],
),
);
}
Widget _buildListViewItem(ReaderBook book, ColorScheme colors) {
return GestureDetector(
onTap: () => _openBookDetail(book),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 48,
height: 64,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.menu_book, size: 24, color: colors.onSurface.withValues(alpha: 0.2)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: colors.onSurface)),
const SizedBox(height: 4),
Text('.${book.fileExtension}',
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
],
),
),
if (book.readingPercentage > 0)
Text('${(book.readingPercentage * 100).toStringAsFixed(0)}%',
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
],
),
),
);
}
}

View File

@@ -1,501 +0,0 @@
import 'dart:async';
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_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, this.level = 1});
factory TocItem.fromJson(Map<String, dynamic> json) {
return TocItem(
href: json['href'] ?? '',
title: json['label'] ?? json['title'] ?? '',
level: json['level'] as int? ?? 1,
);
}
}
/// 递归展平嵌套目录
List<TocItem> _flattenToc(List<dynamic> items, [int level = 1]) {
final result = <TocItem>[];
for (final item in items) {
if (item is Map) {
final map = Map<String, dynamic>.from(item);
result.add(TocItem(
href: map['href'] ?? '',
title: map['label'] ?? map['title'] ?? '',
level: level,
));
if (map['subitems'] is List && (map['subitems'] as List).isNotEmpty) {
result.addAll(_flattenToc(map['subitems'] as List, level + 1));
}
}
}
return result;
}
/// 翻页区域动作
enum PageTurnAction { prev, next, menu, none }
/// 默认九宫格布局(中间菜单,左右翻页)
const List<PageTurnAction> _defaultZoneActions = [
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
PageTurnAction.prev, PageTurnAction.menu, PageTurnAction.next,
];
/// 核心电子书阅读组件 — 使用 InAppWebView 渲染 foliate-js 阅读器
class EpubPlayer extends StatefulWidget {
final ReaderBook book;
final String? initialCfi;
final VoidCallback showOrHideToolbar;
final ValueChanged<List<TocItem>>? onTocReady;
const EpubPlayer({
super.key,
required this.book,
this.initialCfi,
required this.showOrHideToolbar,
this.onTocReady,
});
@override
State<EpubPlayer> createState() => EpubPlayerState();
}
class EpubPlayerState extends State<EpubPlayer> {
late InAppWebViewController _controller;
String cfi = '';
double percentage = 0.0;
String chapterTitle = '';
String chapterHref = '';
int chapterCurrentPage = 0;
int chapterTotalPages = 0;
// 浏览历史
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,
);
// ─── 翻页方法 ───────────────────────────────────────
void prevPage() {
_controller.evaluateJavascript(source: 'prevPage()');
}
void nextPage() {
_controller.evaluateJavascript(source: 'nextPage()');
}
void prevChapter() {
_controller.evaluateJavascript(source: 'prevSection()');
}
void nextChapter() {
_controller.evaluateJavascript(source: 'nextSection()');
}
void goToPercentage(double value) {
_controller.evaluateJavascript(source: 'goToPercent($value)');
}
void goToHref(String href) {
_controller.evaluateJavascript(source: "goToHref('$href')");
}
void goToCfi(String cfi) {
_controller.evaluateJavascript(source: "goToCfi('$cfi')");
}
// ─── 历史导航 ───────────────────────────────────────
void backHistory() {
_controller.evaluateJavascript(source: 'back()');
}
void forwardHistory() {
_controller.evaluateJavascript(source: 'forward()');
}
// ─── 保存进度 ───────────────────────────────────────
Future<void> saveReadingProgress() async {
if (cfi.isEmpty) return;
final provider = context.read<AppProvider>();
final updated = widget.book.copyWith(
lastReadCfi: cfi,
readingPercentage: percentage,
updatedAt: DateTime.now(),
);
await provider.updateReaderBook(updated);
}
// ─── 翻页区域处理 ──────────────────────────────────
void _onClick(Map<String, dynamic> location) {
final x = (location['x'] as num).toDouble();
final y = (location['y'] as num).toDouble();
final part = coordinatesToPart(x, y);
final action = _defaultZoneActions[part];
switch (action) {
case PageTurnAction.prev:
prevPage();
break;
case PageTurnAction.next:
nextPage();
break;
case PageTurnAction.menu:
widget.showOrHideToolbar();
break;
case PageTurnAction.none:
break;
}
}
// ─── 滚轮翻页 ──────────────────────────────────────
Future<void> _handlePointerEvents(PointerEvent event) async {
if (event is! PointerScrollEvent) return;
_accumulatedScrollDelta += event.scrollDelta.dy;
_scrollDebounceTimer?.cancel();
_scrollDebounceTimer = Timer(const Duration(milliseconds: 80), () {
if (_accumulatedScrollDelta.abs() >= _scrollThreshold) {
if (_accumulatedScrollDelta > 0) {
nextPage();
} else {
prevPage();
}
}
_accumulatedScrollDelta = 0;
});
}
// ─── 外部链接处理 ──────────────────────────────────
Future<void> _handleExternalLink(dynamic rawLink) async {
String? link;
if (rawLink is String && rawLink.trim().isNotEmpty) {
link = rawLink.trim();
} else if (rawLink is Map && rawLink['href'] is String) {
link = (rawLink['href'] as String).trim();
}
if (!mounted || link == null || link.isEmpty) return;
final uri = Uri.tryParse(link);
if (uri == null || uri.scheme.isEmpty || uri.scheme == 'javascript') return;
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('打开外部链接'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('是否在浏览器中打开以下链接?'),
const SizedBox(height: 8),
SelectableText(link!, style: const TextStyle(fontSize: 13)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('打开')),
],
),
);
if (shouldOpen == true) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
// ─── WebView 回调 ───────────────────────────────────
Future<void> _onWebViewCreated(InAppWebViewController controller) async {
if (Platform.isAndroid) {
await InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
_controller = controller;
_setHandlers(controller);
WakelockPlus.enable();
}
void _setHandlers(InAppWebViewController controller) {
controller.addJavaScriptHandler(
handlerName: 'onLoadEnd',
callback: (args) {},
);
controller.addJavaScriptHandler(
handlerName: 'onRelocated',
callback: (args) {
final location = args[0] as Map<String, dynamic>;
if (cfi == location['cfi']) return;
setState(() {
cfi = location['cfi'] ?? '';
percentage = double.tryParse(location['percentage']?.toString() ?? '0') ?? 0.0;
chapterTitle = location['chapterTitle'] ?? '';
chapterHref = location['chapterHref'] ?? '';
chapterCurrentPage = location['chapterCurrentPage'] ?? 0;
chapterTotalPages = location['chapterTotalPages'] ?? 0;
});
saveReadingProgress();
},
);
controller.addJavaScriptHandler(
handlerName: 'onClick',
callback: (args) {
final location = args[0] as Map<String, dynamic>;
_onClick(location);
},
);
controller.addJavaScriptHandler(
handlerName: 'onExternalLink',
callback: (args) async {
await _handleExternalLink(args.isNotEmpty ? args.first : null);
},
);
controller.addJavaScriptHandler(
handlerName: 'onSetToc',
callback: (args) {
final List<dynamic> rawToc = args[0];
final toc = _flattenToc(rawToc);
widget.onTocReady?.call(toc);
},
);
// 以下 handler 保留注册以避免 JS 端报错,但不做处理
controller.addJavaScriptHandler(handlerName: 'onSelectionEnd', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onSelectionCleared', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onAnnotationClick', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onSearch', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'renderAnnotations', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onImageClick', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'onFootnoteClose', callback: (args) {});
controller.addJavaScriptHandler(handlerName: 'handleBookmark', callback: (args) {});
controller.addJavaScriptHandler(
handlerName: 'onPushState',
callback: (args) {
final state = args[0] as Map<String, dynamic>;
if (!mounted) return;
setState(() {
_canGoBack = state['canGoBack'] ?? false;
_canGoForward = state['canGoForward'] ?? false;
_showHistory = _canGoBack || _canGoForward;
});
},
);
controller.addJavaScriptHandler(
handlerName: 'onPullUp',
callback: (args) {
widget.showOrHideToolbar();
},
);
}
@override
void dispose() {
_scrollDebounceTimer?.cancel();
saveReadingProgress();
WakelockPlus.disable();
super.dispose();
}
// ─── 阅读信息浮层 ──────────────────────────────────
Widget _buildReadingInfo() {
if (chapterCurrentPage == 0 && percentage == 0.0) {
return const SizedBox.shrink();
}
final isDark = Theme.of(context).brightness == Brightness.dark;
final infoColor = isDark ? Colors.white.withAlpha(130) : Colors.black.withAlpha(130);
final infoStyle = TextStyle(color: infoColor, fontSize: 10);
return IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 50, left: 16, right: 16),
child: Text(
chapterTitle.isNotEmpty ? chapterTitle : widget.book.title,
style: infoStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 24, left: 16, right: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$chapterCurrentPage/$chapterTotalPages', style: infoStyle),
Text('${(percentage * 100).toStringAsFixed(1)}%', style: infoStyle),
_buildBatteryWidget(infoColor),
_buildClockWidget(infoStyle),
],
),
),
],
),
);
}
Widget _buildBatteryWidget(Color color) {
return FutureBuilder<int>(
future: Battery().batteryLevel,
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.battery_std, size: 12, color: color),
const SizedBox(width: 2),
Text('${snapshot.data}%', style: TextStyle(color: color, fontSize: 10)),
],
);
},
);
}
Widget _buildClockWidget(TextStyle style) {
return StreamBuilder<DateTime>(
stream: Stream.periodic(const Duration(seconds: 30), (_) => DateTime.now()),
builder: (context, snapshot) {
final now = snapshot.data ?? DateTime.now();
return Text(
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}',
style: style,
);
},
);
}
// ─── 历史导航胶囊 ──────────────────────────────────
Widget _buildHistoryCapsule() {
final colors = Theme.of(context).colorScheme;
final buttonStyle = TextButton.styleFrom(
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32)),
);
Widget btn(IconData icon, String label, VoidCallback onPressed) {
return TextButton.icon(
icon: Icon(icon, size: 18, color: colors.onSurface.withAlpha(180)),
label: Text(label, style: TextStyle(color: colors.onSurface.withAlpha(180), fontSize: 14)),
onPressed: onPressed,
style: buttonStyle,
);
}
final buttons = <Widget>[];
if (_canGoBack) buttons.add(btn(Icons.arrow_back, '返回', backHistory));
buttons.add(btn(Icons.close, '关闭', () => setState(() => _showHistory = false)));
if (_canGoForward) buttons.add(btn(Icons.arrow_forward, '前进', forwardHistory));
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
child: ClipRRect(
borderRadius: BorderRadius.circular(32),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
height: 32,
decoration: BoxDecoration(
color: colors.surfaceContainer.withAlpha(123),
borderRadius: BorderRadius.circular(32),
border: Border.all(color: colors.outline, width: 0.5),
),
child: Row(mainAxisSize: MainAxisSize.min, children: buttons),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final fileAbsolute = BookFileHelper.instance.resolveAbsolutePath(widget.book.filePath);
final bookUrl = 'http://127.0.0.1:${Server().port}/book/${Uri.encodeComponent(fileAbsolute)}';
final initialCfi = widget.initialCfi ?? widget.book.lastReadCfi;
final url = generateReaderUrl(
fileUrl: bookUrl,
cfi: initialCfi,
backgroundColor: 'FFFBFBF3',
textColor: 'FF343434',
);
return Listener(
onPointerSignal: _handlePointerEvents,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
SizedBox.expand(
child: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: _settings,
onWebViewCreated: _onWebViewCreated,
onReceivedError: (controller, request, error) {
debugPrint('[EpubPlayer] WebView error: ${error.description}');
},
onConsoleMessage: (controller, msg) {
debugPrint('[EpubPlayer] JS: ${msg.message}');
},
),
),
_buildReadingInfo(),
if (_showHistory) _buildHistoryCapsule(),
],
),
),
);
}
}

View File

@@ -1,114 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'epub_player.dart';
/// 进度控制面板 — 滑块跳转 + 章节信息
class ProgressPanel extends StatefulWidget {
final GlobalKey<EpubPlayerState> epubPlayerKey;
const ProgressPanel({super.key, required this.epubPlayerKey});
@override
State<ProgressPanel> createState() => _ProgressPanelState();
}
class _ProgressPanelState extends State<ProgressPanel> {
double _sliderValue = 0.0;
Timer? _debounceTimer;
EpubPlayerState? get _player => widget.epubPlayerKey.currentState;
@override
void initState() {
super.initState();
_sliderValue = _player?.percentage ?? 0.0;
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final player = _player;
final chapterTitle = player?.chapterTitle ?? '';
final currentPage = player?.chapterCurrentPage ?? 0;
final totalPages = player?.chapterTotalPages ?? 0;
final percent = player?.percentage ?? 0.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 章节标题
if (chapterTitle.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
chapterTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface),
),
),
// 滑块行
Row(
children: [
IconButton(
icon: const Icon(Icons.skip_previous, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
onPressed: () => _player?.prevChapter(),
),
Expanded(
child: Slider(
value: _sliderValue.clamp(0.0, 1.0),
onChanged: (value) {
setState(() => _sliderValue = value);
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 100), () {
_player?.goToPercentage(value);
});
},
),
),
IconButton(
icon: const Icon(Icons.skip_next, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
onPressed: () => _player?.nextChapter(),
),
],
),
// 页码和百分比
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_infoItem('$currentPage', '当前页', colors),
_infoItem('$totalPages', '总页数', colors),
_infoItem('${(percent * 100).toStringAsFixed(1)}%', '进度', colors),
],
),
),
],
),
);
}
Widget _infoItem(String value, String label, ColorScheme colors) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 2),
Text(label, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))),
],
);
}
}

View File

@@ -1,187 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/reader_book.dart';
import '../../providers/app_provider.dart';
import '../../utils/reader/book_file_helper.dart';
import 'reading_page.dart';
/// 阅读器书籍详情页
class ReaderBookDetailPage extends StatefulWidget {
final ReaderBook book;
const ReaderBookDetailPage({super.key, required this.book});
@override
State<ReaderBookDetailPage> createState() => _ReaderBookDetailPageState();
}
class _ReaderBookDetailPageState extends State<ReaderBookDetailPage> {
late String _title;
late ReaderBook _book;
@override
void initState() {
super.initState();
_book = widget.book;
_title = _book.title;
}
Future<void> _startReading() async {
// 确保获取最新的book数据
final provider = context.read<AppProvider>();
final latest = provider.readerBooks.firstWhere(
(b) => b.id == _book.id,
orElse: () => _book,
);
if (context.mounted) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ReadingPage(book: latest),
),
);
// 返回后刷新
if (mounted) {
await provider.loadReaderBooks();
final updated = provider.readerBooks.firstWhere(
(b) => b.id == _book.id,
orElse: () => _book,
);
setState(() {
_book = updated;
});
}
}
}
Future<void> _deleteBook() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除书籍'),
content: Text('确定要删除「$_title」吗?\n此操作将同时删除书籍文件。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
await BookFileHelper.instance.deleteBookFiles(_book.id);
await context.read<AppProvider>().removeReaderBook(_book.id);
if (mounted) {
Navigator.pop(context);
}
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: colors.surface,
appBar: AppBar(
title: const Text('书籍详情'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 占位书封
Center(
child: Container(
width: 140,
height: 200,
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant, width: 0.5),
),
child: Icon(Icons.menu_book, size: 64, color: colors.onSurface.withValues(alpha: 0.2)),
),
),
const SizedBox(height: 24),
// 书名
Text('书名', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 4),
Text(_title, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: colors.onSurface)),
const SizedBox(height: 16),
// 文件名
Text('文件', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 4),
Text('${_book.fileName} (.${_book.fileExtension})',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 16),
// 阅读进度
Text('阅读进度', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _book.readingPercentage,
minHeight: 8,
backgroundColor: colors.surfaceContainerHighest,
),
),
),
const SizedBox(width: 12),
Text('${(_book.readingPercentage * 100).toStringAsFixed(1)}%',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)),
],
),
const SizedBox(height: 16),
// 导入时间
Text('导入时间', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 4),
Text(_book.createdAt.toString().substring(0, 10),
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
const SizedBox(height: 40),
// 底部按钮
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _deleteBook,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('删除'),
),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: FilledButton(
onPressed: _startReading,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('阅读'),
),
),
],
),
],
),
),
);
}
}

View File

@@ -1,258 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
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';
/// 书籍阅读页面
class ReadingPage extends StatefulWidget {
final ReaderBook book;
const ReadingPage({super.key, required this.book});
@override
State<ReadingPage> createState() => _ReadingPageState();
}
class _ReadingPageState extends State<ReadingPage> {
final GlobalKey<EpubPlayerState> _epubPlayerKey = GlobalKey<EpubPlayerState>();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final FocusNode _readerFocusNode = FocusNode();
static const _empty = SizedBox.shrink();
bool _toolbarOffstage = true;
Widget _currentPage = const SizedBox.shrink();
bool _serverReady = false;
List<TocItem> _toc = [];
@override
void initState() {
super.initState();
_initServer();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
WidgetsBinding.instance.addPostFrameCallback((_) {
_readerFocusNode.requestFocus();
});
}
Future<void> _initServer() async {
String _ = await BookFileHelper.instance.bookFileRoot;
await Server().start(preferredPort: 0);
if (mounted) setState(() => _serverReady = true);
}
@override
void dispose() {
_epubPlayerKey.currentState?.saveReadingProgress();
Server().stop();
_readerFocusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _showToolbar() => setState(() => _toolbarOffstage = false);
void _hideToolbar() {
setState(() {
_currentPage = _empty;
_toolbarOffstage = true;
});
}
void _toggleToolbar() => _toolbarOffstage ? _showToolbar() : _hideToolbar();
void _onTocReady(List<TocItem> toc) {
if (mounted) setState(() => _toc = toc);
}
void _openTocDrawer() {
_hideToolbar();
_scaffoldKey.currentState?.openDrawer();
}
void _setPanel(Widget panel) {
setState(() => _currentPage = panel);
}
// ─── 键盘快捷键 ──────────────────────────────────
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;
final toolbar = Offstage(
offstage: _toolbarOffstage,
child: PointerInterceptor(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _hideToolbar,
behavior: HitTestBehavior.opaque,
child: Container(color: Colors.black.withAlpha(38)),
),
),
Column(
children: [
AppBar(
backgroundColor: colors.surface.withAlpha(240),
title: Text(widget.book.title, overflow: TextOverflow.ellipsis),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
const Spacer(),
BottomSheet(
onClosing: () {},
enableDrag: false,
builder: (context) => SafeArea(
top: false,
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: StatefulBuilder(
builder: (BuildContext context, StateSetter modalSetState) {
final hasContent = !identical(_currentPage, _empty);
return IntrinsicHeight(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasContent)
Expanded(
child: SingleChildScrollView(child: _currentPage),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
icon: const Icon(Icons.toc),
tooltip: '目录',
onPressed: _openTocDrawer,
),
IconButton(
icon: const Icon(Icons.edit_note),
tooltip: '笔记',
onPressed: () {
modalSetState(() {
_setPanel(_buildNotesPanel());
});
},
),
IconButton(
icon: const Icon(Icons.data_usage),
tooltip: '进度',
onPressed: () {
modalSetState(() {
_setPanel(ProgressPanel(epubPlayerKey: _epubPlayerKey));
});
},
),
IconButton(
icon: const Icon(Icons.skip_previous),
tooltip: '上一章',
onPressed: () {
_epubPlayerKey.currentState?.prevChapter();
},
),
IconButton(
icon: const Icon(Icons.skip_next),
tooltip: '下一章',
onPressed: () {
_epubPlayerKey.currentState?.nextChapter();
},
),
],
),
],
),
);
},
),
),
),
),
],
),
],
),
),
);
return Scaffold(
key: _scaffoldKey,
backgroundColor: colors.surface,
drawer: PointerInterceptor(
child: Drawer(
width: MediaQuery.of(context).size.width * 0.75,
child: TocDrawer(
toc: _toc,
epubPlayerKey: _epubPlayerKey,
),
),
),
body: _serverReady
? Focus(
focusNode: _readerFocusNode,
onKeyEvent: _handleKeyEvent,
autofocus: true,
child: Stack(
children: [
EpubPlayer(
key: _epubPlayerKey,
book: widget.book,
showOrHideToolbar: _toggleToolbar,
onTocReady: _onTocReady,
),
toolbar,
],
),
)
: const Center(child: CircularProgressIndicator()),
);
}
Widget _buildNotesPanel() {
return ReadingNotesPanel(
bookId: widget.book.id,
onNavigate: (cfi) {
_hideToolbar();
_epubPlayerKey.currentState?.goToCfi(cfi);
},
);
}
}

View File

@@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
import 'epub_player.dart';
/// 目录抽屉 — 显示书籍章节目录,点击跳转
class TocDrawer extends StatelessWidget {
final List<TocItem> toc;
final GlobalKey<EpubPlayerState> epubPlayerKey;
final VoidCallback? onClose;
const TocDrawer({
super.key,
required this.toc,
required this.epubPlayerKey,
this.onClose,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
color: colors.surface,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
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(),
IconButton(
icon: const Icon(Icons.close),
onPressed: onClose ?? () => Navigator.pop(context),
),
],
),
),
Divider(height: 1, color: colors.outlineVariant),
Expanded(
child: toc.isEmpty
? Center(
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: EdgeInsets.only(left: 20 + indent, right: 20, top: 12, bottom: 12),
child: Text(
item.title,
style: TextStyle(
fontSize: item.level == 1 ? 14 : 13,
fontWeight: item.level == 1 ? FontWeight.w500 : FontWeight.normal,
color: colors.onSurface.withAlpha(204),
),
),
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -8,8 +8,6 @@ import '../utils/movie/movie_poster_dao.dart';
import '../utils/book/book_review_dao.dart';
import '../utils/book/book_excerpt_dao.dart';
import '../utils/tag/tag_dao.dart';
import '../utils/reader/reader_book_dao.dart';
import '../models/reader_book.dart';
import '../utils/database_helper.dart';
import '../utils/image_path_helper.dart';
import '../utils/user_prefs.dart';
@@ -26,14 +24,11 @@ class AppProvider extends ChangeNotifier {
final BookReviewDao _bookReviewDao = BookReviewDao();
final BookExcerptDao _bookExcerptDao = BookExcerptDao();
final TagDao _tagDao = TagDao();
final ReaderBookDao _readerBookDao = ReaderBookDao();
// 数据列表
List<Movie> _movies = [];
List<Book> _books = [];
List<Note> _notes = [];
List<ReaderBook> _readerBooks = [];
// 当前主界面选中的标签 (0: 观影1: 阅读2: 笔记)
int _mainTabIndex = 0;
@@ -75,12 +70,10 @@ class AppProvider extends ChangeNotifier {
_movieDao.getAllMovies(),
_bookDao.getAllBooks(),
_noteDao.getAllNotes(),
_readerBookDao.getAllReaderBooks(),
]);
_movies = results[0] as List<Movie>;
_books = results[1] as List<Book>;
_notes = results[2] as List<Note>;
_readerBooks = results[3] as List<ReaderBook>;
debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}');
notifyListeners();
}
@@ -128,26 +121,6 @@ class AppProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> loadReaderBooks() async {
_readerBooks = await _readerBookDao.getAllReaderBooks();
notifyListeners();
}
Future<void> addReaderBook(ReaderBook book) async {
await _readerBookDao.insertReaderBook(book);
await loadReaderBooks();
}
Future<void> updateReaderBook(ReaderBook book) async {
await _readerBookDao.updateReaderBook(book);
await loadReaderBooks();
}
Future<void> removeReaderBook(String id) async {
await _readerBookDao.deleteReaderBook(id);
await loadReaderBooks();
}
// ─── 分页加载(供列表页触底加载使用)────────────────────────
static const int _pageSize = 20;
@@ -177,8 +150,7 @@ class AppProvider extends ChangeNotifier {
List<Movie> get movies => _movies;
List<Book> get books => _books;
List<Note> get notes => _notes;
List<ReaderBook> get readerBooks => _readerBooks;
// 根据状态获取影视列表
List<Movie> getMoviesByStatus(String status) {
return _movies.where((movie) => movie.status == status).toList();

View File

@@ -1,75 +0,0 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
import '../models/reader_book.dart';
import '../providers/app_provider.dart';
import '../utils/reader/book_file_helper.dart';
/// 书籍导入服务
class BookImportService {
static const allowedExtensions = ['epub', 'txt'];
static Future<ReaderBook?> pickAndImportBook(
BuildContext context,
AppProvider provider,
) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: allowedExtensions,
allowMultiple: false,
);
if (result == null || result.files.isEmpty) return null;
final platformFile = result.files.first;
final sourcePath = platformFile.path;
if (sourcePath == null) return null;
final file = File(sourcePath);
if (!await file.exists()) return null;
final extension = p.extension(file.path).replaceAll('.', '').toLowerCase();
if (!allowedExtensions.contains(extension)) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('不支持的格式:$extension')),
);
}
return null;
}
final title = p.basenameWithoutExtension(platformFile.name);
final helper = BookFileHelper.instance;
final id = const Uuid().v4();
// 清理文件名,去除特殊字符
final safeName = platformFile.name.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
final destPath = await helper.bookFile(id, safeName);
// 复制文件
await file.copy(destPath);
final now = DateTime.now();
final readerBook = ReaderBook(
id: id,
title: title,
fileName: platformFile.name,
filePath: '$id/$safeName', // 相对路径
fileExtension: extension,
createdAt: now,
updatedAt: now,
);
await provider.addReaderBook(readerBook);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$title」导入成功')),
);
}
return readerBook;
}
}

View File

@@ -1,98 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
/// 本地 HTTP 服务器,为 WebView 提供书籍文件和 foliate-js 资源
class Server {
static final Server _singleton = Server._internal();
factory Server() => _singleton;
Server._internal();
HttpServer? _server;
bool get isRunning => _server != null;
int get port => _server?.port ?? 0;
Future<void> start({int preferredPort = 0}) async {
if (_server != null) {
await stop();
}
final handler = const shelf.Pipeline()
.addMiddleware(shelf.logRequests())
.addHandler(_handleRequest);
try {
_server = await io.serve(handler, '127.0.0.1', preferredPort);
} catch (_) {
_server = await io.serve(handler, '127.0.0.1', 0);
}
}
Future<void> stop() async {
if (_server == null) return;
await _server!.close(force: true);
_server = null;
}
Future<shelf.Response> _handleRequest(shelf.Request request) async {
final uriPath = request.requestedUri.path;
// 书籍文件请求
if (uriPath.startsWith('/book/')) {
final bookPath = Uri.decodeComponent(uriPath.substring(6));
final file = File(bookPath);
if (!await file.exists()) {
return shelf.Response.notFound('Book not found');
}
return shelf.Response.ok(
file.openRead(),
headers: {
'Content-Type': 'application/epub+zip',
'Access-Control-Allow-Origin': '*',
},
);
}
// foliate-js 资源请求
if (uriPath.startsWith('/foliate-js/')) {
final assetPath = 'assets/foliate-js/${uriPath.substring(12)}';
String contentType;
if (uriPath.endsWith('.html')) {
contentType = 'text/html';
} else if (uriPath.endsWith('.css')) {
contentType = 'text/css';
} else if (uriPath.endsWith('.js') || uriPath.endsWith('.mjs')) {
contentType = 'application/javascript';
} else if (uriPath.endsWith('.json')) {
contentType = 'application/json';
} else if (uriPath.endsWith('.svg')) {
contentType = 'image/svg+xml';
} else {
contentType = 'application/octet-stream';
}
try {
// 优先尝试 load() 加载为字节流(最可靠),再转为字符串或直接返回
final data = await rootBundle.load(assetPath);
return shelf.Response.ok(
data.buffer.asUint8List(),
headers: {
'Content-Type': contentType,
'Access-Control-Allow-Origin': '*',
},
);
} catch (e) {
debugPrint('[Server] Asset not found: $assetPath error=$e');
return shelf.Response.notFound('Asset not found: $assetPath');
}
}
return shelf.Response.ok(
'OK',
headers: {'Access-Control-Allow-Origin': '*'},
);
}
}

View File

@@ -98,7 +98,22 @@ class DatabaseHelper {
await _upgradeToV13(db);
}
if (oldVersion < 14) {
await _createReaderBooksTable(db);
await db.execute('''
CREATE TABLE IF NOT EXISTS reader_books (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT DEFAULT '',
cover_path TEXT,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_extension TEXT NOT NULL DEFAULT 'epub',
last_read_cfi TEXT DEFAULT '',
reading_percentage REAL DEFAULT 0.0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
}
if (oldVersion < 15) {
// 安全添加 cover_offset 列(防止列已存在时报错)
@@ -158,7 +173,26 @@ class DatabaseHelper {
}
if (oldVersion < 23) {
// 创建书籍批注表(高亮、下划线、书签)
await _createBookAnnotationsTable(db);
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
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_book_id ON book_annotations(book_id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
);
}
}
@@ -208,26 +242,6 @@ class DatabaseHelper {
}
}
/// 升级到V14创建阅读器书籍表
Future<void> _createReaderBooksTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS reader_books (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT DEFAULT '',
cover_path TEXT,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_extension TEXT NOT NULL DEFAULT 'epub',
last_read_cfi TEXT DEFAULT '',
reading_percentage REAL DEFAULT 0.0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0
)
''');
}
/// 升级到V13创建标签表并回填已有数据
Future<void> _upgradeToV13(Database db) async {
await db.execute('''
@@ -667,7 +681,10 @@ class DatabaseHelper {
)
''');
// 阅读器书籍
// Note Plus 块编辑器文档
await _createNotePlusTable(db);
// EPUB 阅读器书籍表
await db.execute('''
CREATE TABLE IF NOT EXISTS reader_books (
id TEXT PRIMARY KEY,
@@ -685,11 +702,21 @@ class DatabaseHelper {
)
''');
// Note Plus 块编辑器文档表
await _createNotePlusTable(db);
// 书籍批注表
await _createBookAnnotationsTable(db);
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
)
''');
}
/// 创建 Note Plus 文档表
@@ -710,32 +737,6 @@ class DatabaseHelper {
''');
}
/// 创建书籍批注表(高亮、下划线、书签)
Future<void> _createBookAnnotationsTable(Database db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS book_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
cfi TEXT NOT NULL DEFAULT '',
chapter TEXT DEFAULT '',
type TEXT NOT NULL DEFAULT 'highlight',
color TEXT NOT NULL DEFAULT 'FFEB3B',
reader_note TEXT DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
// 索引:按 book_id 查询加速
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_book_id ON book_annotations(book_id)',
);
// 索引:按 book_id + type 查询加速
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_book_annotations_type ON book_annotations(book_id, type)',
);
}
// 关闭数据库
Future close() async {
if (_database != null) {

View File

@@ -0,0 +1,477 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
import 'reader_models.dart';
/// EPUB 解析器 - 从 ZIP 归档中解析 EPUB 结构
class EpubParser {
/// 从文件路径解析 EPUB
Future<EpubBookInfo?> parseFromFile(String filePath,
{String? fileName}) async {
try {
debugPrint('[EpubParser] 开始解析: $filePath');
final bytes = await File(filePath).readAsBytes();
debugPrint('[EpubParser] 读取 ${bytes.length} 字节');
final archive = ZipDecoder().decodeBytes(bytes);
debugPrint('[EpubParser] ZIP 解码成功, ${archive.files.length} 个文件');
return _parseFromArchive(archive, fileName: fileName);
} catch (e, stack) {
debugPrint('[EpubParser] 解析失败: $e');
debugPrint('[EpubParser] $stack');
return null;
}
}
EpubBookInfo? _parseFromArchive(Archive archive, {String? fileName}) {
try {
// 检查加密(只拒绝真正阻止内容读取的加密,忽略字体混淆等)
final encFile = archive.findFile('META-INF/encryption.xml');
if (encFile != null) {
try {
final encContent = utf8.decode(encFile.content as List<int>);
final encDoc = XmlDocument.parse(encContent);
// 如果有 EncryptedData 且不是字体文件,则拒绝
final encryptedData = encDoc.findAllElements('EncryptedData');
for (final ed in encryptedData) {
final cipherRef = ed.findAllElements('CipherReference').firstOrNull;
final uri = cipherRef?.getAttribute('URI') ?? '';
// 非字体文件被加密 → 真正的 DRM
if (!uri.endsWith('.ttf') &&
!uri.endsWith('.otf') &&
!uri.endsWith('.woff') &&
!uri.endsWith('.woff2')) {
debugPrint('[EpubParser] 内容加密的 EPUB不支持: $uri');
return null;
}
}
debugPrint('[EpubParser] 仅字体混淆,继续解析');
} catch (e) {
debugPrint('[EpubParser] encryption.xml 解析失败,跳过: $e');
}
}
final opfPath = _findOpfPath(archive);
debugPrint('[EpubParser] OPF 路径: $opfPath');
if (opfPath == null) return null;
final opfFile = archive.findFile(opfPath);
debugPrint('[EpubParser] OPF 文件: ${opfFile != null ? '找到' : '未找到'}');
if (opfFile == null) return null;
final opfContent = utf8.decode(opfFile.content as List<int>);
debugPrint('[EpubParser] OPF 内容长度: ${opfContent.length}');
return _parseOpf(opfContent, opfPath, archive, fileName);
} catch (e, stack) {
debugPrint('[EpubParser] _parseFromArchive 失败: $e');
debugPrint('[EpubParser] $stack');
return null;
}
}
/// 查找 OPF 文件路径
String? _findOpfPath(Archive archive) {
// 策略1: 解析 container.xml
final containerFile = archive.findFile('META-INF/container.xml');
if (containerFile != null) {
try {
final content = utf8.decode(containerFile.content as List<int>);
final doc = XmlDocument.parse(content);
final rootfile = doc.findAllElements('rootfile').firstOrNull;
if (rootfile != null) {
final fullPath = rootfile.getAttribute('full-path');
if (fullPath != null) return fullPath;
}
} catch (_) {}
}
// 策略2: 常见路径
const commonPaths = [
'content.opf',
'OEBPS/content.opf',
'OPS/content.opf',
'EPUB/content.opf',
];
for (final path in commonPaths) {
if (archive.findFile(path) != null) return path;
}
// 策略3: 扫描 .opf 文件
for (final file in archive.files) {
if (file.name.endsWith('.opf')) return file.name;
}
return null;
}
/// 解析 OPF 文件
EpubBookInfo? _parseOpf(
String content, String opfPath, Archive archive, String? fileName) {
final opfDir =
opfPath.contains('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
final doc = XmlDocument.parse(content);
final package = doc.rootElement;
final version = package.getAttribute('version') ?? '2.0';
final metadata = package.findElements('metadata').firstOrNull;
final manifest = package.findElements('manifest').firstOrNull;
final spine = package.findElements('spine').firstOrNull;
debugPrint('[EpubParser] metadata=${metadata != null}, manifest=${manifest != null}, spine=${spine != null}');
if (metadata == null || manifest == null || spine == null) return null;
// 解析 manifest (id -> href)
final manifestMap = <String, String>{};
final manifestProperties = <String, String>{};
for (final item in manifest.findElements('item')) {
final id = item.getAttribute('id');
final href = item.getAttribute('href');
final properties = item.getAttribute('properties');
if (id != null && href != null) {
manifestMap[id] = _resolveRelativePath(opfDir, _normalizePath(href));
if (properties != null) manifestProperties[id] = properties;
}
}
// 解析 metadata
final titles = _findByLocalName(metadata, 'title')
.map((e) => e.innerText.trim())
.where((t) => t.isNotEmpty)
.toList();
final authors = _findByLocalName(metadata, 'creator')
.map((e) => e.innerText.trim())
.where((a) => a.isNotEmpty)
.toList();
final description =
_findByLocalName(metadata, 'description').firstOrNull?.innerText.trim();
// 解析 spine
final spineItems = <SpineItem>[];
final spineIndexMap = <String, int>{};
int index = 0;
for (final itemref in spine.findElements('itemref')) {
final idref = itemref.getAttribute('idref');
final linearAttr = itemref.getAttribute('linear');
final isLinear =
linearAttr == null || linearAttr.toLowerCase() != 'no';
if (idref != null && manifestMap.containsKey(idref)) {
final href = manifestMap[idref]!;
spineItems.add(SpineItem(
index: index,
href: href,
idref: idref,
linear: isLinear,
));
spineIndexMap[href] = index;
index++;
}
}
// 解析 TOC
List<TocEntry> toc = [];
// EPUB 3 NAV 文档
String? navId;
for (final entry in manifestProperties.entries) {
if (_containsWholeWord(entry.value, 'nav')) {
navId = entry.key;
break;
}
}
if (navId != null && manifestMap.containsKey(navId)) {
final navPath = manifestMap[navId]!;
final navFile = archive.findFile(navPath);
if (navFile != null) {
try {
final navContent = utf8.decode(navFile.content as List<int>);
final navDir = navPath.contains('/')
? navPath.substring(0, navPath.lastIndexOf('/'))
: '';
toc = _parseNav(navContent, navDir, spineIndexMap);
} catch (_) {}
}
}
// EPUB 2 NCX 回退
if (toc.isEmpty) {
final tocId = spine.getAttribute('toc');
if (tocId != null && manifestMap.containsKey(tocId)) {
final tocPath = manifestMap[tocId]!;
final tocFile = archive.findFile(tocPath);
if (tocFile != null) {
try {
final tocContent = utf8.decode(tocFile.content as List<int>);
final ncxDir = tocPath.contains('/')
? tocPath.substring(0, tocPath.lastIndexOf('/'))
: '';
toc = _parseNcx(tocContent, ncxDir, spineIndexMap);
} catch (_) {}
}
}
}
// 最终回退: 从 spine 生成平坦目录
if (toc.isEmpty) {
int chNum = 1;
for (final si in spineItems) {
if (!si.linear) continue;
toc.add(TocEntry(
label: '$chNum',
href: '${si.href}#top',
spineIndex: si.index,
));
chNum++;
}
}
// 检测封面
String? coverHref = _detectCover(metadata, manifestMap, manifestProperties, archive, opfDir);
final title = titles.isNotEmpty
? titles.first
: (fileName ?? '').split('/').last.split('.').first;
return EpubBookInfo(
title: title,
author: authors.isNotEmpty ? authors.first : '',
authors: authors,
description: description,
coverHref: coverHref,
opfRootPath: opfPath,
epubVersion: version,
spine: spineItems,
toc: toc,
);
}
/// 检测封面图片路径(相对于 OPF 目录)
String? _detectCover(
XmlElement metadata,
Map<String, String> manifestMap,
Map<String, String> manifestProperties,
Archive archive,
String opfDir,
) {
// 策略1: meta name="cover"
final coverMeta = metadata
.findAllElements('meta')
.where((e) => e.getAttribute('name') == 'cover')
.firstOrNull;
if (coverMeta != null) {
final coverId = coverMeta.getAttribute('content');
if (coverId != null && manifestMap.containsKey(coverId)) {
final href = manifestMap[coverId]!;
if (_isImageFile(href)) return href;
}
}
// 策略2: manifest 属性包含 cover-image
for (final entry in manifestProperties.entries) {
if (_containsWholeWord(entry.value, 'cover-image')) {
if (manifestMap.containsKey(entry.key)) {
final href = manifestMap[entry.key]!;
if (_isImageFile(href)) return href;
}
}
}
// 策略3: 常见文件名
for (final key in manifestMap.keys) {
final lower = key.toLowerCase();
if (lower == 'cover.jpg' ||
lower == 'cover.png' ||
lower == 'cover.jpeg' ||
lower == 'cover.webp') {
return manifestMap[key]!;
}
}
// 策略4: guide 中的 cover 引用
final guideElement = XmlDocument.parse(
'<root>${metadata.parent?.toXmlString() ?? ''}</root>')
.rootElement
.findElements('guide')
.firstOrNull;
if (guideElement != null) {
for (final ref in guideElement.findElements('reference')) {
final type = ref.getAttribute('type') ?? '';
if (type.toLowerCase() == 'cover') {
final href = ref.getAttribute('href');
if (href != null) {
final resolved = _resolveRelativePath(opfDir, href);
if (_isImageFile(resolved)) return resolved;
// href 可能指向一个 XHTML 文件,需要从中提取图片
final coverFile = archive.findFile(resolved);
if (coverFile != null) {
try {
final html = utf8.decode(coverFile.content as List<int>);
final imgSrc = _extractFirstImage(html);
if (imgSrc != null) {
final hrefDir = resolved.contains('/')
? resolved.substring(0, resolved.lastIndexOf('/'))
: '';
return _resolveRelativePath(hrefDir, imgSrc);
}
} catch (_) {}
}
}
}
}
}
return null;
}
String? _extractFirstImage(String html) {
final imgReg = RegExp(r'<img[^>]+src="([^">]+)"', caseSensitive: false);
final match = imgReg.firstMatch(html);
return match?.group(1);
}
/// 解析 EPUB 3 NAV 文档
List<TocEntry> _parseNav(
String content, String navDir, Map<String, int> spineIndexMap) {
try {
final doc = XmlDocument.parse(content);
final navElement = doc.findAllElements('nav').where((el) {
final epubType = el.getAttribute('epub:type') ??
el.getAttribute('type') ??
'';
return _containsWholeWord(epubType, 'toc');
}).firstOrNull;
if (navElement == null) return [];
final rootOl = navElement.childElements
.where((el) => el.localName == 'ol')
.firstOrNull;
if (rootOl == null) return [];
return _parseNavListItems(rootOl.findElements('li'), navDir, spineIndexMap);
} catch (_) {
return [];
}
}
List<TocEntry> _parseNavListItems(
Iterable<XmlElement> items, String baseDir, Map<String, int> spineIndexMap) {
final entries = <TocEntry>[];
for (final li in items) {
final anchor = li.childElements
.where((el) => el.localName == 'a' || el.localName == 'span')
.firstOrNull;
final label =
anchor?.innerText.trim().isNotEmpty == true ? anchor!.innerText.trim() : 'Chapter';
final hrefValue =
anchor?.localName == 'a' ? anchor!.getAttribute('href') : null;
String href = '';
int spineIdx = -1;
if (hrefValue != null && hrefValue.trim().isNotEmpty) {
final resolved = _resolveRelativePath(baseDir, hrefValue);
href = resolved;
final pathOnly = href.split('#').first;
spineIdx = spineIndexMap[pathOnly] ?? -1;
}
final nestedOl = li.childElements
.where((el) => el.localName == 'ol')
.firstOrNull;
final children = nestedOl != null
? _parseNavListItems(nestedOl.findElements('li'), baseDir, spineIndexMap)
: <TocEntry>[];
entries.add(TocEntry(
label: label,
href: href,
spineIndex: spineIdx,
children: children,
));
}
return entries;
}
/// 解析 EPUB 2 NCX 文档
List<TocEntry> _parseNcx(
String content, String baseDir, Map<String, int> spineIndexMap) {
try {
final doc = XmlDocument.parse(content);
final navMap = doc.findAllElements('navMap').firstOrNull;
if (navMap == null) return [];
return _parseNavPoints(navMap.findElements('navPoint'), baseDir, spineIndexMap);
} catch (_) {
return [];
}
}
List<TocEntry> _parseNavPoints(
Iterable<XmlElement> navPoints, String baseDir, Map<String, int> spineIndexMap) {
final entries = <TocEntry>[];
for (final np in navPoints) {
final label = np
.findElements('navLabel')
.firstOrNull
?.findElements('text')
.firstOrNull
?.innerText
.trim() ??
'Chapter';
final src = np.findElements('content').firstOrNull?.getAttribute('src') ?? '';
final resolved = _resolveRelativePath(baseDir, src);
final pathOnly = resolved.split('#').first;
final spineIdx = spineIndexMap[pathOnly] ?? -1;
final children = _parseNavPoints(np.findElements('navPoint'), baseDir, spineIndexMap);
entries.add(TocEntry(
label: label,
href: resolved,
spineIndex: spineIdx,
children: children,
));
}
return entries;
}
// ─── 辅助方法 ─────────────────────────────────────────────────
Iterable<XmlElement> _findByLocalName(XmlElement parent, String name) {
return parent.descendantElements.where((e) => e.localName == name);
}
bool _containsWholeWord(String? value, String word) {
if (value == null || value.trim().isEmpty) return false;
return RegExp('\\b${RegExp.escape(word)}\\b', caseSensitive: false)
.hasMatch(value);
}
String _resolveRelativePath(String baseDir, String relativePath) {
if (baseDir.isEmpty) return relativePath;
final baseUri = Uri.parse(baseDir.endsWith('/') ? baseDir : '$baseDir/');
final resolved = baseUri.resolve(relativePath);
String result = resolved.toString();
if (result.startsWith('/')) result = result.substring(1);
return Uri.decodeFull(result);
}
String _normalizePath(String path) {
path = path.trim();
while (path.startsWith('/')) {
path = path.substring(1);
}
while (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
path = path.replaceAll(RegExp(r'/+'), '/');
return path;
}
bool _isImageFile(String path) {
final lower = path.toLowerCase();
return lower.endsWith('.jpg') ||
lower.endsWith('.jpeg') ||
lower.endsWith('.png') ||
lower.endsWith('.webp');
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
import 'epub_parser.dart';
import 'reader_dao.dart';
import 'reader_models.dart';
/// EPUB 服务层 - 管理导入、解压、删除
class EpubService {
final ReaderDao _dao = ReaderDao();
final EpubParser _parser = EpubParser();
static const _uuid = Uuid();
/// 导入 EPUB 文件
/// 返回 {'bookId': ..., 'title': ...} 或 null解析失败
Future<Map<String, dynamic>?> importBook(String sourcePath) async {
final bookId = _uuid.v4();
final now = DateTime.now().toIso8601String();
final fileName = p.basename(sourcePath);
// 复制 EPUB 到永久存储FilePicker 临时文件会被清理)
final appDir = await getApplicationDocumentsDirectory();
final booksDir = Directory(p.join(appDir.path, 'epub_books'));
if (!await booksDir.exists()) await booksDir.create(recursive: true);
final permanentPath = p.join(booksDir.path, '$bookId.epub');
await File(sourcePath).copy(permanentPath);
// 从永久副本解析
final info = await _parser.parseFromFile(
permanentPath,
fileName: fileName,
);
if (info == null) return null;
// 解压到临时目录
final extractDir = await getExtractDir(bookId);
await _extractEpub(permanentPath, extractDir);
// 提取封面
String? coverPath;
if (info.coverHref != null) {
coverPath = await _extractCover(info, extractDir, bookId);
}
// 写入数据库file_path 存永久路径)
await _dao.insertReaderBook({
'id': bookId,
'title': info.title,
'author': info.author,
'cover_path': coverPath,
'file_path': permanentPath,
'file_name': fileName,
'file_extension': 'epub',
'last_read_cfi': '',
'reading_percentage': 0.0,
'created_at': now,
'updated_at': now,
'is_deleted': 0,
});
return {'bookId': bookId, 'title': info.title};
}
/// 解压 EPUB 到目标目录
Future<void> _extractEpub(String sourcePath, String targetDir) async {
final dir = Directory(targetDir);
if (await dir.exists()) await dir.delete(recursive: true);
await dir.create(recursive: true);
final bytes = await File(sourcePath).readAsBytes();
final archive = ZipDecoder().decodeBytes(bytes);
for (final file in archive) {
final filePath = p.join(targetDir, file.name);
if (file.isFile) {
final outFile = File(filePath);
await outFile.parent.create(recursive: true);
await outFile.writeAsBytes(file.content as List<int>);
} else {
await Directory(filePath).create(recursive: true);
}
}
}
/// 提取封面图片
Future<String?> _extractCover(
EpubBookInfo info, String extractDir, String bookId) async {
try {
final opfDir = info.opfRootPath.contains('/')
? info.opfRootPath.substring(0, info.opfRootPath.lastIndexOf('/'))
: '';
final coverRelPath = opfDir.isEmpty
? info.coverHref!
: '$opfDir/${info.coverHref!}';
final coverFile = File(p.join(extractDir, coverRelPath));
if (!await coverFile.exists()) return null;
// 保存到应用文档目录
final appDir = await getApplicationDocumentsDirectory();
final coverDir = p.join(appDir.path, 'images', 'books', bookId);
await Directory(coverDir).create(recursive: true);
final ext = p.extension(coverFile.path).toLowerCase();
final destPath = p.join(coverDir, 'cover$ext');
await coverFile.copy(destPath);
return destPath;
} catch (_) {
return null;
}
}
/// 获取解压目录
Future<String> getExtractDir(String bookId) async {
final tempDir = await getTemporaryDirectory();
return p.join(tempDir.path, 'epub', bookId);
}
/// 确保已解压(如果临时目录被清理则重新解压)
/// 返回解压目录路径,失败返回 null
Future<String?> ensureExtracted(String bookId, String filePath) async {
final extractDir = await getExtractDir(bookId);
final dir = Directory(extractDir);
if (await dir.exists()) {
final files = dir.listSync();
if (files.isNotEmpty) return extractDir;
}
// 重新解压
if (!await File(filePath).exists()) return null;
await _extractEpub(filePath, extractDir);
return extractDir;
}
/// 删除书籍
Future<void> deleteBook(String bookId) async {
// 清理解压目录
try {
final extractDir = await getExtractDir(bookId);
final dir = Directory(extractDir);
if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {}
// 清理封面
try {
final appDir = await getApplicationDocumentsDirectory();
final coverDir = p.join(appDir.path, 'images', 'books', bookId);
final dir = Directory(coverDir);
if (await dir.exists()) await dir.delete(recursive: true);
} catch (_) {}
// 清理永久 EPUB 文件
try {
final appDir = await getApplicationDocumentsDirectory();
final epubFile = File(p.join(appDir.path, 'epub_books', '$bookId.epub'));
if (await epubFile.exists()) await epubFile.delete();
} catch (_) {}
// 软删除数据库记录
await _dao.deleteReaderBook(bookId);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
class EpubStreamService {
String? _currentBookPath;
String? _pendingBookPath;
Future<void>? _openBookFuture;
/// Cached decoded archive for the current book.
Archive? _cachedArchive;
Future<void> warmUp() async {}
Future<void> openBook(String epubPath) {
if (_currentBookPath == epubPath && _cachedArchive != null) {
return Future.value();
}
if (_pendingBookPath == epubPath && _openBookFuture != null) {
return _openBookFuture!;
}
_pendingBookPath = epubPath;
_openBookFuture = _doOpenBook(epubPath);
return _openBookFuture!;
}
Future<void> _doOpenBook(String epubPath) async {
try {
final bytes = await File(epubPath).readAsBytes();
_cachedArchive = ZipDecoder().decodeBytes(bytes);
_currentBookPath = epubPath;
} catch (e) {
_currentBookPath = null;
_cachedArchive = null;
rethrow;
} finally {
if (_pendingBookPath == epubPath) {
_pendingBookPath = null;
_openBookFuture = null;
}
}
}
/// Read a single file from the currently open EPUB archive.
/// Returns the file bytes, or null if not found / no book loaded.
Future<Uint8List?> readFileFromEpub({
required String targetFilePath,
String? epubPath,
}) async {
if (epubPath != null && epubPath != _currentBookPath) {
await openBook(epubPath);
}
if (_currentBookPath == null || _cachedArchive == null) {
return null;
}
final file = _cachedArchive!.findFile(targetFilePath);
if (file == null || file.content == null) {
return null;
}
return file.content is Uint8List
? file.content as Uint8List
: Uint8List.fromList(file.content as List<int>);
}
void dispose() {
_cachedArchive = null;
_currentBookPath = null;
_pendingBookPath = null;
_openBookFuture = null;
}
String getMimeType(String filePath) {
final ext = p.extension(filePath).toLowerCase().replaceAll('.', '');
return _mimeTypeMap[ext] ?? 'application/octet-stream';
}
static const _mimeTypeMap = {
'html': 'text/html',
'htm': 'text/html',
'xhtml': 'application/xhtml+xml',
'xml': 'application/xml',
'css': 'text/css',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ttf': 'font/ttf',
'otf': 'font/otf',
'woff': 'font/woff',
'woff2': 'font/woff2',
'js': 'application/javascript',
};
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'reader_scripts.dart';
class EpubTheme {
final double zoom;
final bool shouldOverrideTextColor;
final ColorScheme colorScheme;
final Color? overridePrimaryColor;
final EdgeInsets padding;
/// File name (with extension) of the custom font, or null for epub default.
final String? fontFileName;
/// When true, force the custom font on top of the epub's own font rules.
final bool overrideFontFamily;
EpubTheme({
required this.zoom,
required this.shouldOverrideTextColor,
required this.colorScheme,
this.overridePrimaryColor,
required this.padding,
this.fontFileName,
this.overrideFontFamily = false,
});
bool get isDark => colorScheme.brightness == Brightness.dark;
Color get surfaceColor => colorScheme.surface;
EpubTheme copyWith({
double? zoom,
bool? shouldOverrideTextColor,
ColorScheme? colorScheme,
Color? overridePrimaryColor,
EdgeInsets? padding,
Object? fontFileName = _kUnset,
bool? overrideFontFamily,
}) {
return EpubTheme(
zoom: zoom ?? this.zoom,
shouldOverrideTextColor:
shouldOverrideTextColor ?? this.shouldOverrideTextColor,
colorScheme: colorScheme ?? this.colorScheme,
overridePrimaryColor: overridePrimaryColor ?? this.overridePrimaryColor,
padding: padding ?? this.padding,
fontFileName: identical(fontFileName, _kUnset)
? this.fontFileName
: fontFileName as String?,
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
);
}
static const Object _kUnset = Object();
Map<String, dynamic> toThemeMap() {
return {
'padding': {'top': padding.top, 'left': padding.left},
'theme': {
'zoom': zoom,
'shouldOverrideTextColor': shouldOverrideTextColor,
'primaryColor': overridePrimaryColor != null
? colorToMap(overridePrimaryColor!)
: colorToMap(colorScheme.primary),
'onPrimaryColor': colorToMap(colorScheme.onPrimary),
'secondaryColor': colorToMap(colorScheme.secondary),
'onSecondaryColor': colorToMap(colorScheme.onSecondary),
'errorColor': colorToMap(colorScheme.error),
'onErrorColor': colorToMap(colorScheme.onError),
'surfaceColor': colorToMap(colorScheme.surface),
'onSurfaceColor': colorToMap(colorScheme.onSurface),
'primaryContainerColor': colorToMap(colorScheme.primaryContainer),
'onSurfaceVariantColor': colorToMap(colorScheme.onSurfaceVariant),
'outlineVariantColor': colorToMap(colorScheme.outlineVariant),
'surfaceContainerColor': colorToMap(colorScheme.surfaceContainer),
'surfaceContainerHighColor': colorToMap(
colorScheme.surfaceContainerHigh,
),
'fontFileName': fontFileName,
'overrideFontFamily': overrideFontFamily,
},
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is EpubTheme &&
other.zoom == zoom &&
other.shouldOverrideTextColor == shouldOverrideTextColor &&
other.colorScheme == colorScheme &&
other.overridePrimaryColor == overridePrimaryColor &&
other.padding == padding &&
other.fontFileName == fontFileName &&
other.overrideFontFamily == overrideFontFamily;
}
@override
int get hashCode => Object.hash(
zoom,
shouldOverrideTextColor,
colorScheme,
overridePrimaryColor,
padding,
fontFileName,
overrideFontFamily,
);
}

View File

@@ -0,0 +1,258 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path_provider/path_provider.dart';
import 'epub_stream_service.dart';
/// Simple file reference with path and optional anchor.
class Href {
final String path;
final String anchor;
const Href({required this.path, this.anchor = 'top'});
@override
String toString() => '$path#$anchor';
}
/// WebView request handler for streaming EPUB content.
/// Intercepts requests to virtual domain and serves files from compressed EPUB.
class EpubWebViewHandler {
final EpubStreamService _streamService;
/// Virtual domain for EPUB content.
/// Format: epub://localhost/book/{fileHash}/{filePath}
static const String virtualDomain = 'localhost';
static const String virtualScheme = 'epub';
static const _headers = {'Cache-Control': 'public, max-age=31536000'};
EpubWebViewHandler({required EpubStreamService streamService})
: _streamService = streamService;
/// Cached documents directory path.
static String? _documentsPath;
static Future<String> getDocumentsPath() async {
if (_documentsPath != null) return _documentsPath!;
final dir = await getApplicationDocumentsDirectory();
_documentsPath = '${dir.path}/';
return _documentsPath!;
}
/// Create WebView resource request handler.
/// This should be set as the shouldInterceptRequest callback.
Future<WebResourceResponse?> handleRequest({
required String epubPath,
required String fileHash,
required WebUri requestUrl,
}) async {
try {
// Serve user-imported fonts.
if (isFontRequest(requestUrl)) {
final fontResult = await _readFontFile(requestUrl);
if (fontResult == null) {
return WebResourceResponse(
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List.fromList('Font not found'.codeUnits),
);
}
return WebResourceResponse(
contentType: fontResult.$2,
statusCode: 200,
reasonPhrase: 'OK',
data: fontResult.$1,
headers: _headers,
);
}
// Read file from EPUB
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
if (result == null) {
return WebResourceResponse(
statusCode: 404,
reasonPhrase: 'Not Found',
data: Uint8List.fromList('File not found'.codeUnits),
);
}
return WebResourceResponse(
contentType: result.$2,
statusCode: 200,
reasonPhrase: 'OK',
data: result.$1,
headers: _headers,
);
} catch (e) {
return WebResourceResponse(
statusCode: 500,
reasonPhrase: 'Internal Server Error',
data: Uint8List.fromList('Error: $e'.codeUnits),
);
}
}
Future<CustomSchemeResponse?> handleRequestWithCustomScheme({
required String epubPath,
required String fileHash,
required WebUri requestUrl,
}) async {
try {
// Serve user-imported fonts.
if (isFontRequest(requestUrl)) {
final fontResult = await _readFontFile(requestUrl);
if (fontResult == null) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('Font not found'.codeUnits),
);
}
return CustomSchemeResponse(
contentType: fontResult.$2,
data: fontResult.$1,
);
}
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
if (result == null) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('File not found'.codeUnits),
);
}
return CustomSchemeResponse(
contentType: result.$2,
data: result.$1,
);
} catch (e) {
return CustomSchemeResponse(
contentType: 'text/plain',
data: Uint8List.fromList('Error reading file: $e'.codeUnits),
);
}
}
/// Read a file from an EPUB.
/// Returns (data, mimeType) or null on failure.
Future<(Uint8List, String)?> _readFileFromEpub(
String epubPath,
String fileHash,
WebUri requestUrl,
) async {
final prefix = "/book/$fileHash/";
if (!requestUrl.path.startsWith(prefix)) {
return null;
}
final decodedPath = Uri.decodeFull(requestUrl.path);
final relativePath = decodedPath.substring(prefix.length);
final fileRelativePath = relativePath.split('#')[0];
final data = await _streamService.readFileFromEpub(
epubPath: epubPath,
targetFilePath: fileRelativePath,
);
if (data == null) return null;
final mimeType = _streamService.getMimeType(fileRelativePath);
return (data, mimeType);
}
/// Reads a font file from the app's fonts directory.
/// URL format: epub://localhost/fonts/{fileName}
Future<(Uint8List, String)?> _readFontFile(WebUri requestUrl) async {
const prefix = '/fonts/';
if (!requestUrl.path.startsWith(prefix)) {
return null;
}
final fileName = Uri.decodeComponent(
requestUrl.path.substring(prefix.length),
);
if (fileName.isEmpty || fileName.contains('/')) {
return null;
}
final documentsPath = await getDocumentsPath();
final filePath = '${documentsPath}fonts/$fileName';
final file = File(filePath);
if (!await file.exists()) {
return null;
}
final bytes = await file.readAsBytes();
final ext = fileName.toLowerCase().split('.').last;
final mimeType = _fontMimeTypes[ext] ?? 'application/octet-stream';
return (bytes, mimeType);
}
static const _fontMimeTypes = {
'ttf': 'font/ttf',
'otf': 'font/otf',
'woff': 'font/woff',
'woff2': 'font/woff2',
};
/// Generate base URL for a chapter.
/// This URL should be used as the baseUrl parameter when loading HTML.
static String getBaseUrl() {
return '$virtualScheme://$virtualDomain/book/index.html';
}
/// Generate full URL for a specific file.
static String getFileUrl(String fileHash, Href href) {
final url =
'$virtualScheme://$virtualDomain/book/$fileHash/${href.path}${'#${href.anchor}'}';
return Uri.encodeFull(url);
}
/// Generate URL for a user-imported font file.
/// Format: epub://localhost/fonts/{fileName}
static String getFontUrl(String fileName) {
return '$virtualScheme://$virtualDomain/fonts/$fileName';
}
/// Check if a request is for an EPUB file.
static bool isEpubRequest(WebUri requestUrl) {
return requestUrl.scheme == virtualScheme &&
requestUrl.host == virtualDomain &&
requestUrl.path.startsWith('/book/');
}
/// Resolve image bytes from an EPUB for the image viewer.
/// The imageUrl may be a virtual epub:// URL or a relative path.
Future<Uint8List?> resolveImageFromEpub({
required String epubPath,
required String imageUrl,
required String fileHash,
}) async {
try {
String relativePath;
if (imageUrl.startsWith(virtualScheme)) {
final uri = Uri.parse(imageUrl);
final prefix = '/book/$fileHash/';
if (!uri.path.startsWith(prefix)) return null;
relativePath = Uri.decodeFull(uri.path).substring(prefix.length);
} else {
relativePath = imageUrl;
}
relativePath = relativePath.split('#')[0];
return await _streamService.readFileFromEpub(
epubPath: epubPath,
targetFilePath: relativePath,
);
} catch (_) {
return null;
}
}
/// Check if a request is for a user-imported font.
static bool isFontRequest(WebUri requestUrl) {
return requestUrl.scheme == virtualScheme &&
requestUrl.host == virtualDomain &&
requestUrl.path.startsWith('/fonts/');
}
}

View File

@@ -0,0 +1,100 @@
import '../database_helper.dart';
/// EPUB 阅读器数据访问层
class ReaderDao {
final DatabaseHelper _db = DatabaseHelper.instance;
// ─── reader_books ─────────────────────────────────────────────────
/// 获取所有未删除的阅读记录
Future<List<Map<String, dynamic>>> getAllReaderBooks() async {
final db = await _db.database;
return db.query(
'reader_books',
where: 'is_deleted = 0',
orderBy: 'updated_at DESC',
);
}
/// 根据 ID 获取阅读记录
Future<Map<String, dynamic>?> getReaderBookById(String id) async {
final db = await _db.database;
final results = await db.query(
'reader_books',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return results.isNotEmpty ? results.first : null;
}
/// 插入阅读记录
Future<int> insertReaderBook(Map<String, dynamic> book) async {
final db = await _db.database;
return db.insert('reader_books', book);
}
/// 更新阅读记录字段
Future<int> updateReaderBook(String id, Map<String, dynamic> fields) async {
final db = await _db.database;
return db.update(
'reader_books',
fields,
where: 'id = ?',
whereArgs: [id],
);
}
/// 更新阅读进度
Future<int> updateReadingProgress(
String id, String cfi, double percentage) async {
final db = await _db.database;
return db.update(
'reader_books',
{
'last_read_cfi': cfi,
'reading_percentage': percentage,
'updated_at': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
/// 软删除
Future<int> deleteReaderBook(String id) async {
final db = await _db.database;
return db.update(
'reader_books',
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
// ─── book_annotations ─────────────────────────────────────────────
/// 获取某本书的所有批注
Future<List<Map<String, dynamic>>> getAnnotationsByBookId(
String bookId) async {
final db = await _db.database;
return db.query(
'book_annotations',
where: 'book_id = ?',
whereArgs: [bookId],
orderBy: 'created_at DESC',
);
}
/// 插入批注
Future<int> insertAnnotation(Map<String, dynamic> annotation) async {
final db = await _db.database;
return db.insert('book_annotations', annotation);
}
/// 删除批注
Future<int> deleteAnnotation(int id) async {
final db = await _db.database;
return db.delete('book_annotations', where: 'id = ?', whereArgs: [id]);
}
}

View File

@@ -0,0 +1,74 @@
/// EPUB 解析结果数据模型
library;
class EpubBookInfo {
final String title;
final String author;
final List<String> authors;
final String? description;
final String? coverHref;
final String opfRootPath;
final String epubVersion;
final List<SpineItem> spine;
final List<TocEntry> toc;
EpubBookInfo({
required this.title,
required this.author,
required this.authors,
this.description,
this.coverHref,
required this.opfRootPath,
required this.epubVersion,
required this.spine,
required this.toc,
});
}
class SpineItem {
final int index;
final String href;
final String idref;
final bool linear;
SpineItem({
required this.index,
required this.href,
required this.idref,
this.linear = true,
});
}
class TocEntry {
final String label;
final String href;
final int spineIndex;
final List<TocEntry> children;
TocEntry({
required this.label,
required this.href,
this.spineIndex = -1,
this.children = const [],
});
/// 递归展平为列表(保留层级信息通过 depth
List<FlatTocItem> flatten() {
final result = <FlatTocItem>[];
_flattenRecursive(result, 0);
return result;
}
void _flattenRecursive(List<FlatTocItem> list, int depth) {
list.add(FlatTocItem(entry: this, depth: depth));
for (final child in children) {
child._flattenRecursive(list, depth + 1);
}
}
}
class FlatTocItem {
final TocEntry entry;
final int depth;
FlatTocItem({required this.entry, required this.depth});
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'epub_theme.dart';
/// Controls how the reader handles external link taps.
enum ReaderLinkHandling { ask, always, never }
/// Controls the page-turning animation style.
enum ReaderPageAnimation { none, slide }
class ReaderSettings {
final double zoom;
final bool followAppTheme;
final double marginTop;
final double marginBottom;
final double marginLeft;
final double marginRight;
final ReaderLinkHandling linkHandling;
final ReaderPageAnimation pageAnimation;
/// File name (with extension) of the user-imported font to use, or null to
/// use the epub's own fonts.
final String? fontFileName;
/// When true the custom font overrides the epub's own font-family rules.
final bool overrideFontFamily;
/// When true, volume up/down keys turn pages in the reader.
final bool volumeKeyTurnsPage;
const ReaderSettings({
this.zoom = 1.0,
this.followAppTheme = true,
this.marginTop = 16.0,
this.marginBottom = 16.0,
this.marginLeft = 16.0,
this.marginRight = 16.0,
this.linkHandling = ReaderLinkHandling.ask,
this.pageAnimation = ReaderPageAnimation.slide,
this.fontFileName,
this.overrideFontFamily = false,
this.volumeKeyTurnsPage = false,
});
// Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than
// "leave unchanged". Used only for the nullable fontFileName field.
static const Object _kUnset = Object();
ReaderSettings copyWith({
double? zoom,
bool? followAppTheme,
double? marginTop,
double? marginBottom,
double? marginLeft,
double? marginRight,
ReaderLinkHandling? linkHandling,
ReaderPageAnimation? pageAnimation,
Object? fontFileName = _kUnset,
bool? overrideFontFamily,
bool? volumeKeyTurnsPage,
}) {
return ReaderSettings(
zoom: zoom ?? this.zoom,
followAppTheme: followAppTheme ?? this.followAppTheme,
marginTop: marginTop ?? this.marginTop,
marginBottom: marginBottom ?? this.marginBottom,
marginLeft: marginLeft ?? this.marginLeft,
marginRight: marginRight ?? this.marginRight,
linkHandling: linkHandling ?? this.linkHandling,
pageAnimation: pageAnimation ?? this.pageAnimation,
fontFileName: identical(fontFileName, _kUnset)
? this.fontFileName
: fontFileName as String?,
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage,
);
}
EpubTheme toEpubTheme(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return EpubTheme(
zoom: zoom,
shouldOverrideTextColor: true,
colorScheme: colorScheme,
padding: EdgeInsets.only(
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
),
fontFileName: fontFileName,
overrideFontFamily: overrideFontFamily,
);
}
// ==================== Persistence ====================
static const _kPrefix = 'reader_';
Future<void> save() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('${_kPrefix}zoom', zoom);
await prefs.setBool('${_kPrefix}followAppTheme', followAppTheme);
await prefs.setDouble('${_kPrefix}marginTop', marginTop);
await prefs.setDouble('${_kPrefix}marginBottom', marginBottom);
await prefs.setDouble('${_kPrefix}marginLeft', marginLeft);
await prefs.setDouble('${_kPrefix}marginRight', marginRight);
await prefs.setInt('${_kPrefix}linkHandling', linkHandling.index);
await prefs.setInt('${_kPrefix}pageAnimation', pageAnimation.index);
if (fontFileName != null) {
await prefs.setString('${_kPrefix}fontFileName', fontFileName!);
} else {
await prefs.remove('${_kPrefix}fontFileName');
}
await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily);
await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage);
}
static Future<ReaderSettings> load() async {
final prefs = await SharedPreferences.getInstance();
return ReaderSettings(
zoom: prefs.getDouble('${_kPrefix}zoom') ?? 1.0,
followAppTheme: prefs.getBool('${_kPrefix}followAppTheme') ?? true,
marginTop: prefs.getDouble('${_kPrefix}marginTop') ?? 16.0,
marginBottom: prefs.getDouble('${_kPrefix}marginBottom') ?? 16.0,
marginLeft: prefs.getDouble('${_kPrefix}marginLeft') ?? 16.0,
marginRight: prefs.getDouble('${_kPrefix}marginRight') ?? 16.0,
linkHandling: ReaderLinkHandling.values[
prefs.getInt('${_kPrefix}linkHandling') ??
ReaderLinkHandling.ask.index],
pageAnimation: ReaderPageAnimation.values[
prefs.getInt('${_kPrefix}pageAnimation') ??
ReaderPageAnimation.slide.index],
fontFileName: prefs.getString('${_kPrefix}fontFileName'),
overrideFontFamily:
prefs.getBool('${_kPrefix}overrideFontFamily') ?? false,
volumeKeyTurnsPage:
prefs.getBool('${_kPrefix}volumeKeyTurnsPage') ?? false,
);
}
}

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
/// 音量键翻页服务
/// 注意:需要原生 Android 实现才能工作,当前为空操作
class VolumeControlService {
static const MethodChannel _methodChannel = MethodChannel(
'mooknote/volume_control',
);
static bool _available = false;
static bool _checked = false;
static Future<void> enableInterception() async {
if (!Platform.isAndroid) return;
if (!_checked) await _checkAvailable();
if (!_available) return;
try {
await _methodChannel.invokeMethod('enableInterception');
} catch (_) {}
}
static Future<void> disableInterception() async {
if (!Platform.isAndroid) return;
if (!_available) return;
try {
await _methodChannel.invokeMethod('disableInterception');
} catch (_) {}
}
static Stream<String> get volumeKeyEvents {
if (!Platform.isAndroid || !_available) return const Stream.empty();
// 需要原生 EventChannel 实现,当前返回空流
return const Stream.empty();
}
/// 检查原生端是否实现了该 channel
static Future<void> _checkAvailable() async {
_checked = true;
try {
await _methodChannel.invokeMethod('enableInterception');
_available = true;
} on MissingPluginException {
_available = false;
} catch (_) {
_available = false;
}
}
}

View File

@@ -0,0 +1,87 @@
import 'dart:convert';
import 'webview_bridge.dart';
/// Typed Dart mirror of the TypeScript `ReaderApi` interface
/// (`web_assets/controller.js/api.ts`).
///
/// Every public method corresponds 1-to-1 with its TypeScript counterpart.
/// The token parameter is managed internally by [WebViewBridge] — callers
/// never touch raw token integers through this class.
///
/// Methods that return `Future<int>` fire the JS call and return a token the
/// caller can later pass to [WebViewBridge.waitForEvent] / [waitForEvents]
/// when it wants to batch-await multiple operations together.
///
/// Methods that return `Future<void>` fire the JS call and await its
/// completion before returning.
class ReaderApi {
final WebViewBridge _bridge;
ReaderApi(this._bridge);
// ─── Token-based (deferred await) ──────────────────────────────────
/// Loads [url] into the iframe identified by [slot].
/// [anchors] should be a JSON-encoded list: `'["id1","id2"]'`.
Future<int> loadFrame(
String slot,
String url,
String anchors,
String properties,
) => _bridge.call(
(t) => "window.api.loadFrame($t, '$slot', '$url', $anchors, $properties)",
);
/// Scrolls [slot]'s iframe to [pageIndex] without immediately awaiting.
Future<int> jumpToPageFor(String slot, int pageIndex) =>
_bridge.call((t) => "window.api.jumpToPageFor($t, '$slot', $pageIndex)");
/// Scrolls [slot]'s iframe to its last page without immediately awaiting.
Future<int> jumpToLastPageOfFrame(String slot) =>
_bridge.call((t) => "window.api.jumpToLastPageOfFrame($t, '$slot')");
/// Rotates the iframe triple in [direction] (`'next'` or `'prev'`).
Future<int> cycleFrames(String direction) =>
_bridge.call((t) => "window.api.cycleFrames($t, '$direction')");
// ─── Fire-and-await ────────────────────────────────────────────────
/// Scrolls the current iframe to [pageIndex] and awaits completion.
Future<void> jumpToPage(int pageIndex) =>
_bridge.callAndWait((t) => 'window.api.jumpToPage($t, $pageIndex)', 1000);
/// Restores the scroll position using a fractional [ratio] in [0,1].
Future<void> restoreScrollPosition(double ratio) => _bridge.callAndWait(
(t) => 'window.api.restoreScrollPosition($t, $ratio)',
1000,
);
/// Waits for the current frame to finish rendering.
Future<void> waitForRender() =>
_bridge.callAndWait((t) => 'window.api.waitForRender($t)', 1000);
/// Updates the reader theme/layout and awaits completion.
///
/// [theme] must be a JSON-serialisable map produced by `EpubTheme.toMap()`.
Future<void> updateTheme(
double viewWidth,
double viewHeight,
Map<String, dynamic> theme,
) {
final themeJson = jsonEncode(theme);
return _bridge.callAndWait(
(t) => 'window.api.updateTheme($t, $viewWidth, $viewHeight, $themeJson)',
);
}
// ─── Fire-and-forget ───────────────────────────────────────────────
/// Checks whether there is an interactive element (image, etc.) at (x, y).
Future<void> checkLongPressElementAt(double x, double y) =>
_bridge.evaluate('window.api.checkLongPressElementAt($x, $y)');
/// Checks whether the tap at (x, y) hits a link, footnote, or other element.
Future<void> checkTapElementAt(double x, double y) =>
_bridge.evaluate('window.api.checkTapElementAt($x, $y)');
}

View File

@@ -0,0 +1,119 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Manages JS↔Dart communication over an [InAppWebViewController].
///
/// Provides token-based async call tracking so that callers can fire a JS
/// method that will eventually invoke `FlutterBridge.onEventFinished(token)`,
/// and await the result on the Dart side via [waitForEvent].
///
/// Typical usage:
/// ```dart
/// // Fire and forget the token; caller awaits separately.
/// final token = await _bridge.call((t) => "window.api.loadFrame($t, ...)");
/// await _bridge.waitForEvent(token);
///
/// // Fire and immediately await.
/// await _bridge.callAndWait((t) => "window.api.jumpToPage($t, $idx)");
/// ```
class WebViewBridge {
InAppWebViewController? _controller;
int _currentToken = 0;
final Map<int, Completer<void>> _completers = {};
// ─── Controller lifecycle ──────────────────────────────────────────
/// Attaches a live [InAppWebViewController]. Call this in `onWebViewCreated`.
void attach(InAppWebViewController controller) {
_controller = controller;
}
/// Detaches the controller and cancels all pending completers.
void detach() {
_controller = null;
for (final completer in _completers.values) {
if (!completer.isCompleted) {
completer.completeError(StateError('WebViewBridge detached'));
}
}
_completers.clear();
}
// ─── JS evaluation ─────────────────────────────────────────────────
/// Evaluates [source] in the WebView. No-ops if no controller is attached.
Future<void> evaluate(String source) async {
await _controller?.evaluateJavascript(source: source);
}
// ─── Token management ──────────────────────────────────────────────
/// Allocates a new token and registers a [Completer] for it.
///
/// Embed the returned token in the JS call so JS can resolve it via
/// `FlutterBridge.onEventFinished(token)`.
int issueToken() {
_currentToken++;
_completers[_currentToken] = Completer<void>();
return _currentToken;
}
/// Called by the `onEventFinished` JS handler to resolve a pending token.
///
/// A [token] of `-1` is a sentinel for fire-and-forget notifications that
/// do not need to be tracked.
void resolveToken(int token) {
if (token == -1) return;
final completer = _completers.remove(token);
if (completer != null && !completer.isCompleted) {
completer.complete();
}
}
// ─── Awaiting ──────────────────────────────────────────────────────
/// Waits for [token] to be resolved, or times out after [timeoutMs] ms.
Future<void> waitForEvent(int token, [int timeoutMs = 10000]) async {
final completer = _completers[token];
if (completer == null) {
debugPrint('WebViewBridge: no completer for token $token');
return;
}
return completer.future.timeout(
Duration(milliseconds: timeoutMs),
onTimeout: () {
_completers.remove(token);
debugPrint('WebViewBridge: timeout for token $token');
},
);
}
/// Waits for all [tokens] to be resolved concurrently.
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
await Future.wait(tokens.map((t) => waitForEvent(t, timeoutMs)));
}
// ─── Convenience helpers ───────────────────────────────────────────
/// Issues a token, evaluates the JS returned by [source], and returns the
/// token so the caller can [waitForEvent] later.
Future<int> call(String Function(int token) source) async {
final token = issueToken();
await evaluate(source(token));
return token;
}
/// Issues a token, evaluates the JS returned by [source], and immediately
/// awaits [waitForEvent] before returning.
Future<void> callAndWait(
String Function(int token) source, [
int timeoutMs = 10000,
]) async {
final token = issueToken();
await evaluate(source(token));
await waitForEvent(token, timeoutMs);
}
}

View File

@@ -1,117 +0,0 @@
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

@@ -1,71 +0,0 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
/// 阅读器文件路径管理
class BookFileHelper {
static final BookFileHelper instance = BookFileHelper._init();
BookFileHelper._init();
String? _rootPath;
Future<String> get _root async {
if (_rootPath != null) return _rootPath!;
final dir = await getApplicationDocumentsDirectory();
_rootPath = p.join(dir.path, 'mooknote', 'book_file');
await Directory(_rootPath!).create(recursive: true);
return _rootPath!;
}
Future<String> get bookFileRoot async => _root;
Future<String> get coverDir async {
final root = await _root;
final dir = p.join(root, 'cover');
await Directory(dir).create(recursive: true);
return dir;
}
Future<String> bookDir(String bookId) async {
final root = await _root;
final dir = p.join(root, bookId);
await Directory(dir).create(recursive: true);
return dir;
}
Future<String> bookFile(String bookId, String fileName) async {
final dir = await bookDir(bookId);
return p.join(dir, fileName);
}
String? relativePath(String absolutePath) {
if (_rootPath == null) return null;
if (absolutePath.startsWith(_rootPath!)) {
return absolutePath.substring(_rootPath!.length + 1);
}
return null;
}
Future<String> absolutePath(String relativePath) async {
final root = await _root;
return p.join(root, relativePath);
}
Future<void> deleteBookFiles(String bookId) async {
final dir = await bookDir(bookId);
if (await Directory(dir).exists()) {
await Directory(dir).delete(recursive: true);
}
}
/// 同步初始化(必须在使用 resolveAbsolutePath 前调用一次 bookFileRoot
Future<void> ensureInitialized() async {
await _root;
}
/// 根据相对路径解析绝对路径(调用前需确保已初始化)
String resolveAbsolutePath(String relativePath) {
if (_rootPath == null) return relativePath;
return p.join(_rootPath!, relativePath);
}
}

View File

@@ -1,12 +0,0 @@
/// 将归一化坐标 (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,69 +0,0 @@
import 'package:flutter/foundation.dart';
import '../../models/reader_book.dart';
import '../database_helper.dart';
/// 阅读器书籍 DAO
class ReaderBookDao {
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
try {
return await fn();
} catch (e) {
debugPrint('[ReaderBookDao] $op error: $e');
rethrow;
}
}
Future<List<ReaderBook>> getAllReaderBooks() => _wrap('getAllReaderBooks', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'reader_books',
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'created_at DESC',
);
return List.generate(maps.length, (i) => ReaderBook.fromJson(maps[i]));
});
Future<ReaderBook?> getReaderBookById(String id) => _wrap('getReaderBookById', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'reader_books',
where: 'id = ? AND is_deleted = ?',
whereArgs: [id, 0],
);
if (maps.isEmpty) return null;
return ReaderBook.fromJson(maps.first);
});
Future<void> insertReaderBook(ReaderBook book) => _wrap('insertReaderBook', () async {
final db = await _dbHelper.database;
await db.insert('reader_books', book.toJson());
});
Future<void> updateReaderBook(ReaderBook book) => _wrap('updateReaderBook', () async {
final db = await _dbHelper.database;
await db.update(
'reader_books',
book.toJson(),
where: 'id = ?',
whereArgs: [book.id],
);
});
Future<void> deleteReaderBook(String id) => _wrap('deleteReaderBook', () async {
final db = await _dbHelper.database;
await db.update(
'reader_books',
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
});
Future<void> permanentDeleteReaderBook(String id) => _wrap('permanentDeleteReaderBook', () async {
final db = await _dbHelper.database;
await db.delete('reader_books', where: 'id = ?', whereArgs: [id]);
});
}

View File

@@ -1,73 +0,0 @@
import 'dart:convert';
import '../../service/book_server.dart';
/// 生成 foliate-js 阅读器 URL
String generateReaderUrl({
required String fileUrl,
String cfi = '',
required String backgroundColor,
required String textColor,
bool isDarkMode = false,
}) {
final indexHtmlPath = 'http://127.0.0.1:${Server().port}/foliate-js/index.html';
final jsBg = _convertDartColorToJs(backgroundColor);
final jsTc = _convertDartColorToJs(textColor);
final style = {
'fontSize': 100,
'fontName': '',
'fontPath': '',
'fontWeight': 400,
'letterSpacing': 0,
'spacing': 1.6,
'paragraphSpacing': 0.6,
'textIndent': 2,
'fontColor': '#$jsTc',
'backgroundColor': '#$jsBg',
'topMargin': 25,
'bottomMargin': 25,
'sideMargin': 3,
'justify': true,
'hyphenate': false,
'pageTurnStyle': 'slide',
'maxColumnCount': 1,
'columnThreshold': 3,
'writingMode': 'horizontal-tb',
'textAlign': 'justify',
'backgroundImage': '',
'bgimgBlur': 0,
'bgimgOpacity': 1.0,
'bgimgFit': 'cover',
'allowScript': false,
'customCSS': '',
'customCSSEnabled': false,
'useBookStyles': true,
'headingFontSize': 130,
'codeHighlightTheme': 'atom-one-light',
};
final params = {
'importing': false,
'url': fileUrl,
'initialCfi': cfi,
'style': style,
};
final queryParts = params.entries
.map((e) => '${e.key}=${Uri.encodeComponent(jsonEncode(e.value))}')
.join('&');
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

@@ -201,4 +201,10 @@ class UserPrefs {
/// 已忽略的版本号(不再提示更新)
String get dismissedVersion => prefs.getString('dismissedVersion') ?? '';
Future<bool> setDismissedVersion(String value) => prefs.setString('dismissedVersion', value);
// ========== EPUB 阅读器 ==========
/// EPUB 阅读器字体大小
double get epubFontSize => prefs.getDouble('epubFontSize') ?? 18.0;
Future<bool> setEpubFontSize(double value) => prefs.setDouble('epubFontSize', value);
}

View File

@@ -8,8 +8,8 @@ import '../pages/stroll_page.dart';
import '../pages/media_calendar_page.dart';
import '../pages/person_list_page.dart';
import '../pages/markdown_reader/md_reader_tab_page.dart';
import '../pages/epub_reader/epub_library_page.dart';
import '../pages/tag_management_page.dart';
import '../pages/reader/bookshelf_page.dart';
import '../pages/profile_page.dart';
import '../pages/movies/movie_detail_page.dart';
import '../pages/book/book_detail_page.dart';
@@ -195,9 +195,9 @@ class _CustomDrawerState extends State<CustomDrawer> {
Navigator.push(context, MaterialPageRoute(builder: (_) => const MdReaderTabPage()));
}),
Divider(height: 1, indent: 52, endIndent: 20, color: colors.outlineVariant),
_buildToolItem(Icons.menu_book_outlined, '阅读', () {
_buildToolItem(Icons.auto_stories_outlined, 'EPUB阅读', () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const BookshelfPage()));
Navigator.push(context, MaterialPageRoute(builder: (_) => const EpubLibraryPage()));
}, bottomRounded: true),
],
),

View File

@@ -1,121 +0,0 @@
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

@@ -1,144 +0,0 @@
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,
);
}),
],
);
}
}