mirror of
https://github.com/gabehf/Fladder.git
synced 2026-03-09 07:28:14 -07:00
Init repo
This commit is contained in:
commit
764b6034e3
566 changed files with 212335 additions and 0 deletions
118
lib/screens/book_viewer/book_viewer_chapters.dart
Normal file
118
lib/screens/book_viewer/book_viewer_chapters.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/fladder_image.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showBookViewerChapters(
|
||||
BuildContext context, AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider,
|
||||
{Function(BookModel book)? onPressed}) async {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return showModalSideSheet(context,
|
||||
content: BookViewerChapters(
|
||||
provider: provider,
|
||||
onPressed: onPressed,
|
||||
));
|
||||
} else {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => BookViewerChapters(
|
||||
provider: provider,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerChapters extends ConsumerWidget {
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
final Function(BookModel book)? onPressed;
|
||||
const BookViewerChapters({required this.provider, this.onPressed, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentBook = ref.watch(bookViewerProvider.select((value) => value.book));
|
||||
final chapters = ref.watch(provider.select((value) => value.chapters));
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Chapters",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
...chapters.map(
|
||||
(book) {
|
||||
final bool current = currentBook == book;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Card(
|
||||
elevation: current ? 10 : 3,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 80),
|
||||
alignment: Alignment.center,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
leading: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Card(
|
||||
child: FladderImage(
|
||||
image: book.getPosters?.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(book.name),
|
||||
trailing: current
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Icon(
|
||||
Icons.visibility_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: () => onPressed?.call(book),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.read_more_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/screens/book_viewer/book_viewer_controls.dart
Normal file
398
lib/screens/book_viewer/book_viewer_controls.dart
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:ficonsax/ficonsax.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_chapters.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_settings.dart';
|
||||
import 'package:fladder/screens/shared/default_titlebar.dart';
|
||||
import 'package:fladder/screens/shared/fladder_snackbar.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_brightness/screen_brightness.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class BookViewController {
|
||||
bool controlsVisible = true;
|
||||
|
||||
late ValueNotifier<bool> visibilityChanged = ValueNotifier(controlsVisible);
|
||||
|
||||
void toggleControls({bool? value}) {
|
||||
controlsVisible = value ?? !controlsVisible;
|
||||
visibilityChanged.value = controlsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerControls extends ConsumerStatefulWidget {
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
final BookViewController viewController;
|
||||
|
||||
final ExtendedPageController controller;
|
||||
const BookViewerControls({
|
||||
required this.provider,
|
||||
required this.controller,
|
||||
required this.viewController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _BookViewerControlsState();
|
||||
}
|
||||
|
||||
class _BookViewerControlsState extends ConsumerState<BookViewerControls> {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
final Duration pageAnimDuration = const Duration(milliseconds: 125);
|
||||
final Curve pageAnimCurve = Curves.easeInCubic;
|
||||
late final BookViewController viewController = widget.viewController;
|
||||
|
||||
late final double topPadding = MediaQuery.of(context).viewPadding.top;
|
||||
late final double bottomPadding = MediaQuery.of(context).viewPadding.bottom;
|
||||
|
||||
bool showControls = true;
|
||||
void toggleControls({bool? value}) {
|
||||
setState(() {
|
||||
showControls = value ?? !showControls;
|
||||
});
|
||||
SystemChrome.setEnabledSystemUIMode(!showControls ? SystemUiMode.leanBack : SystemUiMode.edgeToEdge, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WakelockPlus.enable();
|
||||
viewController.visibilityChanged.addListener(() {
|
||||
toggleControls(value: viewController.controlsVisible);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
WakelockPlus.disable();
|
||||
ScreenBrightness().resetScreenBrightness();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: []);
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final details = ref.watch(widget.provider);
|
||||
final bookViewerSettings = ref.watch(bookViewerSettingsProvider);
|
||||
final chapters = details.chapters;
|
||||
final bookViewerDetails = ref.watch(bookViewerProvider);
|
||||
final currentPage = bookViewerDetails.currentPage;
|
||||
const overlayColor = Colors.black;
|
||||
final previousChapter = details.previousChapter(bookViewerDetails.book);
|
||||
final nextChapter = details.nextChapter(bookViewerDetails.book);
|
||||
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
FocusScope.of(context).requestFocus(focusNode);
|
||||
}
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
child: KeyboardListener(
|
||||
focusNode: focusNode,
|
||||
autofocus: AdaptiveLayout.of(context).isDesktop,
|
||||
onKeyEvent: (value) {
|
||||
if (value is KeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowLeft || value.logicalKey == LogicalKeyboardKey.keyA) {
|
||||
bookViewerSettings.readDirection == ReadDirection.leftToRight ? previousPage() : nextPage();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowRight || value.logicalKey == LogicalKeyboardKey.keyD) {
|
||||
bookViewerSettings.readDirection == ReadDirection.leftToRight ? nextPage() : previousPage();
|
||||
}
|
||||
if (value.logicalKey == LogicalKeyboardKey.space) {
|
||||
toggleControls();
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: showControls ? 1 : 0,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(1),
|
||||
overlayColor.withOpacity(0.65),
|
||||
overlayColor.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: topPadding).copyWith(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (AdaptiveLayout.of(context).isDesktop)
|
||||
const Flexible(
|
||||
child: DefaultTitleBar(
|
||||
height: 50,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const BackButton(),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
bookViewerDetails.book?.name ?? "None",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!bookViewerDetails.loading) ...{
|
||||
if (bookViewerDetails.book != null && bookViewerDetails.pages.isNotEmpty) ...{
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withOpacity(0),
|
||||
overlayColor.withOpacity(0.65),
|
||||
overlayColor.withOpacity(1),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: bottomPadding).copyWith(top: 16, bottom: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? previousChapter?.name != null
|
||||
? "Load ${previousChapter?.name}"
|
||||
: ""
|
||||
: nextChapter?.name != null
|
||||
? "Load ${nextChapter?.name}"
|
||||
: "",
|
||||
child: IconButton.filled(
|
||||
onPressed: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? previousChapter != null
|
||||
? () async => await loadNextBook(previousChapter)
|
||||
: null
|
||||
: nextChapter != null
|
||||
? () async => await loadNextBook(nextChapter)
|
||||
: null,
|
||||
icon: const Icon(IconsaxOutline.backward),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
if (bookViewerSettings.readDirection == ReadDirection.leftToRight)
|
||||
...controls(currentPage, bookViewerSettings, bookViewerDetails)
|
||||
else
|
||||
...controls(currentPage, bookViewerSettings, bookViewerDetails)
|
||||
.reversed,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? nextChapter?.name != null
|
||||
? "Load ${nextChapter?.name}"
|
||||
: ""
|
||||
: previousChapter?.name != null
|
||||
? "Load ${previousChapter?.name}"
|
||||
: "",
|
||||
child: IconButton.filled(
|
||||
onPressed: bookViewerSettings.readDirection == ReadDirection.leftToRight
|
||||
? nextChapter != null
|
||||
? () async => await loadNextBook(nextChapter)
|
||||
: null
|
||||
: previousChapter != null
|
||||
? () async => await loadNextBook(previousChapter)
|
||||
: null,
|
||||
icon: const Icon(IconsaxOutline.forward),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Transform.flip(
|
||||
flipX: bookViewerSettings.readDirection == ReadDirection.rightToLeft,
|
||||
child: IconButton(
|
||||
onPressed: () => widget.controller
|
||||
.animateToPage(1, duration: pageAnimDuration, curve: pageAnimCurve),
|
||||
icon: const Icon(IconsaxOutline.backward)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
showBookViewerSettings(context);
|
||||
},
|
||||
icon: const Icon(IconsaxOutline.setting_2),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: chapters.length > 1
|
||||
? () {
|
||||
showBookViewerChapters(
|
||||
context,
|
||||
widget.provider,
|
||||
onPressed: (book) async {
|
||||
Navigator.of(context).pop();
|
||||
loadNextBook(book);
|
||||
},
|
||||
);
|
||||
}
|
||||
: () => fladderSnackbar(context, title: "No other chapters"),
|
||||
icon: const Icon(IconsaxOutline.bookmark_2),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
} else
|
||||
const Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.menu_book_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text("Unable to load book"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bookViewerDetails.loading)
|
||||
Center(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (bookViewerDetails.book != null) ...{
|
||||
Flexible(
|
||||
child: Text("Loading ${bookViewerDetails.book?.name}",
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
},
|
||||
const CircularProgressIndicator.adaptive(strokeCap: StrokeCap.round),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> controls(int currentPage, BookViewerSettingsModel bookViewerSettings, BookViewerModel details) {
|
||||
final clampedCurrentPage = currentPage.clamp(1, details.pages.length);
|
||||
return [
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
(currentPage.clamp(1, details.pages.length)).toInt().toString().padLeft(1).padRight(1),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Transform.flip(
|
||||
flipX: bookViewerSettings.readDirection == ReadDirection.rightToLeft,
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: FladderSlider(
|
||||
value: clampedCurrentPage.toDouble(),
|
||||
divisions: details.pages.length - 1,
|
||||
min: 1,
|
||||
max: details.pages.length.toDouble(),
|
||||
onChangeEnd: (value) => widget.controller.jumpToPage(value.toInt()),
|
||||
onChanged: (value) => ref.read(bookViewerProvider.notifier).setPage(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
details.pages.length.toString().padLeft(1).padRight(1),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> loadNextBook(BookModel? book) async {
|
||||
await ref.read(bookViewerProvider.notifier).fetchBook(book);
|
||||
widget.controller.jumpToPage(0);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> nextPage() async =>
|
||||
throttler.run(() async => await widget.controller.nextPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> previousPage() async =>
|
||||
throttler.run(() async => await widget.controller.previousPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
}
|
||||
119
lib/screens/book_viewer/book_viewer_reader.dart
Normal file
119
lib/screens/book_viewer/book_viewer_reader.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BookViewerReader extends ConsumerWidget {
|
||||
final int index;
|
||||
final List<String> pages;
|
||||
final BookViewerSettingsModel bookViewSettings;
|
||||
final Function() previousPage;
|
||||
final Function() nextPage;
|
||||
final BookViewController viewController;
|
||||
final double lastScale;
|
||||
final Function(double value) newScale;
|
||||
const BookViewerReader({
|
||||
required this.index,
|
||||
required this.pages,
|
||||
required this.bookViewSettings,
|
||||
required this.previousPage,
|
||||
required this.nextPage,
|
||||
required this.viewController,
|
||||
required this.lastScale,
|
||||
required this.newScale,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double? initScale({
|
||||
required Size imageSize,
|
||||
required Size size,
|
||||
double? initialScale,
|
||||
}) {
|
||||
final double n1 = imageSize.height / imageSize.width;
|
||||
final double n2 = size.height / size.width;
|
||||
if (n1 > n2) {
|
||||
final FittedSizes fittedSizes = applyBoxFit(BoxFit.cover, imageSize, size);
|
||||
//final Size sourceSize = fittedSizes.source;
|
||||
final Size destinationSize = fittedSizes.destination;
|
||||
return size.width / destinationSize.width;
|
||||
} else if (n1 / n2 < 1 / 4) {
|
||||
final FittedSizes fittedSizes = applyBoxFit(BoxFit.cover, imageSize, size);
|
||||
//final Size sourceSize = fittedSizes.source;
|
||||
final Size destinationSize = fittedSizes.destination;
|
||||
return size.height / destinationSize.height;
|
||||
}
|
||||
|
||||
return initialScale;
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTapUp: (tapDetails) {
|
||||
double screenWidth = MediaQuery.of(context).size.width;
|
||||
double tapPosition = tapDetails.globalPosition.dx;
|
||||
double tapPercentage = tapPosition / screenWidth;
|
||||
if (tapPercentage < 0.22) {
|
||||
bookViewSettings.readDirection == ReadDirection.leftToRight ? previousPage() : nextPage();
|
||||
} else if (tapPercentage < 0.88) {
|
||||
viewController.toggleControls();
|
||||
} else {
|
||||
bookViewSettings.readDirection == ReadDirection.leftToRight ? nextPage() : previousPage();
|
||||
}
|
||||
},
|
||||
child: ExtendedImage.file(
|
||||
fit: BoxFit.contain,
|
||||
imageCacheName: pages[index - 1],
|
||||
mode: ExtendedImageMode.gesture,
|
||||
initGestureConfigHandler: (state) {
|
||||
double? initialScale = !bookViewSettings.keepPageZoom
|
||||
? switch (bookViewSettings.initZoomState) {
|
||||
InitZoomState.contained => 1.0,
|
||||
InitZoomState.covered => 1.75,
|
||||
}
|
||||
: lastScale;
|
||||
|
||||
if (state.extendedImageInfo != null) {
|
||||
initialScale = initScale(
|
||||
size: MediaQuery.sizeOf(context),
|
||||
initialScale: initialScale,
|
||||
imageSize: Size(
|
||||
state.extendedImageInfo!.image.width.toDouble(), state.extendedImageInfo!.image.height.toDouble()));
|
||||
}
|
||||
|
||||
return GestureConfig(
|
||||
inertialSpeed: 300,
|
||||
inPageView: true,
|
||||
initialScale: initialScale!,
|
||||
initialAlignment: bookViewSettings.initZoomState == InitZoomState.contained && initialScale == 1.0
|
||||
? InitialAlignment.center
|
||||
: switch (bookViewSettings.readDirection) {
|
||||
ReadDirection.rightToLeft => InitialAlignment.topRight,
|
||||
ReadDirection.leftToRight => InitialAlignment.topLeft,
|
||||
},
|
||||
reverseMousePointerScrollDirection: true,
|
||||
maxScale: math.max(initialScale, 5.0),
|
||||
minScale: math.min(initialScale, 1),
|
||||
animationMaxScale: math.max(initialScale, 5.0),
|
||||
gestureDetailsIsChanged: (details) {
|
||||
if (bookViewSettings.keepPageZoom) {
|
||||
if (lastScale != (details?.totalScale ?? initialScale)) {
|
||||
newScale(details?.totalScale ?? 1.0);
|
||||
}
|
||||
}
|
||||
},
|
||||
cacheGesture: bookViewSettings.cachePageZoom,
|
||||
hitTestBehavior: HitTestBehavior.translucent,
|
||||
);
|
||||
},
|
||||
File(pages[index - 1]),
|
||||
enableMemoryCache: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
33
lib/screens/book_viewer/book_viewer_reader_web.dart
Normal file
33
lib/screens/book_viewer/book_viewer_reader_web.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BookViewerReader extends ConsumerWidget {
|
||||
final int index;
|
||||
final List<String> pages;
|
||||
final BookViewerSettingsModel bookViewSettings;
|
||||
final Function() previousPage;
|
||||
final Function() nextPage;
|
||||
final BookViewController viewController;
|
||||
final double lastScale;
|
||||
final Function(double value) newScale;
|
||||
const BookViewerReader({
|
||||
required this.index,
|
||||
required this.pages,
|
||||
required this.bookViewSettings,
|
||||
required this.previousPage,
|
||||
required this.nextPage,
|
||||
required this.viewController,
|
||||
required this.lastScale,
|
||||
required this.newScale,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
child: Text("Web not supported."),
|
||||
);
|
||||
}
|
||||
}
|
||||
270
lib/screens/book_viewer/book_viewer_screen.dart
Normal file
270
lib/screens/book_viewer/book_viewer_screen.dart
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:fladder/models/book_model.dart';
|
||||
import 'package:fladder/providers/book_viewer_provider.dart';
|
||||
import 'package:fladder/providers/items/book_details_provider.dart';
|
||||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_controls.dart';
|
||||
import 'package:fladder/screens/book_viewer/book_viewer_reader.dart'
|
||||
if (dart.library.html) 'package:fladder/screens/book_viewer/book_viewer_reader_web.dart';
|
||||
import 'package:fladder/util/themes_data.dart';
|
||||
import 'package:fladder/util/throttler.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> openBookViewer(
|
||||
BuildContext context,
|
||||
AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider, {
|
||||
int? initialPage,
|
||||
}) async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
useSafeArea: false,
|
||||
builder: (context) => Dialog.fullscreen(
|
||||
child: BookViewerScreen(
|
||||
initialPage: initialPage ?? 0,
|
||||
provider: provider,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class BookViewerScreen extends ConsumerStatefulWidget {
|
||||
final int initialPage;
|
||||
final AutoDisposeStateNotifierProvider<BookDetailsProviderNotifier, BookProviderModel> provider;
|
||||
const BookViewerScreen({required this.provider, this.initialPage = 0, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _BookViewerScreenState();
|
||||
}
|
||||
|
||||
class _BookViewerScreenState extends ConsumerState<BookViewerScreen> {
|
||||
final Throttler throttler = Throttler(duration: const Duration(milliseconds: 130));
|
||||
final Duration pageAnimDuration = const Duration(milliseconds: 125);
|
||||
final Curve pageAnimCurve = Curves.easeInCubic;
|
||||
late final ExtendedPageController extendedController = ExtendedPageController(initialPage: widget.initialPage);
|
||||
late final BookViewController viewController = BookViewController();
|
||||
bool outOfRange = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(bookViewerSettingsProvider.notifier).setSavedBrightness());
|
||||
}
|
||||
|
||||
late double lastScale = switch (ref.read(bookViewerSettingsProvider).initZoomState) {
|
||||
InitZoomState.contained => 1.0,
|
||||
InitZoomState.covered => 1.75,
|
||||
};
|
||||
|
||||
late double lastPosition = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bookViewerDetails = ref.watch(bookViewerProvider);
|
||||
final loading = bookViewerDetails.loading;
|
||||
final pages = bookViewerDetails.pages;
|
||||
final book = bookViewerDetails.book;
|
||||
final bookViewSettings = ref.watch(bookViewerSettingsProvider);
|
||||
ref.listen(
|
||||
bookViewerProvider.select((value) => value.loading),
|
||||
(previous, next) {
|
||||
if (previous == true && next == false) {
|
||||
ref.read(bookViewerProvider.notifier).updatePlayback((widget.initialPage.toDouble()).toInt());
|
||||
}
|
||||
},
|
||||
);
|
||||
return Theme(
|
||||
data: ThemesData.of(context).dark,
|
||||
child: PopScope(
|
||||
canPop: true,
|
||||
onPopInvoked: (didPop) async {
|
||||
await ref.read(bookViewerProvider.notifier).stopPlayback();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!loading)
|
||||
ExtendedImageGesturePageView.builder(
|
||||
itemCount: pages.length + 2,
|
||||
controller: extendedController,
|
||||
canScrollPage: (gestureDetails) {
|
||||
return bookViewSettings.disableScrollOnZoom
|
||||
? gestureDetails != null
|
||||
? !(gestureDetails.totalScale! > 1.0)
|
||||
: true
|
||||
: true;
|
||||
},
|
||||
onPageChanged: (value) {
|
||||
final newRange = pages.length + 1 == value || value == 0;
|
||||
if (outOfRange != newRange) {
|
||||
viewController.toggleControls(value: newRange);
|
||||
outOfRange = newRange;
|
||||
}
|
||||
ref.read(bookViewerProvider.notifier).updatePlayback(value);
|
||||
},
|
||||
reverse: bookViewSettings.readDirection == ReadDirection.rightToLeft,
|
||||
itemBuilder: (context, index) {
|
||||
if (pages.length + 1 == index || index == 0) {
|
||||
final atEnd = index >= pages.length;
|
||||
final details = ref.read(widget.provider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: bookViewSettings.readDirection != ReadDirection.leftToRight
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (atEnd) ...{
|
||||
Flexible(
|
||||
child: Text(
|
||||
"End: \n${book?.name}",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (details.nextChapter(bookViewerDetails.book) != null) ...{
|
||||
const SizedBox(height: 32),
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Next: ",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
onPressed: () async =>
|
||||
await loadNextBook(details.nextChapter(bookViewerDetails.book)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.keyboard_arrow_left_rounded),
|
||||
Text(
|
||||
details.nextChapter(bookViewerDetails.book)!.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
} else ...{
|
||||
const SizedBox(height: 32),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.info_rounded),
|
||||
const SizedBox(width: 16),
|
||||
Text("No next chapter"),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else ...{
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Start: \n${book?.name}",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
if (details.previousChapter(bookViewerDetails.book) != null) ...{
|
||||
const SizedBox(height: 32),
|
||||
Flexible(
|
||||
child: Text(
|
||||
"Previous:",
|
||||
textAlign: TextAlign.start,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
onPressed: () async =>
|
||||
await loadNextBook(details.previousChapter(bookViewerDetails.book)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
details.previousChapter(bookViewerDetails.book)!.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onPrimary),
|
||||
),
|
||||
const Icon(Icons.keyboard_arrow_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
} else ...{
|
||||
const SizedBox(height: 32),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.info_rounded),
|
||||
const SizedBox(width: 16),
|
||||
Text("First chapter"),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return BookViewerReader(
|
||||
index: index,
|
||||
pages: pages,
|
||||
bookViewSettings: bookViewSettings,
|
||||
previousPage: previousPage,
|
||||
nextPage: nextPage,
|
||||
viewController: viewController,
|
||||
lastScale: lastScale,
|
||||
newScale: (value) => lastScale = value,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
BookViewerControls(
|
||||
provider: widget.provider,
|
||||
viewController: viewController,
|
||||
controller: extendedController,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> nextPage() async =>
|
||||
throttler.run(() async => await extendedController.nextPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> previousPage() async => throttler
|
||||
.run(() async => await extendedController.previousPage(duration: pageAnimDuration, curve: pageAnimCurve));
|
||||
|
||||
Future<void> loadNextBook(BookModel? book) async {
|
||||
await ref.read(bookViewerProvider.notifier).fetchBook(book);
|
||||
extendedController.jumpToPage(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
187
lib/screens/book_viewer/book_viewer_settings.dart
Normal file
187
lib/screens/book_viewer/book_viewer_settings.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'package:fladder/providers/settings/book_viewer_settings_provider.dart';
|
||||
import 'package:fladder/util/adaptive_layout.dart';
|
||||
import 'package:fladder/util/string_extensions.dart';
|
||||
import 'package:fladder/widgets/shared/enum_selection.dart';
|
||||
import 'package:fladder/widgets/shared/fladder_slider.dart';
|
||||
import 'package:fladder/widgets/shared/modal_side_sheet.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
Future<void> showBookViewerSettings(
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (AdaptiveLayout.of(context).isDesktop) {
|
||||
return showModalSideSheet(context, content: const BookViewerSettingsScreen());
|
||||
} else {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => const BookViewerSettingsScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BookViewerSettingsScreen extends ConsumerWidget {
|
||||
const BookViewerSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(bookViewerSettingsProvider);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Reader settings",
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (!AdaptiveLayout.of(context).isDesktop) ...{
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
const Text("Screen Brightness"),
|
||||
Flexible(
|
||||
child: Opacity(
|
||||
opacity: settings.screenBrightness == null ? 0.5 : 1,
|
||||
child: FladderSlider(
|
||||
value: settings.screenBrightness ?? 1.0,
|
||||
min: 0,
|
||||
max: 1,
|
||||
onChanged: (value) => ref.read(bookViewerSettingsProvider.notifier).setScreenBrightness(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => ref.read(bookViewerSettingsProvider.notifier).setScreenBrightness(null),
|
||||
icon: Opacity(
|
||||
opacity: settings.screenBrightness != null ? 0.5 : 1,
|
||||
child: Icon(
|
||||
Icons.brightness_auto_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: EnumSelection(
|
||||
label: const Text("Read direction"),
|
||||
current: settings.readDirection.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => ReadDirection.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.name.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(readDirection: value)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: EnumSelection(
|
||||
label: const Text("Init zoom"),
|
||||
current: settings.initZoomState.name.toUpperCaseSplit(),
|
||||
itemBuilder: (context) => InitZoomState.values
|
||||
.map((value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.name.toUpperCaseSplit()),
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(initZoomState: value)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(disableScrollOnZoom: !settings.disableScrollOnZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Disable slide page gestures when zoomed"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.disableScrollOnZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(disableScrollOnZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(cachePageZoom: !settings.cachePageZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Cache page zoom state"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.cachePageZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((incoming) => incoming.copyWith(cachePageZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((state) => state.copyWith(keepPageZoom: !settings.keepPageZoom)),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Expanded(
|
||||
flex: 3,
|
||||
child: Text("Keep page zoom"),
|
||||
),
|
||||
const Spacer(),
|
||||
Switch.adaptive(
|
||||
value: settings.keepPageZoom,
|
||||
onChanged: (value) => ref
|
||||
.read(bookViewerSettingsProvider.notifier)
|
||||
.update((incoming) => incoming.copyWith(keepPageZoom: value)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.bottom,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue