mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-05-19 03:41:57 +00:00
feat(reader): add page indicator, app bar, bottom bar, gesture handler, and settings modal
- PageIndicator widget to display current page and total pages. - Created ReaderAppBar for navigation and chapter information. - ReaderBottomBar for page navigation and settings access. - Added ReaderGestureHandler for managing tap zones and gestures. - ReaderSettingsModal for user-configurable settings.
This commit is contained in:
parent
0789f4c85a
commit
4e9af30e8e
17 changed files with 3122 additions and 2082 deletions
|
|
@ -1,309 +0,0 @@
|
||||||
import 'package:extended_image/extended_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mangayomi/main.dart';
|
|
||||||
import 'package:mangayomi/models/settings.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/image_view_paged.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_paged.dart';
|
|
||||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
|
||||||
import 'package:mangayomi/providers/l10n_providers.dart';
|
|
||||||
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:photo_view/photo_view_gallery.dart';
|
|
||||||
|
|
||||||
class DoubleColummView extends StatefulWidget {
|
|
||||||
final List<UChapDataPreload?> datas;
|
|
||||||
final Function(UChapDataPreload datas) onLongPressData;
|
|
||||||
final BackgroundColor backgroundColor;
|
|
||||||
final Function(bool) isFailedToLoadImage;
|
|
||||||
const DoubleColummView({
|
|
||||||
super.key,
|
|
||||||
required this.datas,
|
|
||||||
required this.onLongPressData,
|
|
||||||
required this.backgroundColor,
|
|
||||||
required this.isFailedToLoadImage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DoubleColummView> createState() => _DoubleColummViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DoubleColummViewState extends State<DoubleColummView>
|
|
||||||
with TickerProviderStateMixin {
|
|
||||||
late AnimationController _scaleAnimationController;
|
|
||||||
late Animation<double> _animation;
|
|
||||||
Alignment _scalePosition = Alignment.center;
|
|
||||||
final PhotoViewController _photoViewController = PhotoViewController();
|
|
||||||
final PhotoViewScaleStateController _photoViewScaleStateController =
|
|
||||||
PhotoViewScaleStateController();
|
|
||||||
Duration? _doubleTapAnimationDuration() {
|
|
||||||
int doubleTapAnimationValue = isar.settings
|
|
||||||
.getSync(227)!
|
|
||||||
.doubleTapAnimationSpeed!;
|
|
||||||
if (doubleTapAnimationValue == 0) {
|
|
||||||
return const Duration(milliseconds: 10);
|
|
||||||
} else if (doubleTapAnimationValue == 1) {
|
|
||||||
return const Duration(milliseconds: 800);
|
|
||||||
}
|
|
||||||
return const Duration(milliseconds: 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScaleEnd(
|
|
||||||
BuildContext context,
|
|
||||||
ScaleEndDetails details,
|
|
||||||
PhotoViewControllerValue controllerValue,
|
|
||||||
) {
|
|
||||||
if (controllerValue.scale! < 1) {
|
|
||||||
_photoViewScaleStateController.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
double get pixelRatio => View.of(context).devicePixelRatio;
|
|
||||||
Size get size => View.of(context).physicalSize / pixelRatio;
|
|
||||||
Alignment _computeAlignmentByTapOffset(Offset offset) {
|
|
||||||
return Alignment(
|
|
||||||
(offset.dx - size.width / 2) / (size.width / 2),
|
|
||||||
(offset.dy - size.height / 2) / (size.height / 2),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scaleAnimationController = AnimationController(
|
|
||||||
duration: _doubleTapAnimationDuration(),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_animation = Tween(begin: 1.0, end: 2.0).animate(
|
|
||||||
CurvedAnimation(curve: Curves.ease, parent: _scaleAnimationController),
|
|
||||||
);
|
|
||||||
_animation.addListener(() {
|
|
||||||
_photoViewController.scale = _animation.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scaleAnimationController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleScale(Offset tapPosition) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (_scaleAnimationController.isAnimating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_photoViewController.scale == 1.0) {
|
|
||||||
_scalePosition = _computeAlignmentByTapOffset(tapPosition);
|
|
||||||
|
|
||||||
if (_scaleAnimationController.isCompleted) {
|
|
||||||
_scaleAnimationController.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
_scaleAnimationController.forward();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_photoViewController.scale == 2.0) {
|
|
||||||
_scaleAnimationController.reverse();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_photoViewScaleStateController.reset();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (widget.datas[0]?.isTransitionPage ?? false) {
|
|
||||||
return TransitionViewPaged(data: widget.datas[0]!);
|
|
||||||
}
|
|
||||||
if (widget.datas.length > 1 &&
|
|
||||||
(widget.datas[1]?.isTransitionPage ?? false)) {
|
|
||||||
return TransitionViewPaged(data: widget.datas[1]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return PhotoViewGallery.builder(
|
|
||||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
|
||||||
itemCount: 1,
|
|
||||||
builder: (context, _) {
|
|
||||||
final l10n = l10nLocalizations(context)!;
|
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
|
||||||
controller: _photoViewController,
|
|
||||||
scaleStateController: _photoViewScaleStateController,
|
|
||||||
basePosition: _scalePosition,
|
|
||||||
onScaleEnd: _onScaleEnd,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onDoubleTapDown: (TapDownDetails details) {
|
|
||||||
_toggleScale(details.globalPosition);
|
|
||||||
},
|
|
||||||
onDoubleTap: () {},
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (widget.datas[0] != null)
|
|
||||||
Flexible(
|
|
||||||
child: ImageViewPaged(
|
|
||||||
data: widget.datas[0]!,
|
|
||||||
loadStateChanged: (state) {
|
|
||||||
if (state.extendedImageLoadState == LoadState.loading) {
|
|
||||||
final ImageChunkEvent? loadingProgress =
|
|
||||||
state.loadingProgress;
|
|
||||||
final double progress =
|
|
||||||
loadingProgress?.expectedTotalBytes != null
|
|
||||||
? loadingProgress!.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: 0;
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(widget.backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: CircularProgressIndicatorAnimateRotate(
|
|
||||||
progress: progress,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState ==
|
|
||||||
LoadState.completed) {
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
return Image(image: state.imageProvider);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.failed) {
|
|
||||||
widget.isFailedToLoadImage(true);
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(widget.backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.image_loading_error,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onLongPressData: (datas) =>
|
|
||||||
widget.onLongPressData.call(datas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// if (widget.datas[1] != null) const SizedBox(width: 10),
|
|
||||||
if (widget.datas[1] != null)
|
|
||||||
Flexible(
|
|
||||||
child: ImageViewPaged(
|
|
||||||
data: widget.datas[1]!,
|
|
||||||
loadStateChanged: (state) {
|
|
||||||
if (state.extendedImageLoadState == LoadState.loading) {
|
|
||||||
final ImageChunkEvent? loadingProgress =
|
|
||||||
state.loadingProgress;
|
|
||||||
final double progress =
|
|
||||||
loadingProgress?.expectedTotalBytes != null
|
|
||||||
? loadingProgress!.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: 0;
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(widget.backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: CircularProgressIndicatorAnimateRotate(
|
|
||||||
progress: progress,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState ==
|
|
||||||
LoadState.completed) {
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
return Image(image: state.imageProvider);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.failed) {
|
|
||||||
widget.isFailedToLoadImage(true);
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(widget.backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.image_loading_error,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
widget.isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onLongPressData: (datas) =>
|
|
||||||
widget.onLongPressData.call(datas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
import 'package:extended_image/extended_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mangayomi/models/settings.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/image_view_paged.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
|
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
|
||||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
|
||||||
import 'package:mangayomi/providers/l10n_providers.dart';
|
|
||||||
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
|
||||||
|
|
||||||
class DoubleColummVerticalView extends StatelessWidget {
|
|
||||||
final List<UChapDataPreload?> datas;
|
|
||||||
final Function(UChapDataPreload datas) onLongPressData;
|
|
||||||
final BackgroundColor backgroundColor;
|
|
||||||
final Function(bool) isFailedToLoadImage;
|
|
||||||
const DoubleColummVerticalView({
|
|
||||||
super.key,
|
|
||||||
required this.datas,
|
|
||||||
required this.onLongPressData,
|
|
||||||
required this.backgroundColor,
|
|
||||||
required this.isFailedToLoadImage,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = l10nLocalizations(context)!;
|
|
||||||
|
|
||||||
if (datas[0]?.isTransitionPage ?? false) {
|
|
||||||
return TransitionViewVertical(data: datas[0]!);
|
|
||||||
}
|
|
||||||
if (datas.length > 1 && (datas[1]?.isTransitionPage ?? false)) {
|
|
||||||
return TransitionViewVertical(data: datas[1]!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (datas[0]?.index == 0)
|
|
||||||
SizedBox(height: MediaQuery.of(context).padding.top),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (datas[0] != null)
|
|
||||||
Flexible(
|
|
||||||
child: ImageViewPaged(
|
|
||||||
data: datas[0]!,
|
|
||||||
loadStateChanged: (state) {
|
|
||||||
if (state.extendedImageLoadState == LoadState.loading) {
|
|
||||||
final ImageChunkEvent? loadingProgress =
|
|
||||||
state.loadingProgress;
|
|
||||||
final double progress =
|
|
||||||
loadingProgress?.expectedTotalBytes != null
|
|
||||||
? loadingProgress!.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: 0;
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: CircularProgressIndicatorAnimateRotate(
|
|
||||||
progress: progress,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.completed) {
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
return Image(image: state.imageProvider);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.failed) {
|
|
||||||
isFailedToLoadImage(true);
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.image_loading_error,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onLongPressData: (datas) => onLongPressData.call(datas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// if (datas[1] != null) const SizedBox(width: 10),
|
|
||||||
if (datas[1] != null)
|
|
||||||
Flexible(
|
|
||||||
child: ImageViewPaged(
|
|
||||||
data: datas[1]!,
|
|
||||||
loadStateChanged: (state) {
|
|
||||||
if (state.extendedImageLoadState == LoadState.loading) {
|
|
||||||
final ImageChunkEvent? loadingProgress =
|
|
||||||
state.loadingProgress;
|
|
||||||
final double progress =
|
|
||||||
loadingProgress?.expectedTotalBytes != null
|
|
||||||
? loadingProgress!.cumulativeBytesLoaded /
|
|
||||||
loadingProgress.expectedTotalBytes!
|
|
||||||
: 0;
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: CircularProgressIndicatorAnimateRotate(
|
|
||||||
progress: progress,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.completed) {
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
return Image(image: state.imageProvider);
|
|
||||||
}
|
|
||||||
if (state.extendedImageLoadState == LoadState.failed) {
|
|
||||||
isFailedToLoadImage(true);
|
|
||||||
return Container(
|
|
||||||
color: getBackgroundColor(backgroundColor),
|
|
||||||
height: context.height(0.8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
l10n.image_loading_error,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(8.0),
|
|
||||||
child: GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
onTap: () {
|
|
||||||
state.reLoadImage();
|
|
||||||
isFailedToLoadImage(false);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8,
|
|
||||||
horizontal: 16,
|
|
||||||
),
|
|
||||||
child: Text(l10n.retry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onLongPressData: (datas) => onLongPressData.call(datas),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/double_columm_view_vertical.dart';
|
import 'package:mangayomi/modules/manga/reader/widgets/double_page_view.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||||
|
|
@ -134,10 +134,10 @@ class ImageViewWebtoon extends StatelessWidget {
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onDoubleTapDown: (details) => onDoubleTapDown(details.globalPosition),
|
onDoubleTapDown: (details) => onDoubleTapDown(details.globalPosition),
|
||||||
onDoubleTap: onDoubleTap,
|
onDoubleTap: onDoubleTap,
|
||||||
child: DoubleColummVerticalView(
|
child: DoublePageView.vertical(
|
||||||
datas: datas,
|
pages: datas,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
isFailedToLoadImage: onFailedToLoadImage,
|
onFailedToLoadImage: onFailedToLoadImage,
|
||||||
onLongPressData: onLongPressData,
|
onLongPressData: onLongPressData,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
367
lib/modules/manga/reader/managers/chapter_preload_manager.dart
Normal file
367
lib/modules/manga/reader/managers/chapter_preload_manager.dart
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
||||||
|
import 'package:mangayomi/services/get_chapter_pages.dart';
|
||||||
|
|
||||||
|
/// Manages the preloading and memory of chapters in the manga reader.
|
||||||
|
class ChapterPreloadManager {
|
||||||
|
/// Maximum number of chapters to keep in memory
|
||||||
|
static const int maxChaptersInMemory = 3;
|
||||||
|
|
||||||
|
/// Maximum number of pages to keep in the preload list
|
||||||
|
static const int maxPagesInMemory = 200;
|
||||||
|
|
||||||
|
/// Buffer size around current index to keep
|
||||||
|
static const int pageBufferBefore = 30;
|
||||||
|
static const int pageBufferAfter = 70;
|
||||||
|
|
||||||
|
/// The list of preloaded chapter data
|
||||||
|
final List<UChapDataPreload> _pages = [];
|
||||||
|
|
||||||
|
/// Set of chapter IDs currently in memory
|
||||||
|
final Set<String> _loadedChapterIds = {};
|
||||||
|
|
||||||
|
/// Queue of chapter IDs in order of loading (for LRU eviction)
|
||||||
|
final Queue<String> _chapterLoadOrder = Queue();
|
||||||
|
|
||||||
|
/// Current reading index
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
/// Flag to prevent concurrent preloading
|
||||||
|
bool _isPreloading = false;
|
||||||
|
|
||||||
|
/// Callbacks
|
||||||
|
void Function()? onPagesUpdated;
|
||||||
|
void Function(int)? onIndexAdjusted;
|
||||||
|
|
||||||
|
/// Gets the list of pages (read-only)
|
||||||
|
List<UChapDataPreload> get pages => List.unmodifiable(_pages);
|
||||||
|
|
||||||
|
/// Gets the current number of pages
|
||||||
|
int get pageCount => _pages.length;
|
||||||
|
|
||||||
|
/// Gets the current index
|
||||||
|
int get currentIndex => _currentIndex;
|
||||||
|
|
||||||
|
/// Gets the loaded chapter count
|
||||||
|
int get loadedChapterCount => _loadedChapterIds.length;
|
||||||
|
|
||||||
|
/// Sets the current reading index
|
||||||
|
set currentIndex(int value) {
|
||||||
|
if (value >= 0 && value < _pages.length) {
|
||||||
|
_currentIndex = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the manager with the first chapter's pages.
|
||||||
|
void initialize(List<UChapDataPreload> initialPages, int startIndex) {
|
||||||
|
_pages.clear();
|
||||||
|
_loadedChapterIds.clear();
|
||||||
|
_chapterLoadOrder.clear();
|
||||||
|
|
||||||
|
_pages.addAll(initialPages);
|
||||||
|
_currentIndex = startIndex;
|
||||||
|
|
||||||
|
// Track the initial chapter
|
||||||
|
if (initialPages.isNotEmpty) {
|
||||||
|
final chapterId = _getChapterIdentifier(initialPages.first.chapter);
|
||||||
|
if (chapterId != null) {
|
||||||
|
_loadedChapterIds.add(chapterId);
|
||||||
|
_chapterLoadOrder.add(chapterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[ChapterPreload] Initialized with ${initialPages.length} pages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a transition page between chapters.
|
||||||
|
UChapDataPreload createTransitionPage({
|
||||||
|
required Chapter currentChapter,
|
||||||
|
required Chapter? nextChapter,
|
||||||
|
required String mangaName,
|
||||||
|
bool isLastChapter = false,
|
||||||
|
}) {
|
||||||
|
return UChapDataPreload.transition(
|
||||||
|
currentChapter: currentChapter,
|
||||||
|
nextChapter: nextChapter,
|
||||||
|
mangaName: mangaName,
|
||||||
|
pageIndex: _pages.length,
|
||||||
|
isLastChapter: isLastChapter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preloads the next chapter's pages.
|
||||||
|
///
|
||||||
|
/// Returns true if preloading was successful, false otherwise.
|
||||||
|
Future<bool> preloadNextChapter(
|
||||||
|
GetChapterPagesModel chapterData,
|
||||||
|
Chapter currentChapter,
|
||||||
|
) async {
|
||||||
|
if (_isPreloading) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] Already preloading, skipping');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isPreloading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (chapterData.uChapDataPreload.isEmpty) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] No pages in chapter data');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstPage = chapterData.uChapDataPreload.first;
|
||||||
|
if (firstPage.chapter == null) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] No chapter in first page');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final chapterId = _getChapterIdentifier(firstPage.chapter);
|
||||||
|
if (chapterId != null && _loadedChapterIds.contains(chapterId)) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] Chapter already loaded: $chapterId');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create transition page
|
||||||
|
final transitionPage = createTransitionPage(
|
||||||
|
currentChapter: currentChapter,
|
||||||
|
nextChapter: firstPage.chapter,
|
||||||
|
mangaName: currentChapter.manga.value?.name ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update page indices for new pages
|
||||||
|
final startIndex = _pages.length + 1;
|
||||||
|
final newPages = chapterData.uChapDataPreload.asMap().entries.map((
|
||||||
|
entry,
|
||||||
|
) {
|
||||||
|
return entry.value..pageIndex = startIndex + entry.key;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Add to pages list
|
||||||
|
_pages.add(transitionPage);
|
||||||
|
_pages.addAll(newPages);
|
||||||
|
|
||||||
|
// Track the new chapter
|
||||||
|
if (chapterId != null) {
|
||||||
|
_loadedChapterIds.add(chapterId);
|
||||||
|
_chapterLoadOrder.add(chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict old chapters if necessary
|
||||||
|
await _evictOldChaptersIfNeeded();
|
||||||
|
|
||||||
|
// Notify listeners
|
||||||
|
onPagesUpdated?.call();
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[ChapterPreload] Added ${newPages.length} pages from next chapter',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'[ChapterPreload] Total pages: ${_pages.length}, Chapters: ${_loadedChapterIds.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
_isPreloading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a "last chapter" transition page.
|
||||||
|
bool addLastChapterTransition(Chapter chapter) {
|
||||||
|
// Check if already added
|
||||||
|
if (_pages.isNotEmpty && (_pages.last.isLastChapter ?? false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final transitionPage = createTransitionPage(
|
||||||
|
currentChapter: chapter,
|
||||||
|
nextChapter: null,
|
||||||
|
mangaName: chapter.manga.value?.name ?? '',
|
||||||
|
isLastChapter: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pages.add(transitionPage);
|
||||||
|
onPagesUpdated?.call();
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] Added last chapter transition');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts old chapters to stay within memory limits.
|
||||||
|
Future<void> _evictOldChaptersIfNeeded() async {
|
||||||
|
// Evict by chapter count
|
||||||
|
while (_loadedChapterIds.length > maxChaptersInMemory &&
|
||||||
|
_chapterLoadOrder.isNotEmpty) {
|
||||||
|
final oldestChapterId = _chapterLoadOrder.first;
|
||||||
|
|
||||||
|
// Don't evict if current page is in this chapter
|
||||||
|
final currentPage = _currentIndex < _pages.length
|
||||||
|
? _pages[_currentIndex]
|
||||||
|
: null;
|
||||||
|
final currentChapterId = currentPage != null
|
||||||
|
? _getChapterIdentifier(currentPage.chapter)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (oldestChapterId == currentChapterId) {
|
||||||
|
// Can't evict current chapter, try next
|
||||||
|
if (_chapterLoadOrder.length > 1) {
|
||||||
|
_chapterLoadOrder.removeFirst();
|
||||||
|
_chapterLoadOrder.add(oldestChapterId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _evictChapter(oldestChapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict by page count if still too many
|
||||||
|
if (_pages.length > maxPagesInMemory) {
|
||||||
|
await _trimPagesToBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts a specific chapter from memory.
|
||||||
|
Future<void> _evictChapter(String chapterId) async {
|
||||||
|
final pagesToRemove = <int>[];
|
||||||
|
final keysToRemoveFromCache = <String>[];
|
||||||
|
|
||||||
|
for (var i = 0; i < _pages.length; i++) {
|
||||||
|
final page = _pages[i];
|
||||||
|
if (_getChapterIdentifier(page.chapter) == chapterId) {
|
||||||
|
pagesToRemove.add(i);
|
||||||
|
|
||||||
|
// Clear the cropImage to free memory
|
||||||
|
page.cropImage = null;
|
||||||
|
|
||||||
|
// Build cache key for image cache removal
|
||||||
|
if (page.pageUrl?.url != null) {
|
||||||
|
keysToRemoveFromCache.add(page.pageUrl!.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove pages from the end to avoid index shifting issues
|
||||||
|
for (var i = pagesToRemove.length - 1; i >= 0; i--) {
|
||||||
|
final index = pagesToRemove[i];
|
||||||
|
_pages.removeAt(index);
|
||||||
|
|
||||||
|
// Adjust current index if needed
|
||||||
|
if (_currentIndex > index) {
|
||||||
|
_currentIndex--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
_loadedChapterIds.remove(chapterId);
|
||||||
|
_chapterLoadOrder.remove(chapterId);
|
||||||
|
|
||||||
|
// Notify about index adjustment
|
||||||
|
onIndexAdjusted?.call(_currentIndex);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[ChapterPreload] Evicted chapter: $chapterId, '
|
||||||
|
'Removed ${pagesToRemove.length} pages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims pages to keep only those within the buffer range.
|
||||||
|
Future<void> _trimPagesToBuffer() async {
|
||||||
|
if (_pages.length <= maxPagesInMemory) return;
|
||||||
|
|
||||||
|
final startKeep = (_currentIndex - pageBufferBefore).clamp(
|
||||||
|
0,
|
||||||
|
_pages.length,
|
||||||
|
);
|
||||||
|
final endKeep = (_currentIndex + pageBufferAfter).clamp(0, _pages.length);
|
||||||
|
|
||||||
|
final pagesToRemoveFromStart = startKeep;
|
||||||
|
final pagesToRemoveFromEnd = _pages.length - endKeep;
|
||||||
|
|
||||||
|
// Remove from end first
|
||||||
|
if (pagesToRemoveFromEnd > 0) {
|
||||||
|
final keysToRemove = <String>[];
|
||||||
|
for (var i = _pages.length - 1; i >= endKeep; i--) {
|
||||||
|
final page = _pages[i];
|
||||||
|
page.cropImage = null;
|
||||||
|
if (page.pageUrl?.url != null) {
|
||||||
|
keysToRemove.add(page.pageUrl!.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pages.removeRange(endKeep, _pages.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from start
|
||||||
|
if (pagesToRemoveFromStart > 0) {
|
||||||
|
final keysToRemove = <String>[];
|
||||||
|
for (var i = 0; i < pagesToRemoveFromStart; i++) {
|
||||||
|
final page = _pages[i];
|
||||||
|
page.cropImage = null;
|
||||||
|
if (page.pageUrl?.url != null) {
|
||||||
|
keysToRemove.add(page.pageUrl!.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pages.removeRange(0, pagesToRemoveFromStart);
|
||||||
|
_currentIndex -= pagesToRemoveFromStart;
|
||||||
|
|
||||||
|
onIndexAdjusted?.call(_currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[ChapterPreload] Trimmed pages, '
|
||||||
|
'New count: ${_pages.length}, Index: $_currentIndex',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets a unique identifier for a chapter.
|
||||||
|
String? _getChapterIdentifier(Chapter? chapter) {
|
||||||
|
if (chapter == null) return null;
|
||||||
|
|
||||||
|
final url = chapter.url?.trim() ?? '';
|
||||||
|
final archivePath = chapter.archivePath?.trim() ?? '';
|
||||||
|
|
||||||
|
if (url.isNotEmpty) return 'url:$url';
|
||||||
|
if (archivePath.isNotEmpty) return 'archive:$archivePath';
|
||||||
|
|
||||||
|
return 'id:${chapter.id}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes of all resources.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
// Clear pages
|
||||||
|
_pages.clear();
|
||||||
|
_loadedChapterIds.clear();
|
||||||
|
_chapterLoadOrder.clear();
|
||||||
|
|
||||||
|
// Clear callbacks
|
||||||
|
onPagesUpdated = null;
|
||||||
|
onIndexAdjusted = null;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ChapterPreload] Disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
lib/modules/manga/reader/mixins/reader_gestures.dart
Normal file
212
lib/modules/manga/reader/mixins/reader_gestures.dart
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Widget providing horizontal tap zones for reader navigation.
|
||||||
|
class HorizontalTapZones extends StatelessWidget {
|
||||||
|
/// Callback for left region tap.
|
||||||
|
final VoidCallback onLeftTap;
|
||||||
|
|
||||||
|
/// Callback for center region tap.
|
||||||
|
final VoidCallback onCenterTap;
|
||||||
|
|
||||||
|
/// Callback for right region tap.
|
||||||
|
final VoidCallback onRightTap;
|
||||||
|
|
||||||
|
/// Callback for double-tap with position.
|
||||||
|
final void Function(Offset position)? onDoubleTap;
|
||||||
|
|
||||||
|
/// Whether to show overlay for failed images.
|
||||||
|
final bool showFailedOverlay;
|
||||||
|
|
||||||
|
/// Widget to show when image failed to load.
|
||||||
|
final Widget? failedWidget;
|
||||||
|
|
||||||
|
const HorizontalTapZones({
|
||||||
|
super.key,
|
||||||
|
required this.onLeftTap,
|
||||||
|
required this.onCenterTap,
|
||||||
|
required this.onRightTap,
|
||||||
|
this.onDoubleTap,
|
||||||
|
this.showFailedOverlay = false,
|
||||||
|
this.failedWidget,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Left region (2 flex)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _TapZone(onTap: onLeftTap, onDoubleTap: onDoubleTap),
|
||||||
|
),
|
||||||
|
// Center region (2 flex)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: showFailedOverlay && failedWidget != null
|
||||||
|
? failedWidget!
|
||||||
|
: _TapZone(onTap: onCenterTap, onDoubleTap: onDoubleTap),
|
||||||
|
),
|
||||||
|
// Right region (2 flex)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _TapZone(onTap: onRightTap, onDoubleTap: onDoubleTap),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget providing vertical tap zones for reader navigation.
|
||||||
|
class VerticalTapZones extends StatelessWidget {
|
||||||
|
/// Callback for top region tap.
|
||||||
|
final VoidCallback onTopTap;
|
||||||
|
|
||||||
|
/// Callback for center region tap.
|
||||||
|
final VoidCallback onCenterTap;
|
||||||
|
|
||||||
|
/// Callback for bottom region tap.
|
||||||
|
final VoidCallback onBottomTap;
|
||||||
|
|
||||||
|
/// Callback for double-tap with position.
|
||||||
|
final void Function(Offset position)? onDoubleTap;
|
||||||
|
|
||||||
|
const VerticalTapZones({
|
||||||
|
super.key,
|
||||||
|
required this.onTopTap,
|
||||||
|
required this.onCenterTap,
|
||||||
|
required this.onBottomTap,
|
||||||
|
this.onDoubleTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Top region (2 flex)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _TapZone(onTap: onTopTap, onDoubleTap: onDoubleTap),
|
||||||
|
),
|
||||||
|
// Center region (5 flex) - larger for viewing
|
||||||
|
const Expanded(flex: 5, child: SizedBox.shrink()),
|
||||||
|
// Bottom region (2 flex)
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _TapZone(onTap: onBottomTap, onDoubleTap: onDoubleTap),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TapZone extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final void Function(Offset position)? onDoubleTap;
|
||||||
|
|
||||||
|
const _TapZone({required this.onTap, this.onDoubleTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: onTap,
|
||||||
|
onDoubleTapDown: onDoubleTap != null
|
||||||
|
? (details) => onDoubleTap!(details.globalPosition)
|
||||||
|
: null,
|
||||||
|
onDoubleTap: onDoubleTap != null ? () {} : null,
|
||||||
|
onSecondaryTapDown: onDoubleTap != null
|
||||||
|
? (details) => onDoubleTap!(details.globalPosition)
|
||||||
|
: null,
|
||||||
|
onSecondaryTap: onDoubleTap != null ? () {} : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for keyboard shortcuts in the reader.
|
||||||
|
class ReaderKeyboardHandler {
|
||||||
|
final VoidCallback? onEscape;
|
||||||
|
final VoidCallback? onFullScreen;
|
||||||
|
final VoidCallback? onPreviousPage;
|
||||||
|
final VoidCallback? onNextPage;
|
||||||
|
final VoidCallback? onNextChapter;
|
||||||
|
final VoidCallback? onPreviousChapter;
|
||||||
|
|
||||||
|
const ReaderKeyboardHandler({
|
||||||
|
this.onEscape,
|
||||||
|
this.onFullScreen,
|
||||||
|
this.onPreviousPage,
|
||||||
|
this.onNextPage,
|
||||||
|
this.onNextChapter,
|
||||||
|
this.onPreviousChapter,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Handles a key event and returns true if it was handled.
|
||||||
|
bool handleKeyEvent(KeyEvent event, {bool isReverseHorizontal = false}) {
|
||||||
|
if (event is! KeyDownEvent) return false;
|
||||||
|
switch (event.logicalKey) {
|
||||||
|
case LogicalKeyboardKey.f11:
|
||||||
|
onFullScreen?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.escape:
|
||||||
|
case LogicalKeyboardKey.backspace:
|
||||||
|
onEscape?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowUp:
|
||||||
|
onPreviousPage?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowDown:
|
||||||
|
onNextPage?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowLeft:
|
||||||
|
if (isReverseHorizontal) {
|
||||||
|
onNextPage?.call();
|
||||||
|
} else {
|
||||||
|
onPreviousPage?.call();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.arrowRight:
|
||||||
|
if (isReverseHorizontal) {
|
||||||
|
onPreviousPage?.call();
|
||||||
|
} else {
|
||||||
|
onNextPage?.call();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.keyN:
|
||||||
|
case LogicalKeyboardKey.pageDown:
|
||||||
|
case LogicalKeyboardKey.shiftRight:
|
||||||
|
onNextChapter?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LogicalKeyboardKey.keyP:
|
||||||
|
case LogicalKeyboardKey.pageUp:
|
||||||
|
case LogicalKeyboardKey.shiftLeft:
|
||||||
|
onPreviousChapter?.call();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a KeyboardListener widget with this handler.
|
||||||
|
Widget wrapWithKeyboardListener({
|
||||||
|
required Widget child,
|
||||||
|
bool isReverseHorizontal = false,
|
||||||
|
FocusNode? focusNode,
|
||||||
|
}) {
|
||||||
|
return KeyboardListener(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode ?? FocusNode(),
|
||||||
|
onKeyEvent: (event) =>
|
||||||
|
handleKeyEvent(event, isReverseHorizontal: isReverseHorizontal),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/modules/manga/reader/mixins/reader_memory_management.dart
Normal file
102
lib/modules/manga/reader/mixins/reader_memory_management.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/managers/chapter_preload_manager.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
||||||
|
import 'package:mangayomi/services/get_chapter_pages.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
|
||||||
|
mixin ReaderMemoryManagement {
|
||||||
|
/// The preload manager that handles memory-bounded chapter caching.
|
||||||
|
late final ChapterPreloadManager _preloadManager = ChapterPreloadManager();
|
||||||
|
|
||||||
|
/// Whether the preload manager has been initialized.
|
||||||
|
bool _isPreloadManagerInitialized = false;
|
||||||
|
|
||||||
|
/// Gets the preload manager.
|
||||||
|
ChapterPreloadManager get preloadManager => _preloadManager;
|
||||||
|
|
||||||
|
/// Gets all currently loaded pages.
|
||||||
|
List<UChapDataPreload> get pages => _preloadManager.pages;
|
||||||
|
|
||||||
|
/// Gets the total page count.
|
||||||
|
int get pageCount => _preloadManager.pageCount;
|
||||||
|
|
||||||
|
/// Gets the current page index.
|
||||||
|
int get currentPageIndex => _preloadManager.currentIndex;
|
||||||
|
|
||||||
|
/// Sets the current page index.
|
||||||
|
set currentPageIndex(int value) {
|
||||||
|
_preloadManager.currentIndex = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the preload manager with initial chapter data.
|
||||||
|
///
|
||||||
|
/// [chapterData] - The initial chapter pages to load.
|
||||||
|
/// [startIndex] - The initial page index (default: 0).
|
||||||
|
/// [onPagesUpdated] - Callback when pages are added/removed.
|
||||||
|
/// [onIndexAdjusted] - Callback when current index needs adjustment.
|
||||||
|
void initializePreloadManager(
|
||||||
|
GetChapterPagesModel chapterData, {
|
||||||
|
int startIndex = 0,
|
||||||
|
VoidCallback? onPagesUpdated,
|
||||||
|
void Function(int)? onIndexAdjusted,
|
||||||
|
}) {
|
||||||
|
if (_isPreloadManagerInitialized) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ReaderMemoryManagement] Already initialized, skipping');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_preloadManager.onPagesUpdated = onPagesUpdated;
|
||||||
|
_preloadManager.onIndexAdjusted = onIndexAdjusted;
|
||||||
|
|
||||||
|
_preloadManager.initialize(chapterData.uChapDataPreload, startIndex);
|
||||||
|
|
||||||
|
_isPreloadManagerInitialized = true;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[ReaderMemoryManagement] Initialized with ${chapterData.uChapDataPreload.length} pages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preloads the next chapter with automatic memory management.
|
||||||
|
///
|
||||||
|
/// Unlike the old implementation, this method will automatically
|
||||||
|
/// evict old chapters when the limit is reached.
|
||||||
|
///
|
||||||
|
/// [chapterData] - The chapter data to preload.
|
||||||
|
/// [currentChapter] - The current chapter (for transition page).
|
||||||
|
///
|
||||||
|
/// Returns a Future that completes with `true` if the chapter was preloaded,
|
||||||
|
/// `false` if it was already loaded or if preloading failed.
|
||||||
|
Future<bool> preloadNextChapter(
|
||||||
|
GetChapterPagesModel chapterData,
|
||||||
|
Chapter currentChapter,
|
||||||
|
) async {
|
||||||
|
return await _preloadManager.preloadNextChapter(
|
||||||
|
chapterData,
|
||||||
|
currentChapter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a "last chapter" transition page.
|
||||||
|
///
|
||||||
|
/// Returns `true` if added successfully, `false` if already added.
|
||||||
|
bool addLastChapterTransition(Chapter chapter) {
|
||||||
|
return _preloadManager.addLastChapterTransition(chapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes the preload manager and clears all cached data.
|
||||||
|
Future<void> disposePreloadManager() async {
|
||||||
|
if (!_isPreloadManagerInitialized) return;
|
||||||
|
|
||||||
|
await _preloadManager.dispose();
|
||||||
|
_isPreloadManagerInitialized = false;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[ReaderMemoryManagement] Disposed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
138
lib/modules/manga/reader/services/page_navigation_service.dart
Normal file
138
lib/modules/manga/reader/services/page_navigation_service.dart
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import 'package:extended_image/extended_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
|
/// Service for handling page navigation in the manga reader.
|
||||||
|
///
|
||||||
|
/// Abstracts the complexity of navigating between different reader modes:
|
||||||
|
/// - Paged modes (vertical, LTR, RTL)
|
||||||
|
/// - Continuous modes (vertical continuous, webtoon, horizontal continuous)
|
||||||
|
class PageNavigationService {
|
||||||
|
final ItemScrollController itemScrollController;
|
||||||
|
final ExtendedPageController extendedController;
|
||||||
|
|
||||||
|
const PageNavigationService({
|
||||||
|
required this.itemScrollController,
|
||||||
|
required this.extendedController,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Navigates to a specific page index.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - [index]: The target page index
|
||||||
|
/// - [readerMode]: Current reader mode
|
||||||
|
/// - [animate]: Whether to animate the transition
|
||||||
|
void navigateToPage({
|
||||||
|
required int index,
|
||||||
|
required ReaderMode readerMode,
|
||||||
|
required bool animate,
|
||||||
|
}) {
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
if (_isContinuousMode(readerMode)) {
|
||||||
|
_navigateContinuous(index, animate);
|
||||||
|
} else {
|
||||||
|
_navigatePaged(index, animate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to next page.
|
||||||
|
void nextPage({
|
||||||
|
required ReaderMode readerMode,
|
||||||
|
required int currentIndex,
|
||||||
|
required int maxPages,
|
||||||
|
required bool animate,
|
||||||
|
}) {
|
||||||
|
if (currentIndex >= maxPages - 1) return;
|
||||||
|
navigateToPage(
|
||||||
|
index: currentIndex + 1,
|
||||||
|
readerMode: readerMode,
|
||||||
|
animate: animate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to previous page.
|
||||||
|
void previousPage({
|
||||||
|
required ReaderMode readerMode,
|
||||||
|
required int currentIndex,
|
||||||
|
required bool animate,
|
||||||
|
}) {
|
||||||
|
if (currentIndex <= 0) return;
|
||||||
|
navigateToPage(
|
||||||
|
index: currentIndex - 1,
|
||||||
|
readerMode: readerMode,
|
||||||
|
animate: animate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jumps to a page without animation (for slider).
|
||||||
|
void jumpToPage({required int index, required ReaderMode readerMode}) {
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
if (_isContinuousMode(readerMode)) {
|
||||||
|
itemScrollController.jumpTo(index: index);
|
||||||
|
} else {
|
||||||
|
if (extendedController.hasClients) {
|
||||||
|
extendedController.jumpToPage(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateContinuous(int index, bool animate) {
|
||||||
|
if (animate) {
|
||||||
|
itemScrollController.scrollTo(
|
||||||
|
curve: Curves.ease,
|
||||||
|
index: index,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
itemScrollController.jumpTo(index: index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigatePaged(int index, bool animate) {
|
||||||
|
if (!extendedController.hasClients) return;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
extendedController.animateToPage(
|
||||||
|
index,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.ease,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extendedController.jumpToPage(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isContinuousMode(ReaderMode mode) {
|
||||||
|
return mode == ReaderMode.verticalContinuous ||
|
||||||
|
mode == ReaderMode.webtoon ||
|
||||||
|
mode == ReaderMode.horizontalContinuous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixin to add page navigation capabilities to reader state.
|
||||||
|
mixin PageNavigationMixin<T extends StatefulWidget> on State<T> {
|
||||||
|
PageNavigationService? _navigationService;
|
||||||
|
|
||||||
|
/// Initializes the navigation service with the required controllers.
|
||||||
|
void initPageNavigation({
|
||||||
|
required ItemScrollController itemScrollController,
|
||||||
|
required ExtendedPageController extendedController,
|
||||||
|
}) {
|
||||||
|
_navigationService = PageNavigationService(
|
||||||
|
itemScrollController: itemScrollController,
|
||||||
|
extendedController: extendedController,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the navigation service.
|
||||||
|
PageNavigationService get navigationService {
|
||||||
|
assert(
|
||||||
|
_navigationService != null,
|
||||||
|
'PageNavigationService not initialized. Call initPageNavigation first.',
|
||||||
|
);
|
||||||
|
return _navigationService!;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/modules/manga/reader/widgets/auto_scroll_button.dart
Normal file
59
lib/modules/manga/reader/widgets/auto_scroll_button.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Auto-scroll play/pause button for continuous reading modes.
|
||||||
|
///
|
||||||
|
/// Shows a play/pause button at the bottom-right corner when auto-scroll is enabled.
|
||||||
|
/// Only visible in vertical/horizontal continuous modes.
|
||||||
|
class ReaderAutoScrollButton extends StatelessWidget {
|
||||||
|
/// Whether the current mode supports auto-scroll (continuous modes).
|
||||||
|
final bool isContinuousMode;
|
||||||
|
|
||||||
|
/// Whether the UI is currently visible (hide button when UI is hidden).
|
||||||
|
final bool isUiVisible;
|
||||||
|
|
||||||
|
/// ValueNotifier for auto-scroll page setting (user preference).
|
||||||
|
final ValueNotifier<bool> autoScrollPage;
|
||||||
|
|
||||||
|
/// ValueNotifier for auto-scroll running state.
|
||||||
|
final ValueNotifier<bool> autoScroll;
|
||||||
|
|
||||||
|
/// Callback when play/pause is toggled.
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
|
||||||
|
const ReaderAutoScrollButton({
|
||||||
|
super.key,
|
||||||
|
required this.isContinuousMode,
|
||||||
|
required this.isUiVisible,
|
||||||
|
required this.autoScrollPage,
|
||||||
|
required this.autoScroll,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!isContinuousMode) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: isUiVisible
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ValueListenableBuilder(
|
||||||
|
valueListenable: autoScrollPage,
|
||||||
|
builder: (context, isEnabled, child) => isEnabled
|
||||||
|
? ValueListenableBuilder(
|
||||||
|
valueListenable: autoScroll,
|
||||||
|
builder: (context, isPlaying, child) => IconButton(
|
||||||
|
onPressed: onToggle,
|
||||||
|
icon: Icon(
|
||||||
|
isPlaying ? Icons.pause_circle : Icons.play_circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
lib/modules/manga/reader/widgets/double_page_view.dart
Normal file
325
lib/modules/manga/reader/widgets/double_page_view.dart
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
import 'package:extended_image/extended_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mangayomi/main.dart';
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/image_view_paged.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_paged.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||||
|
import 'package:mangayomi/providers/l10n_providers.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:photo_view/photo_view_gallery.dart';
|
||||||
|
|
||||||
|
/// Unified double page view for both paged and continuous reading modes.
|
||||||
|
///
|
||||||
|
/// This replaces both `DoubleColummView` and `DoubleColummVerticalView`
|
||||||
|
/// to eliminate code duplication (previously ~80% identical code).
|
||||||
|
class DoublePageView extends StatefulWidget {
|
||||||
|
/// The two pages to display side by side.
|
||||||
|
final List<UChapDataPreload?> pages;
|
||||||
|
|
||||||
|
/// Callback when an image is long-pressed.
|
||||||
|
final Function(UChapDataPreload data)? onLongPressData;
|
||||||
|
|
||||||
|
/// Background color setting.
|
||||||
|
final BackgroundColor backgroundColor;
|
||||||
|
|
||||||
|
/// Callback for image load failure state.
|
||||||
|
final Function(bool)? onFailedToLoadImage;
|
||||||
|
|
||||||
|
/// Whether to use the paged mode (with PhotoView zoom) or vertical mode.
|
||||||
|
///
|
||||||
|
/// - `true`: Paged mode with pinch-to-zoom support (uses PhotoViewGallery)
|
||||||
|
/// - `false`: Vertical/Continuous mode (simple Column layout)
|
||||||
|
final bool isPagedMode;
|
||||||
|
|
||||||
|
/// Whether to add top padding for the first page (vertical mode only).
|
||||||
|
final bool addTopPadding;
|
||||||
|
|
||||||
|
const DoublePageView({
|
||||||
|
super.key,
|
||||||
|
required this.pages,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.onLongPressData,
|
||||||
|
this.onFailedToLoadImage,
|
||||||
|
this.isPagedMode = true,
|
||||||
|
this.addTopPadding = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a paged mode double page view.
|
||||||
|
const DoublePageView.paged({
|
||||||
|
super.key,
|
||||||
|
required this.pages,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.onLongPressData,
|
||||||
|
this.onFailedToLoadImage,
|
||||||
|
}) : isPagedMode = true,
|
||||||
|
addTopPadding = false;
|
||||||
|
|
||||||
|
/// Creates a vertical/continuous mode double page view.
|
||||||
|
const DoublePageView.vertical({
|
||||||
|
super.key,
|
||||||
|
required this.pages,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.onLongPressData,
|
||||||
|
this.onFailedToLoadImage,
|
||||||
|
this.addTopPadding = true,
|
||||||
|
}) : isPagedMode = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DoublePageView> createState() => _DoublePageViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoublePageViewState extends State<DoublePageView>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
// Controllers for paged mode zoom
|
||||||
|
late AnimationController _scaleAnimationController;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
Alignment _scalePosition = Alignment.center;
|
||||||
|
final PhotoViewController _photoViewController = PhotoViewController();
|
||||||
|
final PhotoViewScaleStateController _photoViewScaleStateController =
|
||||||
|
PhotoViewScaleStateController();
|
||||||
|
|
||||||
|
Duration _doubleTapAnimationDuration() {
|
||||||
|
final doubleTapAnimationValue =
|
||||||
|
isar.settings.getSync(227)?.doubleTapAnimationSpeed ?? 1;
|
||||||
|
return switch (doubleTapAnimationValue) {
|
||||||
|
0 => const Duration(milliseconds: 10),
|
||||||
|
1 => const Duration(milliseconds: 800),
|
||||||
|
_ => const Duration(milliseconds: 200),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScaleEnd(
|
||||||
|
BuildContext context,
|
||||||
|
ScaleEndDetails details,
|
||||||
|
PhotoViewControllerValue controllerValue,
|
||||||
|
) {
|
||||||
|
if (controllerValue.scale! < 1) {
|
||||||
|
_photoViewScaleStateController.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double get pixelRatio => View.of(context).devicePixelRatio;
|
||||||
|
Size get size => View.of(context).physicalSize / pixelRatio;
|
||||||
|
|
||||||
|
Alignment _computeAlignmentByTapOffset(Offset offset) {
|
||||||
|
return Alignment(
|
||||||
|
(offset.dx - size.width / 2) / (size.width / 2),
|
||||||
|
(offset.dy - size.height / 2) / (size.height / 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.isPagedMode) {
|
||||||
|
_scaleAnimationController = AnimationController(
|
||||||
|
duration: _doubleTapAnimationDuration(),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animation = Tween(begin: 1.0, end: 2.0).animate(
|
||||||
|
CurvedAnimation(curve: Curves.ease, parent: _scaleAnimationController),
|
||||||
|
);
|
||||||
|
_animation.addListener(() {
|
||||||
|
_photoViewController.scale = _animation.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (widget.isPagedMode) {
|
||||||
|
_scaleAnimationController.dispose();
|
||||||
|
_photoViewController.dispose();
|
||||||
|
_photoViewScaleStateController.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleScale(Offset tapPosition) {
|
||||||
|
if (!widget.isPagedMode || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (_scaleAnimationController.isAnimating) return;
|
||||||
|
|
||||||
|
if (_photoViewController.scale == 1.0) {
|
||||||
|
_scalePosition = _computeAlignmentByTapOffset(tapPosition);
|
||||||
|
|
||||||
|
if (_scaleAnimationController.isCompleted) {
|
||||||
|
_scaleAnimationController.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scaleAnimationController.forward();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_photoViewController.scale == 2.0) {
|
||||||
|
_scaleAnimationController.reverse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_photoViewScaleStateController.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Check for transition pages
|
||||||
|
if (_isTransitionPage()) {
|
||||||
|
return _buildTransitionPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget.isPagedMode ? _buildPagedMode() : _buildVerticalMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTransitionPage() {
|
||||||
|
return (widget.pages.isNotEmpty &&
|
||||||
|
(widget.pages[0]?.isTransitionPage ?? false)) ||
|
||||||
|
(widget.pages.length > 1 &&
|
||||||
|
(widget.pages[1]?.isTransitionPage ?? false));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTransitionPage() {
|
||||||
|
final transitionPage = widget.pages.firstWhere(
|
||||||
|
(p) => p?.isTransitionPage ?? false,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transitionPage == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return widget.isPagedMode
|
||||||
|
? TransitionViewPaged(data: transitionPage)
|
||||||
|
: TransitionViewVertical(data: transitionPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPagedMode() {
|
||||||
|
return PhotoViewGallery.builder(
|
||||||
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
|
itemCount: 1,
|
||||||
|
builder: (context, _) {
|
||||||
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
controller: _photoViewController,
|
||||||
|
scaleStateController: _photoViewScaleStateController,
|
||||||
|
basePosition: _scalePosition,
|
||||||
|
onScaleEnd: _onScaleEnd,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onDoubleTapDown: (details) => _toggleScale(details.globalPosition),
|
||||||
|
onDoubleTap: () {},
|
||||||
|
child: _buildPageRow(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVerticalMode() {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Add top padding for first page
|
||||||
|
if (widget.addTopPadding && widget.pages[0]?.index == 0)
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.top),
|
||||||
|
_buildPageRow(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageRow() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (widget.pages.isNotEmpty && widget.pages[0] != null)
|
||||||
|
Flexible(child: _buildPageImage(widget.pages[0]!)),
|
||||||
|
if (widget.pages.length > 1 && widget.pages[1] != null)
|
||||||
|
Flexible(child: _buildPageImage(widget.pages[1]!)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageImage(UChapDataPreload pageData) {
|
||||||
|
final l10n = l10nLocalizations(context)!;
|
||||||
|
final onLongPress = widget.onLongPressData ?? (_) {};
|
||||||
|
|
||||||
|
return ImageViewPaged(
|
||||||
|
data: pageData,
|
||||||
|
loadStateChanged: (state) {
|
||||||
|
switch (state.extendedImageLoadState) {
|
||||||
|
case LoadState.loading:
|
||||||
|
return _buildLoadingState(state);
|
||||||
|
case LoadState.completed:
|
||||||
|
return _buildCompletedState(state);
|
||||||
|
case LoadState.failed:
|
||||||
|
return _buildFailedState(state, l10n);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressData: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLoadingState(ExtendedImageState state) {
|
||||||
|
final loadingProgress = state.loadingProgress;
|
||||||
|
final progress = loadingProgress?.expectedTotalBytes != null
|
||||||
|
? loadingProgress!.cumulativeBytesLoaded /
|
||||||
|
loadingProgress.expectedTotalBytes!
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: getBackgroundColor(widget.backgroundColor),
|
||||||
|
height: context.height(0.8),
|
||||||
|
child: CircularProgressIndicatorAnimateRotate(progress: progress),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompletedState(ExtendedImageState state) {
|
||||||
|
widget.onFailedToLoadImage?.call(false);
|
||||||
|
return Image(image: state.imageProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFailedState(ExtendedImageState state, dynamic l10n) {
|
||||||
|
widget.onFailedToLoadImage?.call(true);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: getBackgroundColor(widget.backgroundColor),
|
||||||
|
height: context.height(0.8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.image_loading_error,
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: _buildRetryButton(state, l10n),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRetryButton(ExtendedImageState state, dynamic l10n) {
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
state.reLoadImage();
|
||||||
|
widget.onFailedToLoadImage?.call(false);
|
||||||
|
},
|
||||||
|
onTap: () {
|
||||||
|
state.reLoadImage();
|
||||||
|
widget.onFailedToLoadImage?.call(false);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.primaryColor,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
child: Text(l10n.retry),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
lib/modules/manga/reader/widgets/image_actions_dialog.dart
Normal file
229
lib/modules/manga/reader/widgets/image_actions_dialog.dart
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mangayomi/eval/model/m_bridge.dart';
|
||||||
|
import 'package:mangayomi/main.dart';
|
||||||
|
import 'package:mangayomi/models/manga.dart';
|
||||||
|
import 'package:mangayomi/modules/library/providers/local_archive.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/u_chap_data_preload.dart';
|
||||||
|
import 'package:mangayomi/providers/l10n_providers.dart';
|
||||||
|
import 'package:mangayomi/providers/storage_provider.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/others.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// Bottom sheet dialog for long-press actions on manga images.
|
||||||
|
///
|
||||||
|
/// Provides options to:
|
||||||
|
/// - Set image as cover
|
||||||
|
/// - Share image
|
||||||
|
/// - Save image to gallery
|
||||||
|
class ImageActionsDialog {
|
||||||
|
/// Shows the image actions dialog.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - [context]: Build context
|
||||||
|
/// - [data]: The page data containing the image
|
||||||
|
/// - [manga]: The manga the image belongs to
|
||||||
|
/// - [chapterName]: Name of the chapter (for file naming)
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required UChapDataPreload data,
|
||||||
|
required Manga manga,
|
||||||
|
required String chapterName,
|
||||||
|
}) async {
|
||||||
|
final imageBytes = await data.getImageBytes;
|
||||||
|
if (imageBytes == null || !context.mounted) return;
|
||||||
|
|
||||||
|
final name = "${manga.name} $chapterName - ${data.pageIndex}".replaceAll(
|
||||||
|
RegExp(r'[^a-zA-Z0-9 .()\-\s]'),
|
||||||
|
'_',
|
||||||
|
);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
constraints: BoxConstraints(maxWidth: context.width(1)),
|
||||||
|
builder: (context) => _ImageActionsSheet(
|
||||||
|
imageBytes: imageBytes,
|
||||||
|
manga: manga,
|
||||||
|
fileName: name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageActionsSheet extends StatelessWidget {
|
||||||
|
final List<int> imageBytes;
|
||||||
|
final Manga manga;
|
||||||
|
final String fileName;
|
||||||
|
|
||||||
|
const _ImageActionsSheet({
|
||||||
|
required this.imageBytes,
|
||||||
|
required this.manga,
|
||||||
|
required this.fileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SuperListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(20),
|
||||||
|
topRight: Radius.circular(20),
|
||||||
|
),
|
||||||
|
color: context.themeData.scaffoldBackgroundColor,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Handle bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Container(
|
||||||
|
height: 7,
|
||||||
|
width: 35,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
color: context.secondaryColor.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ActionButton(
|
||||||
|
label: context.l10n.set_as_cover,
|
||||||
|
icon: Icons.image_outlined,
|
||||||
|
onPressed: () => _setAsCover(context),
|
||||||
|
),
|
||||||
|
_ActionButton(
|
||||||
|
label: context.l10n.share,
|
||||||
|
icon: Icons.share_outlined,
|
||||||
|
onPressed: () => _shareImage(context),
|
||||||
|
),
|
||||||
|
_ActionButton(
|
||||||
|
label: context.l10n.save,
|
||||||
|
icon: Icons.save_outlined,
|
||||||
|
onPressed: () => _saveImage(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setAsCover(BuildContext context) async {
|
||||||
|
final res = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
content: Text(context.l10n.use_this_as_cover_art),
|
||||||
|
actions: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.l10n.cancel),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 15),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
isar.writeTxnSync(() {
|
||||||
|
isar.mangas.putSync(
|
||||||
|
manga
|
||||||
|
..customCoverImage = Uint8List.fromList(
|
||||||
|
imageBytes,
|
||||||
|
).getCoverImage
|
||||||
|
..updatedAt = DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Navigator.pop(context, "ok");
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.ok),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res == "ok" && context.mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
botToast(context.l10n.cover_updated, second: 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shareImage(BuildContext context) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [
|
||||||
|
XFile.fromData(
|
||||||
|
Uint8List.fromList(imageBytes),
|
||||||
|
name: fileName,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveImage(BuildContext context) async {
|
||||||
|
final dir = await StorageProvider().getGalleryDirectory();
|
||||||
|
if (dir == null) return;
|
||||||
|
|
||||||
|
final file = File(p.join(dir.path, "$fileName.png"));
|
||||||
|
file.writeAsBytesSync(imageBytes);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
botToast(context.l10n.picture_saved, second: 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const _ActionButton({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(15),
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Padding(padding: const EdgeInsets.all(4), child: Icon(icon)),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/modules/manga/reader/widgets/page_indicator.dart
Normal file
60
lib/modules/manga/reader/widgets/page_indicator.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
|
||||||
|
|
||||||
|
/// Page indicator widget showing current page / total pages.
|
||||||
|
///
|
||||||
|
/// Displayed at the bottom center when the UI is hidden and
|
||||||
|
/// "show page numbers" setting is enabled.
|
||||||
|
class PageIndicator extends ConsumerWidget {
|
||||||
|
/// The current chapter being read.
|
||||||
|
final Chapter chapter;
|
||||||
|
|
||||||
|
/// Whether the UI overlay is currently visible.
|
||||||
|
final bool isUiVisible;
|
||||||
|
|
||||||
|
/// Total number of pages.
|
||||||
|
final int totalPages;
|
||||||
|
|
||||||
|
/// Function to format the current index for display.
|
||||||
|
final String Function(int index) formatCurrentIndex;
|
||||||
|
|
||||||
|
const PageIndicator({
|
||||||
|
super.key,
|
||||||
|
required this.chapter,
|
||||||
|
required this.isUiVisible,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.formatCurrentIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentIndex = ref.watch(currentIndexProvider(chapter));
|
||||||
|
final showPagesNumber = ref.watch(showPagesNumberStateProvider);
|
||||||
|
|
||||||
|
// Don't show when UI is visible or setting is disabled
|
||||||
|
if (isUiVisible || !showPagesNumber) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Text(
|
||||||
|
'${formatCurrentIndex(currentIndex)} / $totalPages',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20.0,
|
||||||
|
shadows: [
|
||||||
|
Shadow(offset: Offset(-1, -1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(1, -1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(1, 1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(-1, 1), blurRadius: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/modules/manga/reader/widgets/reader_app_bar.dart
Normal file
156
lib/modules/manga/reader/widgets/reader_app_bar.dart
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
|
||||||
|
import 'package:mangayomi/providers/l10n_providers.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/string_extensions.dart';
|
||||||
|
import 'package:mangayomi/utils/utils.dart';
|
||||||
|
|
||||||
|
/// The app bar for the manga reader.
|
||||||
|
///
|
||||||
|
/// Displays:
|
||||||
|
/// - Back button
|
||||||
|
/// - Manga name and chapter title
|
||||||
|
/// - Chapter list button
|
||||||
|
/// - Bookmark button
|
||||||
|
/// - Web view button (for non-local sources)
|
||||||
|
///
|
||||||
|
/// This widget is designed to be used directly in reader_view.dart
|
||||||
|
/// as a drop-in replacement for the _appBar() method.
|
||||||
|
class ReaderAppBar extends ConsumerWidget {
|
||||||
|
/// The chapter being read
|
||||||
|
final Chapter chapter;
|
||||||
|
|
||||||
|
/// The manga name to display
|
||||||
|
final String mangaName;
|
||||||
|
|
||||||
|
/// The chapter title to display
|
||||||
|
final String chapterTitle;
|
||||||
|
|
||||||
|
/// Whether the app bar is visible
|
||||||
|
final bool isVisible;
|
||||||
|
|
||||||
|
/// Whether the chapter is bookmarked
|
||||||
|
final bool isBookmarked;
|
||||||
|
|
||||||
|
/// Callback when back button is pressed
|
||||||
|
final VoidCallback onBackPressed;
|
||||||
|
|
||||||
|
/// Callback when bookmark button is pressed
|
||||||
|
final VoidCallback onBookmarkPressed;
|
||||||
|
|
||||||
|
/// Callback when web view button is pressed
|
||||||
|
final VoidCallback? onWebViewPressed;
|
||||||
|
|
||||||
|
/// Background color getter
|
||||||
|
final Color Function(BuildContext) backgroundColor;
|
||||||
|
|
||||||
|
const ReaderAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.chapter,
|
||||||
|
required this.mangaName,
|
||||||
|
required this.chapterTitle,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.isBookmarked,
|
||||||
|
required this.onBackPressed,
|
||||||
|
required this.onBookmarkPressed,
|
||||||
|
this.onWebViewPressed,
|
||||||
|
required this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
|
||||||
|
final isDesktop =
|
||||||
|
Platform.isMacOS || Platform.isLinux || Platform.isWindows;
|
||||||
|
final isLocalArchive = chapter.manga.value?.isLocalArchive ?? false;
|
||||||
|
|
||||||
|
double height = isVisible
|
||||||
|
? Platform.isIOS
|
||||||
|
? 120.0
|
||||||
|
: !fullScreenReader && !isDesktop
|
||||||
|
? 55.0
|
||||||
|
: 80.0
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: 0,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
width: context.width(1),
|
||||||
|
height: height,
|
||||||
|
curve: Curves.ease,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: PreferredSize(
|
||||||
|
preferredSize: Size.fromHeight(height),
|
||||||
|
child: AppBar(
|
||||||
|
centerTitle: false,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
titleSpacing: 0,
|
||||||
|
leading: BackButton(onPressed: onBackPressed),
|
||||||
|
title: _buildTitle(context),
|
||||||
|
actions: _buildActions(context, isLocalArchive),
|
||||||
|
backgroundColor: backgroundColor(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: SizedBox(
|
||||||
|
width: context.width(0.8),
|
||||||
|
child: Text(
|
||||||
|
'$mangaName ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: SizedBox(
|
||||||
|
width: context.width(0.8),
|
||||||
|
child: Text(
|
||||||
|
chapterTitle,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildActions(BuildContext context, bool isLocalArchive) {
|
||||||
|
return [
|
||||||
|
// Chapter list button
|
||||||
|
btnToShowChapterListDialog(context, context.l10n.chapters, chapter),
|
||||||
|
|
||||||
|
// Bookmark button
|
||||||
|
IconButton(
|
||||||
|
onPressed: onBookmarkPressed,
|
||||||
|
icon: Icon(
|
||||||
|
isBookmarked ? Icons.bookmark : Icons.bookmark_border_outlined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Web view button (only for non-local sources)
|
||||||
|
if (!isLocalArchive && onWebViewPressed != null)
|
||||||
|
IconButton(onPressed: onWebViewPressed, icon: const Icon(Icons.public)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the web view navigation data.
|
||||||
|
Map<String, dynamic>? buildWebViewData(Chapter chapter) {
|
||||||
|
final manga = chapter.manga.value;
|
||||||
|
if (manga == null) return null;
|
||||||
|
|
||||||
|
final source = getSource(manga.lang!, manga.source!, manga.sourceId);
|
||||||
|
if (source == null) return null;
|
||||||
|
|
||||||
|
final url = "${source.baseUrl}${chapter.url!.getUrlWithoutDomain}";
|
||||||
|
|
||||||
|
return {'url': url, 'sourceId': source.id.toString(), 'title': chapter.name!};
|
||||||
|
}
|
||||||
479
lib/modules/manga/reader/widgets/reader_bottom_bar.dart
Normal file
479
lib/modules/manga/reader/widgets/reader_bottom_bar.dart
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
|
||||||
|
import 'package:mangayomi/models/chapter.dart';
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/custom_value_indicator_shape.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:mangayomi/utils/global_style.dart';
|
||||||
|
|
||||||
|
/// The bottom bar for the manga reader.
|
||||||
|
///
|
||||||
|
/// This is a complete drop-in replacement for the _bottomBar() method in reader_view.dart.
|
||||||
|
/// It handles all the complex interactions including:
|
||||||
|
/// - Page slider with real-time updates via Consumer
|
||||||
|
/// - Chapter navigation
|
||||||
|
/// - Reader mode selection
|
||||||
|
/// - Crop borders toggle
|
||||||
|
/// - Double page mode toggle
|
||||||
|
/// - Settings access
|
||||||
|
class ReaderBottomBar extends ConsumerWidget {
|
||||||
|
/// The chapter being read
|
||||||
|
final Chapter chapter;
|
||||||
|
|
||||||
|
/// Whether the bar is visible
|
||||||
|
final bool isVisible;
|
||||||
|
|
||||||
|
/// Whether there is a previous chapter
|
||||||
|
final bool hasPreviousChapter;
|
||||||
|
|
||||||
|
/// Whether there is a next chapter
|
||||||
|
final bool hasNextChapter;
|
||||||
|
|
||||||
|
/// Callback when previous chapter button is pressed
|
||||||
|
final VoidCallback? onPreviousChapter;
|
||||||
|
|
||||||
|
/// Callback when next chapter button is pressed
|
||||||
|
final VoidCallback? onNextChapter;
|
||||||
|
|
||||||
|
/// Callback when slider value changes (for updating provider)
|
||||||
|
final void Function(int value, WidgetRef ref) onSliderChanged;
|
||||||
|
|
||||||
|
/// Callback when slider drag ends (for navigation)
|
||||||
|
final void Function(int value) onSliderChangeEnd;
|
||||||
|
|
||||||
|
/// Callback when reader mode is changed
|
||||||
|
final void Function(ReaderMode mode, WidgetRef ref) onReaderModeChanged;
|
||||||
|
|
||||||
|
/// Callback when page mode toggle button is pressed
|
||||||
|
final VoidCallback? onPageModeToggle;
|
||||||
|
|
||||||
|
/// Callback when settings button is pressed
|
||||||
|
final VoidCallback onSettingsPressed;
|
||||||
|
|
||||||
|
/// Provider for watching current reader mode
|
||||||
|
/// Accepts any ProviderListenable that returns ReaderMode?
|
||||||
|
/// (StateProvider, NotifierProvider, etc.)
|
||||||
|
final ProviderListenable<ReaderMode?> currentReaderModeProvider;
|
||||||
|
|
||||||
|
/// Provider family for watching current page index
|
||||||
|
/// Type: CurrentIndexFamily (from reader_controller_provider.g.dart)
|
||||||
|
final CurrentIndexFamily currentIndexProvider;
|
||||||
|
|
||||||
|
/// Current page mode (nullable for safety)
|
||||||
|
final PageMode? currentPageMode;
|
||||||
|
|
||||||
|
/// Whether RTL reading direction is active
|
||||||
|
final bool isReverseHorizontal;
|
||||||
|
|
||||||
|
/// Total number of pages in current chapter
|
||||||
|
final int totalPages;
|
||||||
|
|
||||||
|
/// Function to get current page index label
|
||||||
|
final String Function(int currentIndex) currentIndexLabel;
|
||||||
|
|
||||||
|
/// Background color getter
|
||||||
|
final Color Function(BuildContext) backgroundColor;
|
||||||
|
|
||||||
|
const ReaderBottomBar({
|
||||||
|
super.key,
|
||||||
|
required this.chapter,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.hasPreviousChapter,
|
||||||
|
required this.hasNextChapter,
|
||||||
|
this.onPreviousChapter,
|
||||||
|
this.onNextChapter,
|
||||||
|
required this.onSliderChanged,
|
||||||
|
required this.onSliderChangeEnd,
|
||||||
|
required this.onReaderModeChanged,
|
||||||
|
this.onPageModeToggle,
|
||||||
|
required this.onSettingsPressed,
|
||||||
|
required this.currentReaderModeProvider,
|
||||||
|
required this.currentIndexProvider,
|
||||||
|
required this.currentPageMode,
|
||||||
|
required this.isReverseHorizontal,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.currentIndexLabel,
|
||||||
|
required this.backgroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get _isDoublePageMode => currentPageMode == PageMode.doublePage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final readerMode = ref.watch(currentReaderModeProvider);
|
||||||
|
final isHorizontalContinuous =
|
||||||
|
readerMode == ReaderMode.horizontalContinuous;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
curve: Curves.ease,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
width: context.width(1),
|
||||||
|
height: isVisible ? 130 : 0,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Page slider section
|
||||||
|
Flexible(
|
||||||
|
child: _buildPageSlider(context, ref, isHorizontalContinuous),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Quick actions section
|
||||||
|
Flexible(
|
||||||
|
child: _buildQuickActions(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
readerMode,
|
||||||
|
isHorizontalContinuous,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPageSlider(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
bool isHorizontalContinuous,
|
||||||
|
) {
|
||||||
|
return Transform.scale(
|
||||||
|
scaleX: !isReverseHorizontal ? 1 : -1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Previous chapter button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 23,
|
||||||
|
backgroundColor: backgroundColor(context),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: hasPreviousChapter ? onPreviousChapter : null,
|
||||||
|
icon: Transform.scale(
|
||||||
|
scaleX: 1,
|
||||||
|
child: Icon(
|
||||||
|
Icons.skip_previous_rounded,
|
||||||
|
color: hasPreviousChapter
|
||||||
|
? Theme.of(context).textTheme.bodyLarge!.color
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Slider container
|
||||||
|
Flexible(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
height: 70,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor(context),
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Current page label
|
||||||
|
Transform.scale(
|
||||||
|
scaleX: !isReverseHorizontal ? 1 : -1,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 55,
|
||||||
|
child: Center(
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final currentIndex = ref.watch(
|
||||||
|
currentIndexProvider(chapter),
|
||||||
|
);
|
||||||
|
return Text(
|
||||||
|
currentIndexLabel(currentIndex),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
if (isVisible)
|
||||||
|
Flexible(
|
||||||
|
flex: 14,
|
||||||
|
child: _buildSlider(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
isHorizontalContinuous,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Total pages label
|
||||||
|
Transform.scale(
|
||||||
|
scaleX: !isReverseHorizontal ? 1 : -1,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 55,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
"$totalPages",
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Next chapter button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 23,
|
||||||
|
backgroundColor: backgroundColor(context),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: hasNextChapter ? onNextChapter : null,
|
||||||
|
icon: Transform.scale(
|
||||||
|
scaleX: 1,
|
||||||
|
child: Icon(
|
||||||
|
Icons.skip_next_rounded,
|
||||||
|
color: hasNextChapter
|
||||||
|
? Theme.of(context).textTheme.bodyLarge!.color
|
||||||
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSlider(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
bool isHorizontalContinuous,
|
||||||
|
) {
|
||||||
|
return Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final currentIndex = ref.watch(currentIndexProvider(chapter));
|
||||||
|
|
||||||
|
final maxValue = (_isDoublePageMode && !isHorizontalContinuous)
|
||||||
|
? ((totalPages / 2).ceil() + 1).toDouble()
|
||||||
|
: (totalPages - 1).toDouble();
|
||||||
|
|
||||||
|
final divisions = totalPages == 1
|
||||||
|
? null
|
||||||
|
: _isDoublePageMode
|
||||||
|
? (totalPages / 2).ceil() + 1
|
||||||
|
: totalPages - 1;
|
||||||
|
|
||||||
|
final currentValue = min(
|
||||||
|
currentIndex.toDouble(),
|
||||||
|
(_isDoublePageMode && !isHorizontalContinuous)
|
||||||
|
? ((totalPages / 2).ceil() + 1).toDouble()
|
||||||
|
: totalPages.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
valueIndicatorShape: CustomValueIndicatorShape(
|
||||||
|
tranform: isReverseHorizontal,
|
||||||
|
),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 5.0),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
onChanged: (value) {
|
||||||
|
onSliderChanged(value.toInt(), ref);
|
||||||
|
},
|
||||||
|
onChangeEnd: (newValue) {
|
||||||
|
onSliderChangeEnd(newValue.toInt());
|
||||||
|
},
|
||||||
|
divisions: divisions,
|
||||||
|
value: currentValue,
|
||||||
|
label: currentIndexLabel(currentIndex),
|
||||||
|
min: 0,
|
||||||
|
max: maxValue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickActions(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ReaderMode? readerMode,
|
||||||
|
bool isHorizontalContinuous,
|
||||||
|
) {
|
||||||
|
return Container(
|
||||||
|
height: 65,
|
||||||
|
color: backgroundColor(context),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
// Reader mode button
|
||||||
|
PopupMenuButton<ReaderMode>(
|
||||||
|
popUpAnimationStyle: popupAnimationStyle,
|
||||||
|
color: Colors.black,
|
||||||
|
onSelected: (value) {
|
||||||
|
onReaderModeChanged(value, ref);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
for (var mode in ReaderMode.values)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: mode,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: readerMode == mode
|
||||||
|
? Colors.white
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Text(
|
||||||
|
getReaderModeName(mode, context),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const Icon(Icons.app_settings_alt_outlined),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Crop borders button
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final cropBorders = ref.watch(cropBordersStateProvider);
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref.read(cropBordersStateProvider.notifier).set(!cropBorders);
|
||||||
|
},
|
||||||
|
icon: Stack(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.crop_rounded),
|
||||||
|
if (!cropBorders)
|
||||||
|
Positioned(
|
||||||
|
right: 8,
|
||||||
|
child: Transform.scale(
|
||||||
|
scaleX: 2.5,
|
||||||
|
child: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('\\', style: TextStyle(fontSize: 17)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Double page mode button
|
||||||
|
IconButton(
|
||||||
|
onPressed: !isHorizontalContinuous ? onPageModeToggle : null,
|
||||||
|
icon: Icon(
|
||||||
|
_isDoublePageMode
|
||||||
|
? CupertinoIcons.book_solid
|
||||||
|
: CupertinoIcons.book,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Settings button
|
||||||
|
IconButton(
|
||||||
|
onPressed: onSettingsPressed,
|
||||||
|
icon: const Icon(Icons.settings_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget to display the current page number when UI is hidden.
|
||||||
|
class PageNumberOverlay extends StatelessWidget {
|
||||||
|
final int currentIndex;
|
||||||
|
final int totalPages;
|
||||||
|
final bool isVisible;
|
||||||
|
final bool showPageNumbers;
|
||||||
|
final PageMode pageMode;
|
||||||
|
|
||||||
|
const PageNumberOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.totalPages,
|
||||||
|
required this.isVisible,
|
||||||
|
required this.showPageNumbers,
|
||||||
|
required this.pageMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isVisible || !showPageNumbers) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final label = pageMode == PageMode.doublePage && currentIndex > 0
|
||||||
|
? _getDoublePageLabel()
|
||||||
|
: '${currentIndex + 1}';
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'$label / $totalPages',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20.0,
|
||||||
|
shadows: [
|
||||||
|
Shadow(offset: Offset(-1, -1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(1, -1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(1, 1), blurRadius: 1),
|
||||||
|
Shadow(offset: Offset(-1, 1), blurRadius: 1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getDoublePageLabel() {
|
||||||
|
final index1 = currentIndex * 2;
|
||||||
|
final index2 = index1 + 1;
|
||||||
|
|
||||||
|
if (index1 >= totalPages) {
|
||||||
|
return '$totalPages';
|
||||||
|
}
|
||||||
|
|
||||||
|
return index2 >= totalPages ? '$totalPages' : '$index1-$index2';
|
||||||
|
}
|
||||||
|
}
|
||||||
222
lib/modules/manga/reader/widgets/reader_gesture_handler.dart
Normal file
222
lib/modules/manga/reader/widgets/reader_gesture_handler.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||||
|
|
||||||
|
/// Manages gesture detection zones and tap handling for the reader.
|
||||||
|
///
|
||||||
|
/// The reader screen is divided into zones:
|
||||||
|
///
|
||||||
|
/// For horizontal reading (LTR):
|
||||||
|
/// ```
|
||||||
|
/// ┌─────────────────────────┐
|
||||||
|
/// │ TOP (prev page) │
|
||||||
|
/// ├───────┬───────┬─────────┤
|
||||||
|
/// │ LEFT │CENTER │ RIGHT │
|
||||||
|
/// │(prev) │ (UI) │ (next) │
|
||||||
|
/// ├───────┴───────┴─────────┤
|
||||||
|
/// │ BOTTOM (next page) │
|
||||||
|
/// └─────────────────────────┘
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// For RTL mode, LEFT and RIGHT actions are reversed.
|
||||||
|
class ReaderGestureHandler extends StatelessWidget {
|
||||||
|
/// Whether tap zones are enabled for navigation
|
||||||
|
final bool usePageTapZones;
|
||||||
|
|
||||||
|
/// Whether the reader is in RTL mode
|
||||||
|
final bool isRTL;
|
||||||
|
|
||||||
|
/// Whether there's an image loading error
|
||||||
|
final bool hasImageError;
|
||||||
|
|
||||||
|
/// Whether the reader is in continuous scroll mode
|
||||||
|
final bool isContinuousMode;
|
||||||
|
|
||||||
|
/// Callback when UI should be toggled
|
||||||
|
final VoidCallback onToggleUI;
|
||||||
|
|
||||||
|
/// Callback to go to previous page
|
||||||
|
final VoidCallback onPreviousPage;
|
||||||
|
|
||||||
|
/// Callback to go to next page
|
||||||
|
final VoidCallback onNextPage;
|
||||||
|
|
||||||
|
/// Callback for double-tap to zoom (with position)
|
||||||
|
final void Function(Offset position)? onDoubleTapDown;
|
||||||
|
|
||||||
|
/// Callback for double-tap gesture complete
|
||||||
|
final VoidCallback? onDoubleTap;
|
||||||
|
|
||||||
|
/// Callback for secondary tap (right-click on desktop)
|
||||||
|
final void Function(Offset position)? onSecondaryTapDown;
|
||||||
|
|
||||||
|
/// Callback for secondary tap complete
|
||||||
|
final VoidCallback? onSecondaryTap;
|
||||||
|
|
||||||
|
const ReaderGestureHandler({
|
||||||
|
super.key,
|
||||||
|
required this.usePageTapZones,
|
||||||
|
required this.isRTL,
|
||||||
|
required this.hasImageError,
|
||||||
|
required this.isContinuousMode,
|
||||||
|
required this.onToggleUI,
|
||||||
|
required this.onPreviousPage,
|
||||||
|
required this.onNextPage,
|
||||||
|
this.onDoubleTapDown,
|
||||||
|
this.onDoubleTap,
|
||||||
|
this.onSecondaryTapDown,
|
||||||
|
this.onSecondaryTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// Horizontal zones (left, center, right)
|
||||||
|
_buildHorizontalZones(context),
|
||||||
|
|
||||||
|
// Vertical zones (top, center, bottom)
|
||||||
|
_buildVerticalZones(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHorizontalZones(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Left zone
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _ZoneGestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (usePageTapZones) {
|
||||||
|
isRTL ? onNextPage() : onPreviousPage();
|
||||||
|
} else {
|
||||||
|
onToggleUI();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleTapDown: isContinuousMode ? onDoubleTapDown : null,
|
||||||
|
onDoubleTap: isContinuousMode ? onDoubleTap : null,
|
||||||
|
onSecondaryTapDown: isContinuousMode ? onSecondaryTapDown : null,
|
||||||
|
onSecondaryTap: isContinuousMode ? onSecondaryTap : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Center zone
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: hasImageError
|
||||||
|
? SizedBox(width: context.width(1), height: context.height(0.7))
|
||||||
|
: _ZoneGestureDetector(
|
||||||
|
onTap: onToggleUI,
|
||||||
|
onDoubleTapDown: isContinuousMode ? onDoubleTapDown : null,
|
||||||
|
onDoubleTap: isContinuousMode ? onDoubleTap : null,
|
||||||
|
onSecondaryTapDown: isContinuousMode
|
||||||
|
? onSecondaryTapDown
|
||||||
|
: null,
|
||||||
|
onSecondaryTap: isContinuousMode ? onSecondaryTap : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Right zone
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _ZoneGestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (usePageTapZones) {
|
||||||
|
isRTL ? onPreviousPage() : onNextPage();
|
||||||
|
} else {
|
||||||
|
onToggleUI();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleTapDown: isContinuousMode ? onDoubleTapDown : null,
|
||||||
|
onDoubleTap: isContinuousMode ? onDoubleTap : null,
|
||||||
|
onSecondaryTapDown: isContinuousMode ? onSecondaryTapDown : null,
|
||||||
|
onSecondaryTap: isContinuousMode ? onSecondaryTap : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildVerticalZones(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Top zone
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _ZoneGestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (hasImageError) {
|
||||||
|
onToggleUI();
|
||||||
|
} else if (usePageTapZones) {
|
||||||
|
onPreviousPage();
|
||||||
|
} else {
|
||||||
|
onToggleUI();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleTapDown: isContinuousMode ? onDoubleTapDown : null,
|
||||||
|
onDoubleTap: isContinuousMode ? onDoubleTap : null,
|
||||||
|
onSecondaryTapDown: isContinuousMode ? onSecondaryTapDown : null,
|
||||||
|
onSecondaryTap: isContinuousMode ? onSecondaryTap : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Center zone (transparent, handled by horizontal zones)
|
||||||
|
const Expanded(flex: 5, child: SizedBox.shrink()),
|
||||||
|
|
||||||
|
// Bottom zone
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: _ZoneGestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (hasImageError) {
|
||||||
|
onToggleUI();
|
||||||
|
} else if (usePageTapZones) {
|
||||||
|
onNextPage();
|
||||||
|
} else {
|
||||||
|
onToggleUI();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDoubleTapDown: isContinuousMode ? onDoubleTapDown : null,
|
||||||
|
onDoubleTap: isContinuousMode ? onDoubleTap : null,
|
||||||
|
onSecondaryTapDown: isContinuousMode ? onSecondaryTapDown : null,
|
||||||
|
onSecondaryTap: isContinuousMode ? onSecondaryTap : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual gesture detector for a zone.
|
||||||
|
class _ZoneGestureDetector extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final void Function(Offset position)? onDoubleTapDown;
|
||||||
|
final VoidCallback? onDoubleTap;
|
||||||
|
final void Function(Offset position)? onSecondaryTapDown;
|
||||||
|
final VoidCallback? onSecondaryTap;
|
||||||
|
|
||||||
|
const _ZoneGestureDetector({
|
||||||
|
required this.onTap,
|
||||||
|
this.onDoubleTapDown,
|
||||||
|
this.onDoubleTap,
|
||||||
|
this.onSecondaryTapDown,
|
||||||
|
this.onSecondaryTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: onTap,
|
||||||
|
onDoubleTapDown: onDoubleTapDown != null
|
||||||
|
? (details) => onDoubleTapDown!(details.globalPosition)
|
||||||
|
: null,
|
||||||
|
onDoubleTap: onDoubleTap,
|
||||||
|
onSecondaryTapDown: onSecondaryTapDown != null
|
||||||
|
? (details) => onSecondaryTapDown!(details.globalPosition)
|
||||||
|
: null,
|
||||||
|
onSecondaryTap: onSecondaryTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
425
lib/modules/manga/reader/widgets/reader_settings_modal.dart
Normal file
425
lib/modules/manga/reader/widgets/reader_settings_modal.dart
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/misc.dart' show ProviderListenable;
|
||||||
|
import 'package:mangayomi/models/settings.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/color_filter_widget.dart';
|
||||||
|
import 'package:mangayomi/modules/manga/reader/widgets/custom_popup_menu_button.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
|
||||||
|
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||||
|
import 'package:mangayomi/modules/widgets/custom_draggable_tabbar.dart';
|
||||||
|
import 'package:mangayomi/providers/l10n_providers.dart';
|
||||||
|
|
||||||
|
/// Settings modal for the manga reader using Riverpod providers directly.
|
||||||
|
///
|
||||||
|
/// This is a complete replacement for the _showModalSettings() method.
|
||||||
|
/// It uses the same providers and matches the exact behavior.
|
||||||
|
class ReaderSettingsModal {
|
||||||
|
/// Shows the settings modal.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - [context]: The build context
|
||||||
|
/// - [vsync]: The ticker provider (usually the State object)
|
||||||
|
/// - [currentReaderModeProvider]: The provider for current reader mode
|
||||||
|
/// - [autoScrollPage]: ValueNotifier for auto-scroll page state
|
||||||
|
/// - [autoScroll]: ValueNotifier for auto-scroll running state
|
||||||
|
/// - [pageOffset]: ValueNotifier for page offset (scroll speed)
|
||||||
|
/// - [onReaderModeChanged]: Callback when reader mode changes
|
||||||
|
/// - [onAutoScrollSave]: Callback to save auto-scroll settings
|
||||||
|
/// - [onFullScreenToggle]: Callback to toggle fullscreen
|
||||||
|
/// - [onAutoPageScroll]: Callback to trigger auto-scroll
|
||||||
|
static Future<void> show({
|
||||||
|
required BuildContext context,
|
||||||
|
required TickerProvider vsync,
|
||||||
|
required ProviderListenable<ReaderMode?> currentReaderModeProvider,
|
||||||
|
required ValueNotifier<bool> autoScrollPage,
|
||||||
|
required ValueNotifier<bool> autoScroll,
|
||||||
|
required ValueNotifier<double> pageOffset,
|
||||||
|
required void Function(ReaderMode mode, WidgetRef ref) onReaderModeChanged,
|
||||||
|
required void Function(bool enabled, double offset) onAutoScrollSave,
|
||||||
|
required VoidCallback onFullScreenToggle,
|
||||||
|
required VoidCallback onAutoPageScroll,
|
||||||
|
}) async {
|
||||||
|
// Pause auto-scroll while settings are open
|
||||||
|
final autoScrollWasRunning = autoScroll.value;
|
||||||
|
if (autoScrollWasRunning) {
|
||||||
|
autoScroll.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = l10nLocalizations(context)!;
|
||||||
|
|
||||||
|
await customDraggableTabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: l10n.reading_mode),
|
||||||
|
Tab(text: l10n.general),
|
||||||
|
Tab(text: l10n.custom_filter),
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
// Reading Mode Tab
|
||||||
|
_ReadingModeTab(
|
||||||
|
currentReaderModeProvider: currentReaderModeProvider,
|
||||||
|
autoScrollPage: autoScrollPage,
|
||||||
|
pageOffset: pageOffset,
|
||||||
|
onReaderModeChanged: onReaderModeChanged,
|
||||||
|
onAutoScrollSave: onAutoScrollSave,
|
||||||
|
onAutoScroll: (val) {
|
||||||
|
autoScroll.value = val;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// General Tab
|
||||||
|
_GeneralTab(onFullScreenToggle: onFullScreenToggle),
|
||||||
|
|
||||||
|
// Custom Filter Tab
|
||||||
|
const _CustomFilterTab(),
|
||||||
|
],
|
||||||
|
context: context,
|
||||||
|
vsync: vsync,
|
||||||
|
fullWidth: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resume auto-scroll if it was running
|
||||||
|
if (autoScrollWasRunning || autoScroll.value) {
|
||||||
|
if (autoScrollPage.value) {
|
||||||
|
onAutoPageScroll();
|
||||||
|
autoScroll.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reading Mode Tab with Consumer for reactive updates.
|
||||||
|
class _ReadingModeTab extends ConsumerWidget {
|
||||||
|
final ProviderListenable<ReaderMode?> currentReaderModeProvider;
|
||||||
|
final ValueNotifier<bool> autoScrollPage;
|
||||||
|
final ValueNotifier<double> pageOffset;
|
||||||
|
final void Function(ReaderMode mode, WidgetRef ref) onReaderModeChanged;
|
||||||
|
final void Function(bool enabled, double offset) onAutoScrollSave;
|
||||||
|
final void Function(bool val) onAutoScroll;
|
||||||
|
|
||||||
|
const _ReadingModeTab({
|
||||||
|
required this.currentReaderModeProvider,
|
||||||
|
required this.autoScrollPage,
|
||||||
|
required this.pageOffset,
|
||||||
|
required this.onReaderModeChanged,
|
||||||
|
required this.onAutoScrollSave,
|
||||||
|
required this.onAutoScroll,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = l10nLocalizations(context)!;
|
||||||
|
final readerMode = ref.watch(currentReaderModeProvider);
|
||||||
|
final usePageTapZones = ref.watch(usePageTapZonesStateProvider);
|
||||||
|
final cropBorders = ref.watch(cropBordersStateProvider);
|
||||||
|
|
||||||
|
final isContinuousMode =
|
||||||
|
readerMode == ReaderMode.verticalContinuous ||
|
||||||
|
readerMode == ReaderMode.webtoon ||
|
||||||
|
readerMode == ReaderMode.horizontalContinuous;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Reader Mode
|
||||||
|
CustomPopupMenuButton<ReaderMode>(
|
||||||
|
label: l10n.reading_mode,
|
||||||
|
title: getReaderModeName(readerMode!, context),
|
||||||
|
onSelected: (value) {
|
||||||
|
onReaderModeChanged(value, ref);
|
||||||
|
},
|
||||||
|
value: readerMode,
|
||||||
|
list: ReaderMode.values,
|
||||||
|
itemText: (mode) => getReaderModeName(mode, context),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Crop Borders
|
||||||
|
SwitchListTile(
|
||||||
|
value: cropBorders,
|
||||||
|
title: Text(
|
||||||
|
l10n.crop_borders,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(cropBordersStateProvider.notifier).set(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Page Tap Zones
|
||||||
|
SwitchListTile(
|
||||||
|
value: usePageTapZones,
|
||||||
|
title: Text(
|
||||||
|
l10n.use_page_tap_zones,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(usePageTapZonesStateProvider.notifier).set(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Auto-scroll (only for continuous modes)
|
||||||
|
if (isContinuousMode)
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: autoScrollPage,
|
||||||
|
builder: (context, valueT, child) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: Icon(
|
||||||
|
valueT ? Icons.timer : Icons.timer_outlined,
|
||||||
|
),
|
||||||
|
value: valueT,
|
||||||
|
title: Text(
|
||||||
|
context.l10n.auto_scroll,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).textTheme.bodyLarge!.color!
|
||||||
|
.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
onAutoScrollSave(val, pageOffset.value);
|
||||||
|
autoScrollPage.value = val;
|
||||||
|
onAutoScroll(val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (valueT)
|
||||||
|
ValueListenableBuilder(
|
||||||
|
valueListenable: pageOffset,
|
||||||
|
builder: (context, value, child) => Slider(
|
||||||
|
min: 2.0,
|
||||||
|
max: 30.0,
|
||||||
|
divisions: max(28, 3),
|
||||||
|
value: value,
|
||||||
|
onChanged: (val) {
|
||||||
|
pageOffset.value = val;
|
||||||
|
},
|
||||||
|
onChangeEnd: (val) {
|
||||||
|
onAutoScrollSave(valueT, val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// General Tab with Consumer for reactive updates.
|
||||||
|
class _GeneralTab extends ConsumerWidget {
|
||||||
|
final VoidCallback onFullScreenToggle;
|
||||||
|
|
||||||
|
const _GeneralTab({required this.onFullScreenToggle});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = l10nLocalizations(context)!;
|
||||||
|
final showPagesNumber = ref.watch(showPagesNumberStateProvider);
|
||||||
|
final animatePageTransitions = ref.watch(
|
||||||
|
animatePageTransitionsStateProvider,
|
||||||
|
);
|
||||||
|
final scaleType = ref.watch(scaleTypeStateProvider);
|
||||||
|
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
|
||||||
|
final backgroundColor = ref.watch(backgroundColorStateProvider);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Background Color
|
||||||
|
CustomPopupMenuButton<BackgroundColor>(
|
||||||
|
label: l10n.background_color,
|
||||||
|
title: getBackgroundColorName(backgroundColor, context),
|
||||||
|
onSelected: (value) {
|
||||||
|
ref.read(backgroundColorStateProvider.notifier).set(value);
|
||||||
|
},
|
||||||
|
value: backgroundColor,
|
||||||
|
list: BackgroundColor.values,
|
||||||
|
itemText: (color) => getBackgroundColorName(color, context),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Scale Type
|
||||||
|
CustomPopupMenuButton<ScaleType>(
|
||||||
|
label: l10n.scale_type,
|
||||||
|
title: getScaleTypeNames(context)[scaleType.index],
|
||||||
|
onSelected: (value) {
|
||||||
|
ref
|
||||||
|
.read(scaleTypeStateProvider.notifier)
|
||||||
|
.set(ScaleType.values[value.index]);
|
||||||
|
},
|
||||||
|
value: scaleType,
|
||||||
|
list: ScaleType.values.where((scale) {
|
||||||
|
try {
|
||||||
|
return getScaleTypeNames(
|
||||||
|
context,
|
||||||
|
).contains(getScaleTypeNames(context)[scale.index]);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}).toList(),
|
||||||
|
itemText: (scale) => getScaleTypeNames(context)[scale.index],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Fullscreen
|
||||||
|
SwitchListTile(
|
||||||
|
value: fullScreenReader,
|
||||||
|
title: Text(
|
||||||
|
l10n.fullscreen,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
onFullScreenToggle();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show Page Numbers
|
||||||
|
SwitchListTile(
|
||||||
|
value: showPagesNumber,
|
||||||
|
title: Text(
|
||||||
|
l10n.show_page_number,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(showPagesNumberStateProvider.notifier).set(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Animate Page Transitions
|
||||||
|
SwitchListTile(
|
||||||
|
value: animatePageTransitions,
|
||||||
|
title: Text(
|
||||||
|
l10n.animate_page_transitions,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
ref
|
||||||
|
.read(animatePageTransitionsStateProvider.notifier)
|
||||||
|
.set(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom Filter Tab with Consumer for reactive updates.
|
||||||
|
class _CustomFilterTab extends ConsumerWidget {
|
||||||
|
const _CustomFilterTab();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = l10nLocalizations(context)!;
|
||||||
|
final customColorFilter = ref.watch(customColorFilterStateProvider);
|
||||||
|
final enableCustomColorFilter = ref.watch(
|
||||||
|
enableCustomColorFilterStateProvider,
|
||||||
|
);
|
||||||
|
final colorFilterBlendMode = ref.watch(colorFilterBlendModeStateProvider);
|
||||||
|
|
||||||
|
int r = customColorFilter?.r ?? 0;
|
||||||
|
int g = customColorFilter?.g ?? 0;
|
||||||
|
int b = customColorFilter?.b ?? 0;
|
||||||
|
int a = customColorFilter?.a ?? 0;
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Enable Custom Color Filter
|
||||||
|
SwitchListTile(
|
||||||
|
value: enableCustomColorFilter,
|
||||||
|
title: Text(
|
||||||
|
l10n.custom_color_filter,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge!.color!.withValues(alpha: 0.9),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
ref
|
||||||
|
.read(enableCustomColorFilterStateProvider.notifier)
|
||||||
|
.set(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
if (enableCustomColorFilter) ...[
|
||||||
|
// RGBA Sliders
|
||||||
|
rgbaFilterWidget(a, r, g, b, (val) {
|
||||||
|
final notifier = ref.read(
|
||||||
|
customColorFilterStateProvider.notifier,
|
||||||
|
);
|
||||||
|
if (val.$3 == "r") {
|
||||||
|
notifier.set(a, val.$1.toInt(), g, b, val.$2);
|
||||||
|
} else if (val.$3 == "g") {
|
||||||
|
notifier.set(a, r, val.$1.toInt(), b, val.$2);
|
||||||
|
} else if (val.$3 == "b") {
|
||||||
|
notifier.set(a, r, g, val.$1.toInt(), val.$2);
|
||||||
|
} else {
|
||||||
|
notifier.set(val.$1.toInt(), r, g, b, val.$2);
|
||||||
|
}
|
||||||
|
}, context),
|
||||||
|
|
||||||
|
// Blend Mode
|
||||||
|
CustomPopupMenuButton<ColorFilterBlendMode>(
|
||||||
|
label: l10n.color_filter_blend_mode,
|
||||||
|
title: getColorFilterBlendModeName(
|
||||||
|
colorFilterBlendMode,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
onSelected: (value) {
|
||||||
|
ref
|
||||||
|
.read(colorFilterBlendModeStateProvider.notifier)
|
||||||
|
.set(value);
|
||||||
|
},
|
||||||
|
value: colorFilterBlendMode,
|
||||||
|
list: ColorFilterBlendMode.values,
|
||||||
|
itemText: (mode) => getColorFilterBlendModeName(mode, context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pubspec.lock
52
pubspec.lock
|
|
@ -189,10 +189,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.12.0"
|
version: "8.12.1"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -285,10 +285,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: cross_file
|
name: cross_file
|
||||||
sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239"
|
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5"
|
version: "0.3.5+1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -374,10 +374,10 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
|
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.2.0"
|
version: "12.3.0"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -495,18 +495,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flex_color_scheme
|
name: flex_color_scheme
|
||||||
sha256: "6e713c27a2ebe63393a44d4bf9cdd2ac81e112724a4c69905fc41cbf231af11d"
|
sha256: ab854146f201d2d62cc251fd525ef023b84182c4a0bfe4ae4c18ffc505b412d3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.1"
|
version: "8.4.0"
|
||||||
flex_seed_scheme:
|
flex_seed_scheme:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flex_seed_scheme
|
name: flex_seed_scheme
|
||||||
sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d"
|
sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.6.1"
|
version: "4.0.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -618,10 +618,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687"
|
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.32"
|
version: "2.0.33"
|
||||||
flutter_qjs:
|
flutter_qjs:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1166,18 +1166,18 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.20"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.3"
|
version: "2.5.1"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1770,34 +1770,34 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.24"
|
version: "6.3.28"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.5"
|
version: "6.3.6"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_linux
|
name: url_launcher_linux
|
||||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.2"
|
||||||
url_launcher_macos:
|
url_launcher_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.4"
|
version: "3.2.5"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -1818,10 +1818,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_windows
|
name: url_launcher_windows
|
||||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.4"
|
version: "3.1.5"
|
||||||
uuid:
|
uuid:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue