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:
Moustapha Kodjo Amadou 2025-12-05 16:54:10 +01:00
parent 0789f4c85a
commit 4e9af30e8e
17 changed files with 3122 additions and 2082 deletions

View file

@ -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),
),
),
],
),
),
);
},
);
}
}

View file

@ -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),
),
),
],
),
],
);
}
}

View file

@ -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,
),
);

View 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');
}
}
}

View 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,
);
}
}

View 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

View 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!;
}
}

View 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(),
),
);
}
}

View 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),
),
);
}
}

View 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),
],
),
),
),
);
}
}

View 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,
),
);
}
}

View 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!};
}

View 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';
}
}

View 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,
);
}
}

View 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),
),
],
],
),
),
);
}
}

View file

@ -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: