原生epub阅读

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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