generated from dellevin/template
原生epub阅读
This commit is contained in:
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
266
lib/pages/epub_reader/book_session.dart
Normal file
266
lib/pages/epub_reader/book_session.dart
Normal 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;
|
||||
}
|
||||
458
lib/pages/epub_reader/control_panel.dart
Normal file
458
lib/pages/epub_reader/control_panel.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
245
lib/pages/epub_reader/epub_library_page.dart
Normal file
245
lib/pages/epub_reader/epub_library_page.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/pages/epub_reader/footnote_popup.dart
Normal file
216
lib/pages/epub_reader/footnote_popup.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
227
lib/pages/epub_reader/image_viewer.dart
Normal file
227
lib/pages/epub_reader/image_viewer.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
57
lib/pages/epub_reader/mixins/footnote_mixin.dart
Normal file
57
lib/pages/epub_reader/mixins/footnote_mixin.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
47
lib/pages/epub_reader/mixins/image_viewer_mixin.dart
Normal file
47
lib/pages/epub_reader/mixins/image_viewer_mixin.dart
Normal 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;
|
||||
}
|
||||
82
lib/pages/epub_reader/mixins/link_handling_mixin.dart
Normal file
82
lib/pages/epub_reader/mixins/link_handling_mixin.dart
Normal 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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
lib/pages/epub_reader/mixins/page_navigation_mixin.dart
Normal file
88
lib/pages/epub_reader/mixins/page_navigation_mixin.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/pages/epub_reader/mixins/progress_mixin.dart
Normal file
45
lib/pages/epub_reader/mixins/progress_mixin.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
239
lib/pages/epub_reader/mixins/spine_navigation_mixin.dart
Normal file
239
lib/pages/epub_reader/mixins/spine_navigation_mixin.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/pages/epub_reader/mixins/theme_mixin.dart
Normal file
46
lib/pages/epub_reader/mixins/theme_mixin.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
169
lib/pages/epub_reader/page_turn/android_page_turn_session.dart
Normal file
169
lib/pages/epub_reader/page_turn/android_page_turn_session.dart
Normal 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;
|
||||
}
|
||||
67
lib/pages/epub_reader/page_turn/ios_page_turn_session.dart
Normal file
67
lib/pages/epub_reader/page_turn/ios_page_turn_session.dart
Normal 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;
|
||||
}
|
||||
2
lib/pages/epub_reader/page_turn/page_turn.dart
Normal file
2
lib/pages/epub_reader/page_turn/page_turn.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'android_page_turn_session.dart';
|
||||
export 'ios_page_turn_session.dart';
|
||||
512
lib/pages/epub_reader/reader_renderer.dart
Normal file
512
lib/pages/epub_reader/reader_renderer.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
546
lib/pages/epub_reader/reader_screen.dart
Normal file
546
lib/pages/epub_reader/reader_screen.dart
Normal 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) {
|
||||
// 将字号值映射为 zoom(12px→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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
301
lib/pages/epub_reader/reader_style_sheet.dart
Normal file
301
lib/pages/epub_reader/reader_style_sheet.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
524
lib/pages/epub_reader/reader_webview.dart
Normal file
524
lib/pages/epub_reader/reader_webview.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
370
lib/pages/epub_reader/toc_drawer.dart
Normal file
370
lib/pages/epub_reader/toc_drawer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
lib/pages/epub_reader/widgets/integer_stepper.dart
Normal file
74
lib/pages/epub_reader/widgets/integer_stepper.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
lib/pages/epub_reader/widgets/reader_scale_slider.dart
Normal file
66
lib/pages/epub_reader/widgets/reader_scale_slider.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('阅读'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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': '*'},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
477
lib/utils/epub/epub_parser.dart
Normal file
477
lib/utils/epub/epub_parser.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
163
lib/utils/epub/epub_service.dart
Normal file
163
lib/utils/epub/epub_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
102
lib/utils/epub/epub_stream_service.dart
Normal file
102
lib/utils/epub/epub_stream_service.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
111
lib/utils/epub/epub_theme.dart
Normal file
111
lib/utils/epub/epub_theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
258
lib/utils/epub/epub_webview_handler.dart
Normal file
258
lib/utils/epub/epub_webview_handler.dart
Normal 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/');
|
||||
}
|
||||
}
|
||||
100
lib/utils/epub/reader_dao.dart
Normal file
100
lib/utils/epub/reader_dao.dart
Normal 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]);
|
||||
}
|
||||
}
|
||||
74
lib/utils/epub/reader_models.dart
Normal file
74
lib/utils/epub/reader_models.dart
Normal 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});
|
||||
}
|
||||
102
lib/utils/epub/reader_scripts.dart
Normal file
102
lib/utils/epub/reader_scripts.dart
Normal file
File diff suppressed because one or more lines are too long
142
lib/utils/epub/reader_settings.dart
Normal file
142
lib/utils/epub/reader_settings.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/utils/epub/volume_control_service.dart
Normal file
50
lib/utils/epub/volume_control_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
87
lib/utils/epub/web/reader_api.dart
Normal file
87
lib/utils/epub/web/reader_api.dart
Normal 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)');
|
||||
}
|
||||
119
lib/utils/epub/web/webview_bridge.dart
Normal file
119
lib/utils/epub/web/webview_bridge.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user