mangayomi-mirror/lib/modules/manga/reader/widgets/reader_bottom_bar.dart
Moustapha Kodjo Amadou 4e9af30e8e 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.
2025-12-05 16:54:10 +01:00

479 lines
15 KiB
Dart

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