mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-03-11 17:25:32 +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: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/u_chap_data_preload.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||
|
|
@ -134,10 +134,10 @@ class ImageViewWebtoon extends StatelessWidget {
|
|||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTapDown: (details) => onDoubleTapDown(details.globalPosition),
|
||||
onDoubleTap: onDoubleTap,
|
||||
child: DoubleColummVerticalView(
|
||||
datas: datas,
|
||||
child: DoublePageView.vertical(
|
||||
pages: datas,
|
||||
backgroundColor: backgroundColor,
|
||||
isFailedToLoadImage: onFailedToLoadImage,
|
||||
onFailedToLoadImage: onFailedToLoadImage,
|
||||
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
|
||||
description:
|
||||
name: built_value
|
||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.0"
|
||||
version: "8.12.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -285,10 +285,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239"
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5"
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -374,10 +374,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33
|
||||
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.2.0"
|
||||
version: "12.3.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -495,18 +495,18 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: flex_color_scheme
|
||||
sha256: "6e713c27a2ebe63393a44d4bf9cdd2ac81e112724a4c69905fc41cbf231af11d"
|
||||
sha256: ab854146f201d2d62cc251fd525ef023b84182c4a0bfe4ae4c18ffc505b412d3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "8.4.0"
|
||||
flex_seed_scheme:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flex_seed_scheme
|
||||
sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d"
|
||||
sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
version: "4.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -618,10 +618,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687"
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.32"
|
||||
version: "2.0.33"
|
||||
flutter_qjs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1166,18 +1166,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.20"
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
version: "2.5.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1770,34 +1770,34 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.24"
|
||||
version: "6.3.28"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.5"
|
||||
version: "6.3.6"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.4"
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1818,10 +1818,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
Loading…
Reference in a new issue