mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
comic reading
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/pages/favorites/favorite_actions.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@@ -458,9 +459,25 @@ abstract mixin class _ComicPageActions {
|
||||
/// [ep] the episode number, start from 1
|
||||
///
|
||||
/// [page] the page number, start from 1
|
||||
void read([int? ep, int? page]) {}
|
||||
void read([int? ep, int? page]) {
|
||||
App.rootContext.to(
|
||||
() => Reader(
|
||||
source: comicSource,
|
||||
cid: comic.id,
|
||||
name: comic.title,
|
||||
chapters: comic.chapters,
|
||||
initialChapter: ep,
|
||||
initialPage: page,
|
||||
history: History.fromModel(model: comic, ep: 0, page: 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void continueRead() {}
|
||||
void continueRead() {
|
||||
var ep = history?.ep ?? 1;
|
||||
var page = history?.page ?? 1;
|
||||
read(ep, page);
|
||||
}
|
||||
|
||||
void download() {}
|
||||
|
||||
@@ -1117,4 +1134,3 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
@@ -11,6 +9,7 @@ import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/search_page.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
@@ -149,7 +148,12 @@ class _HistoryState extends State<_History> {
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// TODO: toComicPageWithHistory(context, history[index]);
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
id: history[index].id,
|
||||
sourceKey: history[index].type.comicSource!.key,
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
@@ -177,7 +181,7 @@ class _HistoryState extends State<_History> {
|
||||
);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
).paddingHorizontal(8).paddingBottom(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
165
lib/pages/reader/gesture.dart
Normal file
165
lib/pages/reader/gesture.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class _ReaderGestureDetector extends StatefulWidget {
|
||||
const _ReaderGestureDetector({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
|
||||
}
|
||||
|
||||
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
late TapGestureRecognizer _tapGestureRecognizer;
|
||||
|
||||
static const _kDoubleTapMinTime = Duration(milliseconds: 200);
|
||||
|
||||
static const _kDoubleTapMaxDistanceSquared = 20.0 * 20.0;
|
||||
|
||||
static const _kTapToTurnPagePercent = 0.3;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_tapGestureRecognizer = TapGestureRecognizer()
|
||||
..onTapUp = onTapUp
|
||||
..onSecondaryTapUp = (details) {
|
||||
onSecondaryTapUp(details.globalPosition);
|
||||
};
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_tapGestureRecognizer.addPointer(event);
|
||||
},
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
onMouseWheel(event.scrollDelta.dy > 0);
|
||||
}
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void onMouseWheel(bool forward) {
|
||||
if (forward) {
|
||||
if (!context.reader.toNextPage()) {
|
||||
context.reader.toNextChapter();
|
||||
}
|
||||
} else {
|
||||
if (!context.reader.toPrevPage()) {
|
||||
context.reader.toPrevChapter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TapUpDetails? _previousEvent;
|
||||
|
||||
void onTapUp(TapUpDetails event) {
|
||||
final location = event.globalPosition;
|
||||
final previousLocation = _previousEvent?.globalPosition;
|
||||
if (previousLocation != null) {
|
||||
if ((location - previousLocation).distanceSquared <
|
||||
_kDoubleTapMaxDistanceSquared) {
|
||||
onDoubleTap(location);
|
||||
_previousEvent = null;
|
||||
return;
|
||||
} else {
|
||||
onTap(previousLocation);
|
||||
}
|
||||
}
|
||||
_previousEvent = event;
|
||||
Future.delayed(_kDoubleTapMinTime, () {
|
||||
if (_previousEvent == event) {
|
||||
onTap(location);
|
||||
_previousEvent = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onTap(Offset location) {
|
||||
if (context.readerScaffold.isOpen) {
|
||||
context.readerScaffold.openOrClose();
|
||||
} else {
|
||||
if (appdata.settings['enableTapToTurnPages']) {
|
||||
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
|
||||
final width = context.width;
|
||||
final height = context.height;
|
||||
final x = location.dx;
|
||||
final y = location.dy;
|
||||
if (x < width * _kTapToTurnPagePercent) {
|
||||
isLeft = true;
|
||||
} else if (x > width * (1 - _kTapToTurnPagePercent)) {
|
||||
isRight = true;
|
||||
}
|
||||
if (y < height * _kTapToTurnPagePercent) {
|
||||
isTop = true;
|
||||
} else if (y > height * (1 - _kTapToTurnPagePercent)) {
|
||||
isBottom = true;
|
||||
}
|
||||
bool isCenter = false;
|
||||
switch (context.reader.mode) {
|
||||
case ReaderMode.galleryLeftToRight:
|
||||
case ReaderMode.continuousLeftToRight:
|
||||
if (isLeft) {
|
||||
context.reader.toPrevPage();
|
||||
} else if (isRight) {
|
||||
context.reader.toNextPage();
|
||||
} else {
|
||||
isCenter = true;
|
||||
}
|
||||
case ReaderMode.galleryRightToLeft:
|
||||
case ReaderMode.continuousRightToLeft:
|
||||
if (isLeft) {
|
||||
context.reader.toNextPage();
|
||||
} else if (isRight) {
|
||||
context.reader.toPrevPage();
|
||||
} else {
|
||||
isCenter = true;
|
||||
}
|
||||
case ReaderMode.galleryTopToBottom:
|
||||
case ReaderMode.continuousTopToBottom:
|
||||
if (isTop) {
|
||||
context.reader.toPrevPage();
|
||||
} else if (isBottom) {
|
||||
context.reader.toNextPage();
|
||||
} else {
|
||||
isCenter = true;
|
||||
}
|
||||
}
|
||||
if (!isCenter) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
context.readerScaffold.openOrClose();
|
||||
}
|
||||
}
|
||||
|
||||
void onDoubleTap(Offset location) {
|
||||
context.reader._imageViewController?.handleDoubleTap(location);
|
||||
}
|
||||
|
||||
void onSecondaryTapUp(Offset location) {
|
||||
showDesktopMenu(
|
||||
context,
|
||||
location,
|
||||
[
|
||||
DesktopMenuEntry(text: "Settings".tl, onClick: () {
|
||||
context.readerScaffold.openSetting();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Chapters".tl, onClick: () {
|
||||
context.readerScaffold.openChapterDrawer();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Fullscreen".tl, onClick: () {
|
||||
context.reader.fullscreen();
|
||||
}),
|
||||
DesktopMenuEntry(text: "Exit".tl, onClick: () {
|
||||
context.pop();
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
212
lib/pages/reader/images.dart
Normal file
212
lib/pages/reader/images.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class _ReaderImages extends StatefulWidget {
|
||||
const _ReaderImages({super.key});
|
||||
|
||||
@override
|
||||
State<_ReaderImages> createState() => _ReaderImagesState();
|
||||
}
|
||||
|
||||
class _ReaderImagesState extends State<_ReaderImages> {
|
||||
String? error;
|
||||
|
||||
bool inProgress = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
context.reader.isLoading = true;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void load() async {
|
||||
if (inProgress) return;
|
||||
inProgress = true;
|
||||
var res = await context.reader.widget.source.loadComicPages!(
|
||||
context.reader.widget.cid,
|
||||
context.reader.widget.chapters?.keys
|
||||
.elementAt(context.reader.chapter - 1),
|
||||
);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
error = res.errorMessage;
|
||||
context.reader.isLoading = false;
|
||||
inProgress = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
context.reader.images = res.data;
|
||||
context.reader.isLoading = false;
|
||||
inProgress = false;
|
||||
});
|
||||
}
|
||||
context.readerScaffold.update();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.reader.isLoading) {
|
||||
load();
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
} else if (error != null) {
|
||||
return NetworkError(
|
||||
message: error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
context.reader.isLoading = true;
|
||||
error = null;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (context.reader.mode.isGallery) {
|
||||
return _GalleryMode(key: Key(context.reader.mode.key));
|
||||
} else {
|
||||
// TODO: Implement other modes
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryMode extends StatefulWidget {
|
||||
const _GalleryMode({super.key});
|
||||
|
||||
@override
|
||||
State<_GalleryMode> createState() => _GalleryModeState();
|
||||
}
|
||||
|
||||
class _GalleryModeState extends State<_GalleryMode>
|
||||
implements _ImageViewController {
|
||||
late PageController controller;
|
||||
|
||||
late List<bool> cached;
|
||||
|
||||
int get preCacheCount => 4;
|
||||
|
||||
var photoViewControllers = <int, PhotoViewController>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
controller = PageController(initialPage: context.reader.page);
|
||||
context.reader._imageViewController = this;
|
||||
cached = List.filled(context.reader.maxPage + 2, false);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void cache(int current) {
|
||||
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
||||
if (i <= context.reader.maxPage && !cached[i]) {
|
||||
_precacheImage(i, context);
|
||||
cached[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PhotoViewGallery.builder(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
reverse: context.reader.mode == ReaderMode.galleryRightToLeft,
|
||||
scrollDirection: context.reader.mode == ReaderMode.galleryTopToBottom
|
||||
? Axis.vertical
|
||||
: Axis.horizontal,
|
||||
itemCount: context.reader.images!.length + 2,
|
||||
builder: (BuildContext context, int index) {
|
||||
ImageProvider? imageProvider;
|
||||
if (index != 0 && index != context.reader.images!.length + 1) {
|
||||
imageProvider = _createImageProvider(index, context);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
scaleStateController: PhotoViewScaleStateController(),
|
||||
child: const SizedBox(),
|
||||
);
|
||||
}
|
||||
|
||||
cached[index] = true;
|
||||
cache(index);
|
||||
|
||||
photoViewControllers[index] ??= PhotoViewController();
|
||||
|
||||
return PhotoViewGalleryPageOptions(
|
||||
filterQuality: FilterQuality.medium,
|
||||
controller: photoViewControllers[index],
|
||||
imageProvider: imageProvider,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, error, s, retry) {
|
||||
return NetworkError(message: error.toString(), retry: retry);
|
||||
},
|
||||
);
|
||||
},
|
||||
pageController: controller,
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
backgroundColor: context.colorScheme.surfaceContainerHigh,
|
||||
value: event == null || event.expectedTotalBytes == null
|
||||
? null
|
||||
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPageChanged: (i) {
|
||||
if (i == 0) {
|
||||
if (!context.reader.toNextChapter()) {
|
||||
context.reader.toPage(1);
|
||||
}
|
||||
} else if (i == context.reader.maxPage + 1) {
|
||||
if (!context.reader.toPrevChapter()) {
|
||||
context.reader.toPage(context.reader.maxPage);
|
||||
}
|
||||
} else {
|
||||
context.reader.setPage(i);
|
||||
context.readerScaffold.update();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> animateToPage(int page) {
|
||||
if ((page - controller.page!).abs() > 1) {
|
||||
controller.jumpToPage(page > controller.page! ? page - 1 : page + 1);
|
||||
}
|
||||
return controller.animateToPage(
|
||||
page,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void toPage(int page) {
|
||||
controller.jumpToPage(page);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleDoubleTap(Offset location) {
|
||||
var controller = photoViewControllers[context.reader.page]!;
|
||||
controller.onDoubleClick?.call();
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider _createImageProvider(int page, BuildContext context) {
|
||||
return ReaderImageProvider(
|
||||
context.reader.images![page - 1],
|
||||
context.reader.widget.source.key,
|
||||
context.reader.cid,
|
||||
context.reader.eid,
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheImage(int page, BuildContext context) {
|
||||
precacheImage(
|
||||
_createImageProvider(page, context),
|
||||
context,
|
||||
);
|
||||
}
|
275
lib/pages/reader/reader.dart
Normal file
275
lib/pages/reader/reader.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
library venera_reader;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/reader_image.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
part 'scaffold.dart';
|
||||
|
||||
part 'images.dart';
|
||||
|
||||
part 'gesture.dart';
|
||||
|
||||
extension _ReaderContext on BuildContext {
|
||||
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||
|
||||
_ReaderScaffoldState get readerScaffold =>
|
||||
findAncestorStateOfType<_ReaderScaffoldState>()!;
|
||||
}
|
||||
|
||||
class Reader extends StatefulWidget {
|
||||
const Reader({
|
||||
super.key,
|
||||
required this.source,
|
||||
required this.cid,
|
||||
required this.name,
|
||||
required this.chapters,
|
||||
required this.history,
|
||||
this.initialPage,
|
||||
this.initialChapter,
|
||||
});
|
||||
|
||||
final ComicSource source;
|
||||
|
||||
final String cid;
|
||||
|
||||
final String name;
|
||||
|
||||
/// Map<Chapter ID, Chapter Name>.
|
||||
/// null if the comic is a gallery
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
/// Starts from 1, invalid values equal to 1
|
||||
final int? initialPage;
|
||||
|
||||
/// Starts from 1, invalid values equal to 1
|
||||
final int? initialChapter;
|
||||
|
||||
final History history;
|
||||
|
||||
@override
|
||||
State<Reader> createState() => _ReaderState();
|
||||
}
|
||||
|
||||
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
@override
|
||||
void update() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxPage => images?.length ?? 1;
|
||||
|
||||
String get cid => widget.cid;
|
||||
|
||||
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
|
||||
|
||||
List<String>? images;
|
||||
|
||||
late ReaderMode mode;
|
||||
|
||||
History? history;
|
||||
|
||||
@override
|
||||
bool isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
page = widget.initialPage ?? 1;
|
||||
chapter = widget.initialChapter ?? 1;
|
||||
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
history = widget.history;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxChapter => widget.chapters?.length ?? 1;
|
||||
|
||||
@override
|
||||
void onPageChanged() {
|
||||
updateHistory();
|
||||
}
|
||||
|
||||
void updateHistory() {
|
||||
if(history != null) {
|
||||
history!.page = page;
|
||||
history!.ep = chapter;
|
||||
history!.readEpisode.add(chapter);
|
||||
HistoryManager().addHistory(history!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract mixin class _ReaderLocation {
|
||||
int _page = 1;
|
||||
|
||||
int get page => _page;
|
||||
|
||||
set page(int value) {
|
||||
_page = value;
|
||||
onPageChanged();
|
||||
}
|
||||
|
||||
int chapter = 1;
|
||||
|
||||
int get maxPage;
|
||||
|
||||
int get maxChapter;
|
||||
|
||||
bool get isLoading;
|
||||
|
||||
void update();
|
||||
|
||||
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
|
||||
|
||||
_ImageViewController? _imageViewController;
|
||||
|
||||
void onPageChanged();
|
||||
|
||||
void setPage(int page) {
|
||||
// Prevent page change during animation
|
||||
if (_animationCount > 0) {
|
||||
return;
|
||||
}
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
bool _validatePage(int page) {
|
||||
return page >= 1 && page <= maxPage;
|
||||
}
|
||||
|
||||
/// Returns true if the page is changed
|
||||
bool toNextPage() {
|
||||
return toPage(page + 1);
|
||||
}
|
||||
|
||||
/// Returns true if the page is changed
|
||||
bool toPrevPage() {
|
||||
return toPage(page - 1);
|
||||
}
|
||||
|
||||
int _animationCount = 0;
|
||||
|
||||
bool toPage(int page) {
|
||||
if (_validatePage(page)) {
|
||||
if (page == this.page) {
|
||||
return false;
|
||||
}
|
||||
this.page = page;
|
||||
update();
|
||||
if (enablePageAnimation) {
|
||||
_animationCount++;
|
||||
_imageViewController!.animateToPage(page).then((_) {
|
||||
_animationCount--;
|
||||
});
|
||||
} else {
|
||||
_imageViewController!.toPage(page);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _validateChapter(int chapter) {
|
||||
return chapter >= 1 && chapter <= maxChapter;
|
||||
}
|
||||
|
||||
/// Returns true if the chapter is changed
|
||||
bool toNextChapter() {
|
||||
return toChapter(chapter + 1);
|
||||
}
|
||||
|
||||
/// Returns true if the chapter is changed
|
||||
bool toPrevChapter() {
|
||||
return toChapter(chapter - 1);
|
||||
}
|
||||
|
||||
bool toChapter(int c) {
|
||||
if (_validateChapter(c) && !isLoading) {
|
||||
chapter = c;
|
||||
page = 1;
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Timer? autoPageTurningTimer;
|
||||
|
||||
void autoPageTurning() {
|
||||
if (autoPageTurningTimer != null) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
autoPageTurningTimer = null;
|
||||
} else {
|
||||
int interval = appdata.settings['autoPageTurningInterval'];
|
||||
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||
if (page == maxPage) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
}
|
||||
toNextPage();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mixin class _ReaderWindow {
|
||||
bool isFullscreen = false;
|
||||
|
||||
void fullscreen() {
|
||||
windowManager.setFullScreen(!isFullscreen);
|
||||
isFullscreen = !isFullscreen;
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderMode {
|
||||
galleryLeftToRight('galleryLeftToRight'),
|
||||
galleryRightToLeft('galleryRightToLeft'),
|
||||
galleryTopToBottom('galleryTopToBottom'),
|
||||
continuousTopToBottom('continuousTopToBottom'),
|
||||
continuousLeftToRight('continuousLeftToRight'),
|
||||
continuousRightToLeft('continuousRightToLeft');
|
||||
|
||||
final String key;
|
||||
|
||||
bool get isGallery => key.startsWith('gallery');
|
||||
|
||||
const ReaderMode(this.key);
|
||||
|
||||
static ReaderMode fromKey(String key) {
|
||||
for (var mode in values) {
|
||||
if (mode.key == key) {
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
return galleryLeftToRight;
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class _ImageViewController {
|
||||
void toPage(int page);
|
||||
|
||||
Future<void> animateToPage(int page);
|
||||
|
||||
void handleDoubleTap(Offset location);
|
||||
}
|
302
lib/pages/reader/scaffold.dart
Normal file
302
lib/pages/reader/scaffold.dart
Normal file
@@ -0,0 +1,302 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class _ReaderScaffold extends StatefulWidget {
|
||||
const _ReaderScaffold({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_ReaderScaffold> createState() => _ReaderScaffoldState();
|
||||
}
|
||||
|
||||
class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
bool _isOpen = false;
|
||||
|
||||
static const kTopBarHeight = 56.0;
|
||||
|
||||
static const kBottomBarHeight = 105.0;
|
||||
|
||||
bool get isOpen => _isOpen;
|
||||
|
||||
void openOrClose() {
|
||||
setState(() {
|
||||
_isOpen = !_isOpen;
|
||||
});
|
||||
}
|
||||
|
||||
bool? rotation;
|
||||
|
||||
void update() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
buildPageInfoText(),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
top: _isOpen ? 0 : -(kTopBarHeight + context.padding.top),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: kTopBarHeight + context.padding.top,
|
||||
child: buildTop(),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
bottom: _isOpen ? 0 : -(kBottomBarHeight + context.padding.bottom),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: kBottomBarHeight + context.padding.bottom,
|
||||
child: buildBottom(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTop() {
|
||||
return BlurEffect(
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(top: context.padding.top),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(context.reader.widget.name, style: ts.s18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBottom() {
|
||||
var text = "E${context.reader.chapter} : P${context.reader.page}";
|
||||
if (context.reader.widget.chapters == null) {
|
||||
text = "P${context.reader.page}";
|
||||
}
|
||||
|
||||
Widget child = SizedBox(
|
||||
height: kBottomBarHeight + MediaQuery.of(context).padding.bottom,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
onPressed: context.reader.toPrevChapter,
|
||||
icon: const Icon(Icons.first_page),
|
||||
),
|
||||
Expanded(
|
||||
child: buildSlider(),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: context.reader.toNextChapter,
|
||||
icon: const Icon(Icons.last_page)),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text),
|
||||
),
|
||||
const Spacer(),
|
||||
if (App.isWindows)
|
||||
Tooltip(
|
||||
message: "${"Full Screen".tl}(F12)",
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.fullscreen),
|
||||
onPressed: () {
|
||||
context.reader.fullscreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (App.isAndroid)
|
||||
Tooltip(
|
||||
message: "Screen Rotation".tl,
|
||||
child: IconButton(
|
||||
icon: () {
|
||||
if (rotation == null) {
|
||||
return const Icon(Icons.screen_rotation);
|
||||
} else if (rotation == false) {
|
||||
return const Icon(Icons.screen_lock_portrait);
|
||||
} else {
|
||||
return const Icon(Icons.screen_lock_landscape);
|
||||
}
|
||||
}.call(),
|
||||
onPressed: () {
|
||||
if (rotation == null) {
|
||||
setState(() {
|
||||
rotation = false;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
} else if (rotation == false) {
|
||||
setState(() {
|
||||
rotation = true;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight
|
||||
]);
|
||||
} else {
|
||||
setState(() {
|
||||
rotation = null;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations(
|
||||
DeviceOrientation.values);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Auto Page Turning".tl,
|
||||
child: IconButton(
|
||||
icon: context.reader.autoPageTurningTimer != null
|
||||
? const Icon(Icons.timer)
|
||||
: const Icon(Icons.timer_sharp),
|
||||
onPressed: context.reader.autoPageTurning,
|
||||
),
|
||||
),
|
||||
if (context.reader.widget.chapters != null)
|
||||
Tooltip(
|
||||
message: "Chapters".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.library_books),
|
||||
onPressed: openChapterDrawer,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Save Image".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: saveCurrentImage,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Share".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: share,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return BlurEffect(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSlider() {
|
||||
return Slider(
|
||||
value: context.reader.page.toDouble(),
|
||||
min: 1,
|
||||
max: context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
|
||||
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
||||
onChanged: (i) {
|
||||
context.reader.toPage(i.toInt());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPageInfoText() {
|
||||
var epName = context.reader.widget.chapters?.values
|
||||
.elementAt(context.reader.chapter - 1) ??
|
||||
"E${context.reader.chapter}";
|
||||
if (epName.length > 8) {
|
||||
epName = "${epName.substring(0, 8)}...";
|
||||
}
|
||||
var pageText = "${context.reader.page}/${context.reader.maxPage}";
|
||||
var text = context.reader.widget.chapters != null
|
||||
? "$epName : $pageText"
|
||||
: pageText;
|
||||
|
||||
return Positioned(
|
||||
bottom: 13,
|
||||
left: 25,
|
||||
child: Stack(
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
foreground: Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4
|
||||
..color = context.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void openChapterDrawer() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void saveCurrentImage() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void share() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
void openSetting() {
|
||||
// TODO
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user