Files
MookNote/lib/pages/epub_reader/control_panel.dart
2026-08-12 22:28:33 +08:00

495 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'reader_style_sheet.dart';
import '../../widgets/app_overlay.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;
final int themeIndex;
final int customBgColor;
final int customTextColor;
final ValueChanged<int> onThemeIndexChanged;
final void Function(int bgColor, int textColor) onCustomColorChanged;
final bool currentPageHasBookmark;
final VoidCallback onBookmarkToggle;
final VoidCallback onSearchTap;
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,
required this.themeIndex,
required this.customBgColor,
required this.customTextColor,
required this.onThemeIndexChanged,
required this.onCustomColorChanged,
required this.currentPageHasBookmark,
required this.onBookmarkToggle,
required this.onSearchTap,
});
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();
appModalBottomSheet(
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,
themeIndex: widget.themeIndex,
customBgColor: widget.customBgColor,
customTextColor: widget.customTextColor,
onThemeIndexChanged: widget.onThemeIndexChanged,
onCustomColorChanged: widget.onCustomColorChanged,
),
),
],
),
),
);
},
);
}
@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,
),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: widget.onSearchTap,
),
IconButton(
icon: Icon(
widget.currentPageHasBookmark
? Icons.bookmark
: Icons.bookmark_outline,
),
onPressed: widget.onBookmarkToggle,
),
],
),
),
),
),
// 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,
),
],
),
),
),
),
],
);
}
}